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.ts98
1 files changed, 70 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..aa9a80b0 100644
--- a/language-web/src/main/js/xtext/ContentAssistService.ts
+++ b/language-web/src/main/js/xtext/ContentAssistService.ts
@@ -3,9 +3,11 @@ import type {
3 CompletionContext, 3 CompletionContext,
4 CompletionResult, 4 CompletionResult,
5} from '@codemirror/autocomplete'; 5} from '@codemirror/autocomplete';
6import { syntaxTree } from '@codemirror/language';
6import type { Transaction } from '@codemirror/state'; 7import type { Transaction } from '@codemirror/state';
7import escapeStringRegexp from 'escape-string-regexp'; 8import escapeStringRegexp from 'escape-string-regexp';
8 9
10import { implicitCompletion } from '../language/props';
9import type { UpdateService } from './UpdateService'; 11import type { UpdateService } from './UpdateService';
10import { getLogger } from '../utils/logger'; 12import { getLogger } from '../utils/logger';
11import type { IContentAssistEntry } from './xtextServiceResults'; 13import type { IContentAssistEntry } from './xtextServiceResults';
@@ -14,12 +16,57 @@ const PROPOSALS_LIMIT = 1000;
14 16
15const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; 17const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
16 18
17const HIGH_PRIORITY_KEYWORDS = ['<->']; 19const HIGH_PRIORITY_KEYWORDS = ['<->', '~>'];
18
19const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g;
20 20
21const log = getLogger('xtext.ContentAssistService'); 21const log = getLogger('xtext.ContentAssistService');
22 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
23function createCompletion(entry: IContentAssistEntry): Completion { 70function createCompletion(entry: IContentAssistEntry): Completion {
24 let boost; 71 let boost;
25 switch (entry.kind) { 72 switch (entry.kind) {
@@ -33,7 +80,7 @@ function createCompletion(entry: IContentAssistEntry): Completion {
33 break; 80 break;
34 default: { 81 default: {
35 // Penalize qualified names (vs available unqualified names). 82 // Penalize qualified names (vs available unqualified names).
36 const extraSegments = entry.proposal.match(QUALIFIED_NAME_SEPARATOR_REGEXP)?.length || 0; 83 const extraSegments = entry.proposal.match(/::/g)?.length || 0;
37 boost = Math.max(-5 * extraSegments, -50); 84 boost = Math.max(-5 * extraSegments, -50);
38 } 85 }
39 break; 86 break;
@@ -47,19 +94,6 @@ function createCompletion(entry: IContentAssistEntry): Completion {
47 }; 94 };
48} 95}
49 96
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 { 97export class ContentAssistService {
64 private readonly updateService: UpdateService; 98 private readonly updateService: UpdateService;
65 99
@@ -76,16 +110,16 @@ export class ContentAssistService {
76 } 110 }
77 111
78 async contentAssist(context: CompletionContext): Promise<CompletionResult> { 112 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
79 const tokenBefore = context.tokenBefore(['QualifiedName']); 113 const tokenBefore = findToken(context);
114 if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) {
115 return {
116 from: context.pos,
117 options: [],
118 };
119 }
80 let range: { from: number, to: number }; 120 let range: { from: number, to: number };
81 let prefix = ''; 121 let prefix = '';
82 if (tokenBefore === null) { 122 if (tokenBefore === null) {
83 if (!context.explicit) {
84 return {
85 from: context.pos,
86 options: [],
87 };
88 }
89 range = { 123 range = {
90 from: context.pos, 124 from: context.pos,
91 to: context.pos, 125 to: context.pos,
@@ -124,7 +158,12 @@ export class ContentAssistService {
124 } 158 }
125 const options: Completion[] = []; 159 const options: Completion[] = [];
126 entries.forEach((entry) => { 160 entries.forEach((entry) => {
127 options.push(createCompletion(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 }
128 }); 167 });
129 log.debug('Fetched', options.length, 'completions from server'); 168 log.debug('Fetched', options.length, 'completions from server');
130 this.lastCompletion = { 169 this.lastCompletion = {
@@ -137,7 +176,7 @@ export class ContentAssistService {
137 176
138 private shouldReturnCachedCompletion( 177 private shouldReturnCachedCompletion(
139 token: { from: number, to: number, text: string } | null, 178 token: { from: number, to: number, text: string } | null,
140 ) { 179 ): boolean {
141 if (token === null || this.lastCompletion === null) { 180 if (token === null || this.lastCompletion === null) {
142 return false; 181 return false;
143 } 182 }
@@ -147,10 +186,13 @@ export class ContentAssistService {
147 return true; 186 return true;
148 } 187 }
149 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); 188 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
150 return from >= transformedFrom && to <= transformedTo && span && span.exec(text); 189 return from >= transformedFrom
190 && to <= transformedTo
191 && typeof span !== 'undefined'
192 && span.exec(text) !== null;
151 } 193 }
152 194
153 private shouldInvalidateCachedCompletion(transaction: Transaction) { 195 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean {
154 if (!transaction.docChanged || this.lastCompletion === null) { 196 if (!transaction.docChanged || this.lastCompletion === null) {
155 return false; 197 return false;
156 } 198 }