-
Notifications
You must be signed in to change notification settings - Fork 44
improve completions sorting #798
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
c3a0a49
dba6d7e
4383020
a61fcc9
85cb789
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -102,135 +102,163 @@ export function completionBehavior(behaviorContext: BehaviorContext): Behavior { | |
| }; | ||
| } | ||
|
|
||
| async function getCompletions( | ||
| context: CompletionContext, | ||
| cvContext: CodeViewCompletionContext, | ||
| behaviorContext: BehaviorContext | ||
| ): Promise<CompletionResult | null> { | ||
|
|
||
| // get completions | ||
| const completions = await behaviorContext.pmContext.ui.codeview?.codeViewCompletions(cvContext); | ||
| if (context.aborted || !completions || completions.items.length == 0) { | ||
| return null; | ||
| const compareBySortText = (a: CompletionItem, b: CompletionItem) => { | ||
| if (a.sortText && b.sortText) { | ||
| return a.sortText.localeCompare(b.sortText); | ||
| } else { | ||
| return 0; | ||
| } | ||
| }; | ||
|
|
||
| // order completions | ||
| const haveOrder = !!completions.items?.[0].sortText; | ||
| if (haveOrder) { | ||
| completions.items = completions.items.sort((a, b) => { | ||
| if (a.sortText && b.sortText) { | ||
| return a.sortText.localeCompare(b.sortText); | ||
| } else { | ||
| return 0; | ||
| // compute from | ||
| const itemFrom = (item: CompletionItem, contextPos: number) => { | ||
| // compute from | ||
| return item.textEdit | ||
| ? InsertReplaceEdit.is(item.textEdit) | ||
| ? contextPos - (item.textEdit.insert.end.character - item.textEdit.insert.start.character) | ||
| : TextEdit.is(item.textEdit) | ||
| ? contextPos - (item.textEdit.range.end.character - item.textEdit.range.start.character) | ||
| : contextPos | ||
| : contextPos; | ||
| }; | ||
|
|
||
| /** | ||
| * replaceText for a given CompletionItem is the text that is already in the document | ||
| * that that CompletionItem will replace. | ||
| * | ||
| * Example 1: if you are typing `lib` and get the completion `library`, then this function | ||
| * will give `lib`. | ||
| * Example 2: if you are typing `os.a` and get the completion `abc`, then this function | ||
| * will give `a`. | ||
| */ | ||
| const getReplaceText = (context: CompletionContext, item: CompletionItem) => | ||
| context.state.sliceDoc(itemFrom(item, context.pos), context.pos); | ||
|
|
||
| const makeCompletionItemApplier = (item: CompletionItem, context: CompletionContext) => | ||
| (view: EditorView, completion: Completion) => { | ||
| // compute from | ||
| const from = itemFrom(item, context.pos); | ||
|
|
||
| // handle snippets | ||
| const insertText = item.textEdit?.newText ?? (item.insertText || item.label); | ||
| if (item.insertTextFormat === InsertTextFormat.Snippet) { | ||
| const insertSnippet = snippet(insertText.replace(/\$(\d+)/g, "$${$1}")); | ||
| insertSnippet(view, completion, from, context.pos); | ||
| // normal completions | ||
| } else { | ||
| view.dispatch({ | ||
| ...insertCompletionText(view.state, insertText, from, context.pos), | ||
| annotations: pickedCompletion.of(completion) | ||
| }); | ||
| if (item.command?.command === "editor.action.triggerSuggest") { | ||
| startCompletion(view); | ||
| } | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| const sortTextItemsBoostScore = (context: CompletionContext, items: CompletionItem[], index: number) => { | ||
| const total = items.length; | ||
| const item = items[index]; | ||
| // compute replaceText | ||
| const replaceText = getReplaceText(context, item); | ||
|
|
||
| // if the replaceText doesn't start with "." then bury items that do | ||
| if (!replaceText.startsWith(".") && item.label.startsWith(".")) { | ||
| return -99; | ||
| } | ||
|
|
||
| // compute token | ||
| const token = context.matchBefore(/\S+/)?.text; | ||
| // only boost things that have a prefix match | ||
| if (item.label.toLowerCase().startsWith(replaceText) || | ||
| (item.textEdit && item.textEdit.newText.toLowerCase().startsWith(replaceText)) || | ||
| (item.insertText && item.insertText.toLowerCase().startsWith(replaceText))) { | ||
| return -99 + Math.round(((total - index) / total) * 198);; | ||
| } else { | ||
| return -99; | ||
| } | ||
| }; | ||
|
|
||
| // compute from | ||
| const itemFrom = (item: CompletionItem) => { | ||
| // compute from | ||
| return item.textEdit | ||
| ? InsertReplaceEdit.is(item.textEdit) | ||
| ? context.pos - (item.textEdit.insert.end.character - item.textEdit.insert.start.character) | ||
| : TextEdit.is(item.textEdit) | ||
| ? context.pos - (item.textEdit.range.end.character - item.textEdit.range.start.character) | ||
| : context.pos | ||
| : context.pos; | ||
| }; | ||
| const defaultBoostScore = (context: CompletionContext, items: CompletionItem[], index: number) => { | ||
| const item = items[index]; | ||
|
|
||
| // use order to create boost | ||
| const total = completions.items.length; | ||
| const boostScore = (index: number) => { | ||
| const replaceText = getReplaceText(context, item); | ||
|
|
||
| // compute replaceText | ||
| const item = completions.items[index]; | ||
| const replaceText = context.state.sliceDoc(itemFrom(item), context.pos).toLowerCase(); | ||
| // if you haven't typed into the completions yet (for example after a `.`) then | ||
| // score items starting with non-alphabetic characters -1, everything else 0. | ||
| if (replaceText.length === 0) return isLetter(item.label[0]) ? 0 : -1; | ||
|
|
||
| if (haveOrder) { | ||
| // We filter items by replaceText inclusion before scoring, | ||
| // so i is garaunteed to be an index into `item.label`... | ||
| const i = item.label.toLowerCase().indexOf(replaceText.toLowerCase()); | ||
| // and `replaceTextInItermLabel` should be the same as `replaceText` up to upper/lowercase | ||
| // differences. | ||
| const replaceTextInItemLabel = item.label.slice(i, replaceText.length); | ||
|
|
||
| // if the replaceText doesn't start with "." then bury items that do | ||
| if (!replaceText.startsWith(".") && item.label.startsWith(".")) { | ||
| return -99; | ||
| } | ||
| // mostly counts how many upper/lowercase differences there are | ||
| let diff = simpleStringDiff(replaceTextInItemLabel, replaceText); | ||
|
|
||
| // only boost things that have a prefix match | ||
| if (item.label.toLowerCase().startsWith(replaceText) || | ||
| (item.textEdit && item.textEdit.newText.toLowerCase().startsWith(replaceText)) || | ||
| (item.insertText && item.insertText.toLowerCase().startsWith(replaceText))) { | ||
| return -99 + Math.round(((total - index) / total) * 198);; | ||
| } else { | ||
| return -99; | ||
| } | ||
| // `-i` scores completions better if what you typed is earlier in the completion | ||
| // `-diff/10` mostly tie breaks that score by capitalization differences. | ||
| return -i - diff / 10; // 10 is a magic number | ||
| }; | ||
|
|
||
| } else { | ||
| return undefined; | ||
| } | ||
| }; | ||
| async function getCompletions( | ||
| context: CompletionContext, | ||
| cvContext: CodeViewCompletionContext, | ||
| behaviorContext: BehaviorContext | ||
| ): Promise<CompletionResult | null> { | ||
| if (context.aborted) return null; | ||
|
|
||
| // return completions | ||
| return { | ||
| from: context.pos, | ||
| // get completions | ||
| const completions = await behaviorContext.pmContext.ui.codeview?.codeViewCompletions(cvContext); | ||
| if (completions === undefined) return null; | ||
| if (completions.items.length == 0) return null; | ||
|
|
||
| options: completions.items | ||
| .filter(item => { | ||
| const itemsHaveSortText = completions.items?.[0].sortText !== undefined; | ||
|
|
||
| // no text completions that aren't snippets | ||
| if (item.kind === CompletionItemKind.Text && | ||
| item.insertTextFormat !== InsertTextFormat.Snippet) { | ||
| return false; | ||
| } | ||
| const items = itemsHaveSortText ? | ||
| completions.items.sort(compareBySortText) : | ||
| completions.items; | ||
|
|
||
| // compute text to replace | ||
| const replaceText = context.state.sliceDoc(itemFrom(item), context.pos).toLowerCase(); | ||
| // The token is the contents of the line up to your cursor. | ||
| // For example, if you type `os.a` then token will be `os.a`. | ||
| // Note: in contrast, when you type `os.a` replaceText will give `a` for a completion like `abc`. | ||
| const token = context.matchBefore(/\S+/)?.text; | ||
|
|
||
| // only allow non-text edits if we have no token | ||
| if (!item.textEdit && token) { | ||
| return false; | ||
| } | ||
| const filteredItems = items.filter(item => { | ||
| // no namespaces | ||
| if (vsKindToType(item.kind) === 'namespace') return false; | ||
|
||
|
|
||
| // no text completions that aren't snippets | ||
| if (item.kind === CompletionItemKind.Text && | ||
| item.insertTextFormat !== InsertTextFormat.Snippet) return false; | ||
|
|
||
| // only allow non-text edits if we have no token | ||
| if (item.textEdit === undefined && token) return false; | ||
|
|
||
| // require at least inclusion | ||
| const replaceText = getReplaceText(context, item).toLowerCase(); | ||
| return item.label.toLowerCase().includes(replaceText) || | ||
| item.insertText?.toLowerCase().includes(replaceText); | ||
| }); | ||
|
|
||
| const boostScore = itemsHaveSortText ? | ||
| sortTextItemsBoostScore : | ||
| defaultBoostScore; | ||
|
|
||
| const options = filteredItems | ||
| .map((item, index): Completion => { | ||
| return { | ||
| label: item.label, | ||
| detail: !item.documentation ? item.detail : undefined, | ||
| type: vsKindToType(item.kind), | ||
| info: () => infoNodeForItem(item), | ||
| apply: makeCompletionItemApplier(item, context), | ||
| boost: boostScore(context, filteredItems, index) | ||
| }; | ||
| }); | ||
|
|
||
| // require at least inclusion | ||
| return item.label.toLowerCase().includes(replaceText) || | ||
| (item.insertText && item.insertText.toLowerCase().includes(replaceText)); | ||
| }) | ||
| .map((item, index): Completion => { | ||
| return { | ||
| label: item.label, | ||
| detail: item.detail && !item.documentation ? item.detail : undefined, | ||
| type: vsKindToType(item.kind), | ||
| info: (): Node | null => { | ||
| if (item.documentation) { | ||
| return infoNodeForItem(item); | ||
| } else { | ||
| return null; | ||
| } | ||
| }, | ||
| apply: (view: EditorView, completion: Completion, from: number) => { | ||
| // compute from | ||
| from = itemFrom(item); | ||
|
|
||
| // handle snippets | ||
| const insertText = item.textEdit?.newText ?? (item.insertText || item.label); | ||
| if (item.insertTextFormat === InsertTextFormat.Snippet) { | ||
| const insertSnippet = snippet(insertText.replace(/\$(\d+)/g, "$${$1}")); | ||
| insertSnippet(view, completion, from, context.pos); | ||
| // normal completions | ||
| } else { | ||
| view.dispatch({ | ||
| ...insertCompletionText(view.state, insertText, from, context.pos), | ||
| annotations: pickedCompletion.of(completion) | ||
| }); | ||
| if (item.command?.command === "editor.action.triggerSuggest") { | ||
| startCompletion(view); | ||
| } | ||
| } | ||
| }, | ||
| boost: boostScore(index) | ||
| }; | ||
| }) | ||
| }; | ||
| // return completions | ||
| return { from: context.pos, options }; | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -281,7 +309,6 @@ function vsKindToType(kind?: CompletionItemKind) { | |
|
|
||
|
|
||
| function infoNodeForItem(item: CompletionItem) { | ||
|
|
||
| const headerEl = (text: string, tag: string) => { | ||
| const header = document.createElement(tag); | ||
| header.classList.add("cm-completionInfoHeader"); | ||
|
|
@@ -328,3 +355,15 @@ function infoNodeForItem(item: CompletionItem) { | |
| return null; | ||
| } | ||
| } | ||
|
|
||
| function simpleStringDiff(str1: string, str2: string) { | ||
| let diff = 0; | ||
| for (let i = 0; i < Math.min(str1.length, str2.length); i++) { | ||
| if (str1[i] !== str2[i]) diff++; | ||
| } | ||
| return diff; | ||
| }; | ||
|
|
||
| function isLetter(c: string) { | ||
| return c.toLowerCase() != c.toUpperCase(); | ||
| } | ||



There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's remove this code before we merge!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done!