diff options
Diffstat (limited to 'language-web/src/main/js/xtext/ContentAssistService.ts')
-rw-r--r-- | language-web/src/main/js/xtext/ContentAssistService.ts | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts new file mode 100644 index 00000000..f085c5b1 --- /dev/null +++ b/language-web/src/main/js/xtext/ContentAssistService.ts | |||
@@ -0,0 +1,177 @@ | |||
1 | import type { | ||
2 | Completion, | ||
3 | CompletionContext, | ||
4 | CompletionResult, | ||
5 | } from '@codemirror/autocomplete'; | ||
6 | import type { Transaction } from '@codemirror/state'; | ||
7 | import escapeStringRegexp from 'escape-string-regexp'; | ||
8 | |||
9 | import type { UpdateService } from './UpdateService'; | ||
10 | import { getLogger } from '../utils/logger'; | ||
11 | import type { IContentAssistEntry } from './xtextServiceResults'; | ||
12 | |||
13 | const PROPOSALS_LIMIT = 1000; | ||
14 | |||
15 | const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; | ||
16 | |||
17 | const HIGH_PRIORITY_KEYWORDS = ['<->']; | ||
18 | |||
19 | const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g; | ||
20 | |||
21 | const log = getLogger('xtext.ContentAssistService'); | ||
22 | |||
23 | function createCompletion(entry: IContentAssistEntry): Completion { | ||
24 | let boost; | ||
25 | switch (entry.kind) { | ||
26 | case 'KEYWORD': | ||
27 | // Some hard-to-type operators should be on top. | ||
28 | boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99; | ||
29 | break; | ||
30 | case 'TEXT': | ||
31 | case 'SNIPPET': | ||
32 | boost = -90; | ||
33 | break; | ||
34 | default: { | ||
35 | // Penalize qualified names (vs available unqualified names). | ||
36 | const extraSegments = entry.proposal.match(QUALIFIED_NAME_SEPARATOR_REGEXP)?.length || 0; | ||
37 | boost = Math.max(-5 * extraSegments, -50); | ||
38 | } | ||
39 | break; | ||
40 | } | ||
41 | return { | ||
42 | label: entry.proposal, | ||
43 | detail: entry.description, | ||
44 | info: entry.documentation, | ||
45 | type: entry.kind?.toLowerCase(), | ||
46 | boost, | ||
47 | }; | ||
48 | } | ||
49 | |||
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 { | ||
64 | private readonly updateService: UpdateService; | ||
65 | |||
66 | private lastCompletion: CompletionResult | null = null; | ||
67 | |||
68 | constructor(updateService: UpdateService) { | ||
69 | this.updateService = updateService; | ||
70 | } | ||
71 | |||
72 | onTransaction(transaction: Transaction): void { | ||
73 | if (this.shouldInvalidateCachedCompletion(transaction)) { | ||
74 | this.lastCompletion = null; | ||
75 | } | ||
76 | } | ||
77 | |||
78 | async contentAssist(context: CompletionContext): Promise<CompletionResult> { | ||
79 | const tokenBefore = context.tokenBefore(['QualifiedName']); | ||
80 | let range: { from: number, to: number }; | ||
81 | let prefix = ''; | ||
82 | if (tokenBefore === null) { | ||
83 | if (!context.explicit) { | ||
84 | return { | ||
85 | from: context.pos, | ||
86 | options: [], | ||
87 | }; | ||
88 | } | ||
89 | range = { | ||
90 | from: context.pos, | ||
91 | to: context.pos, | ||
92 | }; | ||
93 | prefix = ''; | ||
94 | } else { | ||
95 | range = { | ||
96 | from: tokenBefore.from, | ||
97 | to: tokenBefore.to, | ||
98 | }; | ||
99 | const prefixLength = context.pos - tokenBefore.from; | ||
100 | if (prefixLength > 0) { | ||
101 | prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from); | ||
102 | } | ||
103 | } | ||
104 | if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) { | ||
105 | log.trace('Returning cached completion result'); | ||
106 | // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` | ||
107 | return { | ||
108 | ...this.lastCompletion as CompletionResult, | ||
109 | ...range, | ||
110 | }; | ||
111 | } | ||
112 | this.lastCompletion = null; | ||
113 | const entries = await this.updateService.fetchContentAssist({ | ||
114 | resource: this.updateService.resourceName, | ||
115 | serviceType: 'assist', | ||
116 | caretOffset: context.pos, | ||
117 | proposalsLimit: PROPOSALS_LIMIT, | ||
118 | }, context); | ||
119 | if (context.aborted) { | ||
120 | return { | ||
121 | ...range, | ||
122 | options: [], | ||
123 | }; | ||
124 | } | ||
125 | const options: Completion[] = []; | ||
126 | entries.forEach((entry) => { | ||
127 | options.push(createCompletion(entry)); | ||
128 | }); | ||
129 | log.debug('Fetched', options.length, 'completions from server'); | ||
130 | this.lastCompletion = { | ||
131 | ...range, | ||
132 | options, | ||
133 | span: computeSpan(prefix, entries.length), | ||
134 | }; | ||
135 | return this.lastCompletion; | ||
136 | } | ||
137 | |||
138 | private shouldReturnCachedCompletion( | ||
139 | token: { from: number, to: number, text: string } | null, | ||
140 | ) { | ||
141 | if (token === null || this.lastCompletion === null) { | ||
142 | return false; | ||
143 | } | ||
144 | const { from, to, text } = token; | ||
145 | const { from: lastFrom, to: lastTo, span } = this.lastCompletion; | ||
146 | if (!lastTo) { | ||
147 | return true; | ||
148 | } | ||
149 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); | ||
150 | return from >= transformedFrom && to <= transformedTo && span && span.exec(text); | ||
151 | } | ||
152 | |||
153 | private shouldInvalidateCachedCompletion(transaction: Transaction) { | ||
154 | if (!transaction.docChanged || this.lastCompletion === null) { | ||
155 | return false; | ||
156 | } | ||
157 | const { from: lastFrom, to: lastTo } = this.lastCompletion; | ||
158 | if (!lastTo) { | ||
159 | return true; | ||
160 | } | ||
161 | const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); | ||
162 | let invalidate = false; | ||
163 | transaction.changes.iterChangedRanges((fromA, toA) => { | ||
164 | if (fromA < transformedFrom || toA > transformedTo) { | ||
165 | invalidate = true; | ||
166 | } | ||
167 | }); | ||
168 | return invalidate; | ||
169 | } | ||
170 | |||
171 | private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] { | ||
172 | const changes = this.updateService.computeChangesSinceLastUpdate(); | ||
173 | const transformedFrom = changes.mapPos(lastFrom); | ||
174 | const transformedTo = changes.mapPos(lastTo, 1); | ||
175 | return [transformedFrom, transformedTo]; | ||
176 | } | ||
177 | } | ||