aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js/xtext/ContentAssistService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'language-web/src/main/js/xtext/ContentAssistService.ts')
-rw-r--r--language-web/src/main/js/xtext/ContentAssistService.ts177
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 @@
1import type {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
6import type { Transaction } from '@codemirror/state';
7import escapeStringRegexp from 'escape-string-regexp';
8
9import type { UpdateService } from './UpdateService';
10import { getLogger } from '../utils/logger';
11import type { IContentAssistEntry } from './xtextServiceResults';
12
13const PROPOSALS_LIMIT = 1000;
14
15const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
16
17const HIGH_PRIORITY_KEYWORDS = ['<->'];
18
19const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g;
20
21const log = getLogger('xtext.ContentAssistService');
22
23function 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
50function 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
63export 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}