diff options
Diffstat (limited to 'subprojects/frontend/src/xtext/ContentAssistService.ts')
-rw-r--r-- | subprojects/frontend/src/xtext/ContentAssistService.ts | 219 |
1 files changed, 219 insertions, 0 deletions
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts new file mode 100644 index 00000000..8b872e06 --- /dev/null +++ b/subprojects/frontend/src/xtext/ContentAssistService.ts | |||
@@ -0,0 +1,219 @@ | |||
1 | import type { | ||
2 | Completion, | ||
3 | CompletionContext, | ||
4 | CompletionResult, | ||
5 | } from '@codemirror/autocomplete'; | ||
6 | import { syntaxTree } from '@codemirror/language'; | ||
7 | import type { Transaction } from '@codemirror/state'; | ||
8 | import escapeStringRegexp from 'escape-string-regexp'; | ||
9 | |||
10 | import { implicitCompletion } from '../language/props'; | ||
11 | import type { UpdateService } from './UpdateService'; | ||
12 | import { getLogger } from '../utils/logger'; | ||
13 | import type { ContentAssistEntry } from './xtextServiceResults'; | ||
14 | |||
15 | const PROPOSALS_LIMIT = 1000; | ||
16 | |||
17 | const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; | ||
18 | |||
19 | const HIGH_PRIORITY_KEYWORDS = ['<->', '~>']; | ||
20 | |||
21 | const log = getLogger('xtext.ContentAssistService'); | ||
22 | |||
23 | interface IFoundToken { | ||
24 | from: number; | ||
25 | |||
26 | to: number; | ||
27 | |||
28 | implicitCompletion: boolean; | ||
29 | |||
30 | text: string; | ||
31 | } | ||
32 | |||
33 | function findToken({ pos, state }: CompletionContext): IFoundToken | null { | ||
34 | const token = syntaxTree(state).resolveInner(pos, -1); | ||
35 | if (token === null) { | ||
36 | return null; | ||
37 | } | ||
38 | if (token.firstChild !== null) { | ||
39 | // We only autocomplete terminal nodes. If the current node is nonterminal, | ||
40 | // returning `null` makes us autocomplete with the empty prefix instead. | ||
41 | return null; | ||
42 | } | ||
43 | return { | ||
44 | from: token.from, | ||
45 | to: token.to, | ||
46 | implicitCompletion: token.type.prop(implicitCompletion) || false, | ||
47 | text: state.sliceDoc(token.from, token.to), | ||
48 | }; | ||
49 | } | ||
50 | |||
51 | function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { | ||
52 | return token !== null | ||
53 | && token.implicitCompletion | ||
54 | && context.pos - token.from >= 2; | ||
55 | } | ||
56 | |||
57 | function computeSpan(prefix: string, entryCount: number): RegExp { | ||
58 | const escapedPrefix = escapeStringRegexp(prefix); | ||
59 | if (entryCount < PROPOSALS_LIMIT) { | ||
60 | // Proposals with the current prefix fit the proposals limit. | ||
61 | // We can filter client side as long as the current prefix is preserved. | ||
62 | return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); | ||
63 | } | ||
64 | // The current prefix overflows the proposals limits, | ||
65 | // so we have to fetch the completions again on the next keypress. | ||
66 | // Hopefully, it'll return a shorter list and we'll be able to filter client side. | ||
67 | return new RegExp(`^${escapedPrefix}$`); | ||
68 | } | ||
69 | |||
70 | function createCompletion(entry: ContentAssistEntry): Completion { | ||
71 | let boost: number; | ||
72 | switch (entry.kind) { | ||
73 | case 'KEYWORD': | ||
74 | // Some hard-to-type operators should be on top. | ||
75 | boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99; | ||
76 | break; | ||
77 | case 'TEXT': | ||
78 | case 'SNIPPET': | ||
79 | boost = -90; | ||
80 | break; | ||
81 | default: { | ||
82 | // Penalize qualified names (vs available unqualified names). | ||
83 | const extraSegments = entry.proposal.match(/::/g)?.length || 0; | ||
84 | boost = Math.max(-5 * extraSegments, -50); | ||
85 | } | ||
86 | break; | ||
87 | } | ||
88 | return { | ||
89 | label: entry.proposal, | ||
90 | detail: entry.description, | ||
91 | info: entry.documentation, | ||
92 | type: entry.kind?.toLowerCase(), | ||
93 | boost, | ||
94 | }; | ||
95 | } | ||
96 | |||
97 | export class ContentAssistService { | ||
98 | private readonly updateService: UpdateService; | ||
99 | |||
100 | private lastCompletion: CompletionResult | null = null; | ||
101 | |||
102 | constructor(updateService: UpdateService) { | ||
103 | this.updateService = updateService; | ||
104 | } | ||
105 | |||
106 | onTransaction(transaction: Transaction): void { | ||
107 | if (this.shouldInvalidateCachedCompletion(transaction)) { | ||
108 | this.lastCompletion = null; | ||
109 | } | ||
110 | } | ||
111 | |||
112 | async contentAssist(context: CompletionContext): Promise<CompletionResult> { | ||
113 | const tokenBefore = findToken(context); | ||
114 | if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) { | ||
115 | return { | ||
116 | from: context.pos, | ||
117 | options: [], | ||
118 | }; | ||
119 | } | ||
120 | let range: { from: number, to: number }; | ||
121 | let prefix = ''; | ||
122 | if (tokenBefore === null) { | ||
123 | range = { | ||
124 | from: context.pos, | ||
125 | to: context.pos, | ||
126 | }; | ||
127 | prefix = ''; | ||
128 | } else { | ||
129 | range = { | ||
130 | from: tokenBefore.from, | ||
131 | to: tokenBefore.to, | ||
132 | }; | ||
133 | const prefixLength = context.pos - tokenBefore.from; | ||
134 | if (prefixLength > 0) { | ||
135 | prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from); | ||
136 | } | ||
137 | } | ||
138 | if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) { | ||
139 | log.trace('Returning cached completion result'); | ||
140 | // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` | ||
141 | return { | ||
142 | ...this.lastCompletion as CompletionResult, | ||
143 | ...range, | ||
144 | }; | ||
145 | } | ||
146 | this.lastCompletion = null; | ||
147 | const entries = await this.updateService.fetchContentAssist({ | ||
148 | resource: this.updateService.resourceName, | ||
149 | serviceType: 'assist', | ||
150 | caretOffset: context.pos, | ||
151 | proposalsLimit: PROPOSALS_LIMIT, | ||
152 | }, context); | ||
153 | if (context.aborted) { | ||
154 | return { | ||
155 | ...range, | ||
156 | options: [], | ||
157 | }; | ||
158 | } | ||
159 | const options: Completion[] = []; | ||
160 | entries.forEach((entry) => { | ||
161 | if (prefix === entry.prefix) { | ||
162 | // Xtext will generate completions that do not complete the current token, | ||
163 | // e.g., `(` after trying to complete an indetifier, | ||
164 | // but we ignore those, since CodeMirror won't filter for them anyways. | ||
165 | options.push(createCompletion(entry)); | ||
166 | } | ||
167 | }); | ||
168 | log.debug('Fetched', options.length, 'completions from server'); | ||
169 | this.lastCompletion = { | ||
170 | ...range, | ||
171 | options, | ||
172 | span: computeSpan(prefix, entries.length), | ||
173 | }; | ||
174 | return this.lastCompletion; | ||
175 | } | ||
176 | |||
177 | private shouldReturnCachedCompletion( | ||
178 | token: { from: number, to: number, text: string } | null, | ||
179 | ): boolean { | ||
180 | if (token === null || this.lastCompletion === null) { | ||
181 | return false; | ||
182 | } | ||
183 | const { from, to, text } = token; | ||
184 | const { from: lastFrom, to: lastTo, span } = this.lastCompletion; | ||
185 | if (!lastTo) { | ||
186 | return true; | ||
187 | } | ||
188 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); | ||
189 | return from >= transformedFrom | ||
190 | && to <= transformedTo | ||
191 | && typeof span !== 'undefined' | ||
192 | && span.exec(text) !== null; | ||
193 | } | ||
194 | |||
195 | private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { | ||
196 | if (!transaction.docChanged || this.lastCompletion === null) { | ||
197 | return false; | ||
198 | } | ||
199 | const { from: lastFrom, to: lastTo } = this.lastCompletion; | ||
200 | if (!lastTo) { | ||
201 | return true; | ||
202 | } | ||
203 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); | ||
204 | let invalidate = false; | ||
205 | transaction.changes.iterChangedRanges((fromA, toA) => { | ||
206 | if (fromA < transformedFrom || toA > transformedTo) { | ||
207 | invalidate = true; | ||
208 | } | ||
209 | }); | ||
210 | return invalidate; | ||
211 | } | ||
212 | |||
213 | private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] { | ||
214 | const changes = this.updateService.computeChangesSinceLastUpdate(); | ||
215 | const transformedFrom = changes.mapPos(lastFrom); | ||
216 | const transformedTo = changes.mapPos(lastTo, 1); | ||
217 | return [transformedFrom, transformedTo]; | ||
218 | } | ||
219 | } | ||