diff options
Diffstat (limited to 'language-web/src/main/js')
-rw-r--r-- | language-web/src/main/js/xtext/ContentAssistService.ts | 105 |
1 files changed, 77 insertions, 28 deletions
diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts index f085c5b1..65381b21 100644 --- a/language-web/src/main/js/xtext/ContentAssistService.ts +++ b/language-web/src/main/js/xtext/ContentAssistService.ts | |||
@@ -3,6 +3,7 @@ import type { | |||
3 | CompletionContext, | 3 | CompletionContext, |
4 | CompletionResult, | 4 | CompletionResult, |
5 | } from '@codemirror/autocomplete'; | 5 | } from '@codemirror/autocomplete'; |
6 | import { syntaxTree } from '@codemirror/language'; | ||
6 | import type { Transaction } from '@codemirror/state'; | 7 | import type { Transaction } from '@codemirror/state'; |
7 | import escapeStringRegexp from 'escape-string-regexp'; | 8 | import escapeStringRegexp from 'escape-string-regexp'; |
8 | 9 | ||
@@ -10,16 +11,69 @@ import type { UpdateService } from './UpdateService'; | |||
10 | import { getLogger } from '../utils/logger'; | 11 | import { getLogger } from '../utils/logger'; |
11 | import type { IContentAssistEntry } from './xtextServiceResults'; | 12 | import type { IContentAssistEntry } from './xtextServiceResults'; |
12 | 13 | ||
14 | const IMPLICIT_COMPLETION_TOKENS = [ | ||
15 | 'QualifiedName', | ||
16 | 'true', | ||
17 | 'false', | ||
18 | 'unknown', | ||
19 | 'error', | ||
20 | ]; | ||
21 | |||
13 | const PROPOSALS_LIMIT = 1000; | 22 | const PROPOSALS_LIMIT = 1000; |
14 | 23 | ||
15 | const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; | 24 | const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; |
16 | 25 | ||
17 | const HIGH_PRIORITY_KEYWORDS = ['<->']; | 26 | const HIGH_PRIORITY_KEYWORDS = ['<->', '~>']; |
18 | |||
19 | const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g; | ||
20 | 27 | ||
21 | const log = getLogger('xtext.ContentAssistService'); | 28 | const log = getLogger('xtext.ContentAssistService'); |
22 | 29 | ||
30 | interface IFoundToken { | ||
31 | from: number; | ||
32 | |||
33 | to: number; | ||
34 | |||
35 | name: string; | ||
36 | |||
37 | text: string; | ||
38 | } | ||
39 | |||
40 | function findToken({ pos, state }: CompletionContext): IFoundToken | null { | ||
41 | const token = syntaxTree(state).resolveInner(pos, -1); | ||
42 | if (token === null) { | ||
43 | return null; | ||
44 | } | ||
45 | if (token.firstChild !== null) { | ||
46 | // We only autocomplete terminal nodes. If the current node is nonterminal, | ||
47 | // returning `null` makes us autocomplete with the empty prefix instead. | ||
48 | return null; | ||
49 | } | ||
50 | return { | ||
51 | from: token.from, | ||
52 | to: token.to, | ||
53 | name: token.name, | ||
54 | text: state.sliceDoc(token.from, token.to), | ||
55 | }; | ||
56 | } | ||
57 | |||
58 | function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { | ||
59 | return token !== null | ||
60 | && IMPLICIT_COMPLETION_TOKENS.includes(token.name) | ||
61 | && context.pos - token.from >= 2; | ||
62 | } | ||
63 | |||
64 | function computeSpan(prefix: string, entryCount: number): RegExp { | ||
65 | const escapedPrefix = escapeStringRegexp(prefix); | ||
66 | if (entryCount < PROPOSALS_LIMIT) { | ||
67 | // Proposals with the current prefix fit the proposals limit. | ||
68 | // We can filter client side as long as the current prefix is preserved. | ||
69 | return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); | ||
70 | } | ||
71 | // The current prefix overflows the proposals limits, | ||
72 | // so we have to fetch the completions again on the next keypress. | ||
73 | // Hopefully, it'll return a shorter list and we'll be able to filter client side. | ||
74 | return new RegExp(`^${escapedPrefix}$`); | ||
75 | } | ||
76 | |||
23 | function createCompletion(entry: IContentAssistEntry): Completion { | 77 | function createCompletion(entry: IContentAssistEntry): Completion { |
24 | let boost; | 78 | let boost; |
25 | switch (entry.kind) { | 79 | switch (entry.kind) { |
@@ -33,7 +87,7 @@ function createCompletion(entry: IContentAssistEntry): Completion { | |||
33 | break; | 87 | break; |
34 | default: { | 88 | default: { |
35 | // Penalize qualified names (vs available unqualified names). | 89 | // Penalize qualified names (vs available unqualified names). |
36 | const extraSegments = entry.proposal.match(QUALIFIED_NAME_SEPARATOR_REGEXP)?.length || 0; | 90 | const extraSegments = entry.proposal.match(/::/g)?.length || 0; |
37 | boost = Math.max(-5 * extraSegments, -50); | 91 | boost = Math.max(-5 * extraSegments, -50); |
38 | } | 92 | } |
39 | break; | 93 | break; |
@@ -47,19 +101,6 @@ function createCompletion(entry: IContentAssistEntry): Completion { | |||
47 | }; | 101 | }; |
48 | } | 102 | } |
49 | 103 | ||
50 | function computeSpan(prefix: string, entryCount: number) { | ||
51 | const escapedPrefix = escapeStringRegexp(prefix); | ||
52 | if (entryCount < PROPOSALS_LIMIT) { | ||
53 | // Proposals with the current prefix fit the proposals limit. | ||
54 | // We can filter client side as long as the current prefix is preserved. | ||
55 | return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); | ||
56 | } | ||
57 | // The current prefix overflows the proposals limits, | ||
58 | // so we have to fetch the completions again on the next keypress. | ||
59 | // Hopefully, it'll return a shorter list and we'll be able to filter client side. | ||
60 | return new RegExp(`^${escapedPrefix}$`); | ||
61 | } | ||
62 | |||
63 | export class ContentAssistService { | 104 | export class ContentAssistService { |
64 | private readonly updateService: UpdateService; | 105 | private readonly updateService: UpdateService; |
65 | 106 | ||
@@ -76,16 +117,16 @@ export class ContentAssistService { | |||
76 | } | 117 | } |
77 | 118 | ||
78 | async contentAssist(context: CompletionContext): Promise<CompletionResult> { | 119 | async contentAssist(context: CompletionContext): Promise<CompletionResult> { |
79 | const tokenBefore = context.tokenBefore(['QualifiedName']); | 120 | const tokenBefore = findToken(context); |
121 | if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) { | ||
122 | return { | ||
123 | from: context.pos, | ||
124 | options: [], | ||
125 | }; | ||
126 | } | ||
80 | let range: { from: number, to: number }; | 127 | let range: { from: number, to: number }; |
81 | let prefix = ''; | 128 | let prefix = ''; |
82 | if (tokenBefore === null) { | 129 | if (tokenBefore === null) { |
83 | if (!context.explicit) { | ||
84 | return { | ||
85 | from: context.pos, | ||
86 | options: [], | ||
87 | }; | ||
88 | } | ||
89 | range = { | 130 | range = { |
90 | from: context.pos, | 131 | from: context.pos, |
91 | to: context.pos, | 132 | to: context.pos, |
@@ -124,7 +165,12 @@ export class ContentAssistService { | |||
124 | } | 165 | } |
125 | const options: Completion[] = []; | 166 | const options: Completion[] = []; |
126 | entries.forEach((entry) => { | 167 | entries.forEach((entry) => { |
127 | options.push(createCompletion(entry)); | 168 | if (prefix === entry.prefix) { |
169 | // Xtext will generate completions that do not complete the current token, | ||
170 | // e.g., `(` after trying to complete an indetifier, | ||
171 | // but we ignore those, since CodeMirror won't filter for them anyways. | ||
172 | options.push(createCompletion(entry)); | ||
173 | } | ||
128 | }); | 174 | }); |
129 | log.debug('Fetched', options.length, 'completions from server'); | 175 | log.debug('Fetched', options.length, 'completions from server'); |
130 | this.lastCompletion = { | 176 | this.lastCompletion = { |
@@ -137,7 +183,7 @@ export class ContentAssistService { | |||
137 | 183 | ||
138 | private shouldReturnCachedCompletion( | 184 | private shouldReturnCachedCompletion( |
139 | token: { from: number, to: number, text: string } | null, | 185 | token: { from: number, to: number, text: string } | null, |
140 | ) { | 186 | ): boolean { |
141 | if (token === null || this.lastCompletion === null) { | 187 | if (token === null || this.lastCompletion === null) { |
142 | return false; | 188 | return false; |
143 | } | 189 | } |
@@ -147,10 +193,13 @@ export class ContentAssistService { | |||
147 | return true; | 193 | return true; |
148 | } | 194 | } |
149 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); | 195 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); |
150 | return from >= transformedFrom && to <= transformedTo && span && span.exec(text); | 196 | return from >= transformedFrom |
197 | && to <= transformedTo | ||
198 | && typeof span !== 'undefined' | ||
199 | && span.exec(text) !== null; | ||
151 | } | 200 | } |
152 | 201 | ||
153 | private shouldInvalidateCachedCompletion(transaction: Transaction) { | 202 | private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { |
154 | if (!transaction.docChanged || this.lastCompletion === null) { | 203 | if (!transaction.docChanged || this.lastCompletion === null) { |
155 | return false; | 204 | return false; |
156 | } | 205 | } |