Skip to content

Commit

Permalink
refactor(frontend): improve content assist robustness
Browse files Browse the repository at this point in the history
  • Loading branch information
kris7t committed Dec 14, 2024
1 parent 6f3c6d5 commit 66bff71
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 27 deletions.
84 changes: 63 additions & 21 deletions subprojects/frontend/src/xtext/ContentAssistService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
} from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
import type { Transaction } from '@codemirror/state';
import type { SyntaxNode } from '@lezer/common';
import escapeStringRegexp from 'escape-string-regexp';

import { implicitCompletion } from '../language/props';
Expand Down Expand Up @@ -38,32 +39,36 @@ interface IFoundToken {
}

function findToken({ pos, state }: CompletionContext): IFoundToken | undefined {
const token = syntaxTree(state).resolveInner(pos, -1);
const { from } = token;
if (from > pos) {
let token = syntaxTree(state).resolveInner(pos, -1);
let qualifiedName: SyntaxNode | null = token;
while (
qualifiedName !== null &&
qualifiedName.type.name !== 'QualifiedName'
) {
qualifiedName = qualifiedName.parent;
}
if (qualifiedName !== null) {
token = qualifiedName;
}
const isQualifiedName = token.type.name === 'QualifiedName';
if (!isQualifiedName && token.firstChild !== null) {
// Only complete terminals and qualified names.
return undefined;
}
const { from, to: endIndex } = token;
if (from > pos || endIndex < pos) {
// We haven't found the token we want to complete.
// Complete with an empty prefix from `pos` instead.
// The other `return undefined;` lines also handle this condition.
return undefined;
}
// We look at the text at the beginning of the token.
// For QualifiedName tokens right before a comment, this may be a comment token.
const endIndex = token.firstChild?.from ?? token.to;
if (pos > endIndex) {
return undefined;
}
const text = state.sliceDoc(from, endIndex).trimEnd();
// Due to parser error recovery, we may get spurious whitespace
// at the end of the token.
const to = from + text.length;
if (to > endIndex) {
return undefined;
}
if (from > pos || endIndex < pos) {
// We haven't found the token we want to complete.
// Complete with an empty prefix from `pos` instead.
return undefined;
}
return {
from,
to,
Expand Down Expand Up @@ -96,7 +101,17 @@ function computeSpan(prefix: string, entryCount: number): RegExp {
return new RegExp(`^${escapedPrefix}$`);
}

function createCompletion(entry: ContentAssistEntry): Completion {
function createCompletion(
prefix: string,
entry: ContentAssistEntry,
): Completion | undefined {
if (!prefix.endsWith(entry.prefix)) {
// Since CodeMirror will fuzzy match all entries, we only work with completions that match
// some suffix of the current prefix according to Xtext. The remaining part of the prefix
// will be added to each completion using `remainingPrefix` to make the fuzzy match successful
// and avoid replacing the current prefix in the editor.
return undefined;
}
let boost: number;
let type: string | undefined;
switch (entry.kind) {
Expand All @@ -121,8 +136,11 @@ function createCompletion(entry: ContentAssistEntry): Completion {
}
break;
}
// The server thinks this part of the prefix is not needed, but we need to add it back to satisfy CodeMirror.
const remainingPrefix = prefix.slice(0, prefix.length - entry.prefix.length);
const completion: Completion = {
label: entry.proposal,
label: remainingPrefix + entry.proposal,
displayLabel: entry.proposal,
type: type ?? entry.kind?.toLowerCase(),
boost,
};
Expand All @@ -144,6 +162,31 @@ function createCompletion(entry: ContentAssistEntry): Completion {
return completion;
}

function getMatch(
completion: Completion,
matched?: readonly number[],
): readonly number[] {
if (matched === undefined || matched.length < 2) {
return [];
}
if (completion.displayLabel === undefined) {
return matched;
}
const adjustment = completion.label.length - completion.displayLabel.length;
if (adjustment <= 0) {
return matched;
}
const adjusted: number[] = [];
for (let i = 0; i < matched.length; i += 2) {
const start = Math.max(0, matched[i]! - adjustment);
const end = matched[i + 1]! - adjustment;
if (end >= 1) {
adjusted.push(start, end);
}
}
return adjusted;
}

export default class ContentAssistService {
private lastCompletion: CompletionResult | undefined;

Expand Down Expand Up @@ -216,18 +259,17 @@ export default class ContentAssistService {
}
const options: Completion[] = [];
entries.forEach((entry) => {
if (prefix === entry.prefix) {
// Xtext will generate completions that do not complete the current token,
// e.g., `(` after trying to complete an indetifier,
// but we ignore those, since CodeMirror won't filter for them anyways.
options.push(createCompletion(entry));
const completion = createCompletion(prefix, entry);
if (completion !== undefined) {
options.push(completion);
}
});
log.debug('Fetched', options.length, 'completions from server');
this.lastCompletion = {
...range,
options,
validFor: computeSpan(prefix, entries.length),
getMatch,
};
return this.lastCompletion;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,28 @@

import com.google.inject.Singleton;

import java.util.Locale;

/**
* Implements the candidate matching algoritm used by CodeMirror 6.
*
* Implements the candidate matching algorithm used by CodeMirror 6.
* <p>
* Using this class ensures that the same candidates will be returned when
* filtering content assist proposals on the server as on the client.
*
* </p>
* <p>
* The matching is "fuzzy" (<code>fzf</code>-like), i.e., the prefix characters
* may occur anywhere in the name, but must be in the same order as in the
* prefix.
*
* </p>
*
* @author Kristóf Marussy
*/
@Singleton
public class FuzzyMatcher implements IPrefixMatcher {
@Override
public boolean isCandidateMatchingPrefix(String name, String prefix) {
var nameIgnoreCase = name.toLowerCase();
var prefixIgnoreCase = prefix.toLowerCase();
var nameIgnoreCase = name.toLowerCase(Locale.ROOT);
var prefixIgnoreCase = prefix.toLowerCase(Locale.ROOT);
int prefixLength = prefixIgnoreCase.length();
if (prefixLength == 0) {
return true;
Expand Down

0 comments on commit 66bff71

Please sign in to comment.