aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext/ContentAssistService.ts
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-13 02:07:04 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-14 02:14:23 +0100
commita96c52b21e7e590bbdd70b80896780a446fa2e8b (patch)
tree663619baa254577bb2f5342192e80bca692ad91d /subprojects/frontend/src/xtext/ContentAssistService.ts
parentbuild: move modules into subproject directory (diff)
downloadrefinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.gz
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.tar.zst
refinery-a96c52b21e7e590bbdd70b80896780a446fa2e8b.zip
build: separate module for frontend
This allows us to simplify the webpack configuration and the gradle build scripts.
Diffstat (limited to 'subprojects/frontend/src/xtext/ContentAssistService.ts')
-rw-r--r--subprojects/frontend/src/xtext/ContentAssistService.ts219
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 @@
1import type {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
6import { syntaxTree } from '@codemirror/language';
7import type { Transaction } from '@codemirror/state';
8import escapeStringRegexp from 'escape-string-regexp';
9
10import { implicitCompletion } from '../language/props';
11import type { UpdateService } from './UpdateService';
12import { getLogger } from '../utils/logger';
13import type { ContentAssistEntry } from './xtextServiceResults';
14
15const PROPOSALS_LIMIT = 1000;
16
17const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
18
19const HIGH_PRIORITY_KEYWORDS = ['<->', '~>'];
20
21const log = getLogger('xtext.ContentAssistService');
22
23interface IFoundToken {
24 from: number;
25
26 to: number;
27
28 implicitCompletion: boolean;
29
30 text: string;
31}
32
33function 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
51function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean {
52 return token !== null
53 && token.implicitCompletion
54 && context.pos - token.from >= 2;
55}
56
57function 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
70function 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
97export 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}