Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions apps/vscode/src/providers/editor/codeview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,6 @@ export function vscodeCodeViewServer(_engine: MarkdownEngine, document: TextDocu
return lspCellYamlOptionsCompletions(context, lspRequest);
}

// if this is Positron, no visual editor completions
// TODO: fix LSP issues for visual editor in Positron:
// https://github.com/posit-dev/positron/issues/1805
if (hasHooks()) {
return {
items: [],
isIncomplete: false
};
}

// otherwise delegate to vscode completion system
const vdoc = virtualDocForCode(context.code, language);
const completions = await vdocCompletions(
Expand Down
260 changes: 148 additions & 112 deletions packages/editor-codemirror/src/behaviors/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,135 +102,160 @@ 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 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 };
}


Expand Down Expand Up @@ -281,7 +306,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");
Expand Down Expand Up @@ -328,3 +352,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();
}
19 changes: 15 additions & 4 deletions packages/editor-codemirror/src/behaviors/indent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,29 @@
*
*/

import { indentWithTab } from "@codemirror/commands";
import { indentLess, indentMore } from "@codemirror/commands";

import { indentOnInput } from "@codemirror/language";
import { keymap } from "@codemirror/view";

import { Behavior } from ".";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";

export function indentBehavior() : Behavior {
export function tabBehavior(): Behavior {
return {
extensions: [
indentOnInput(),
keymap.of([indentWithTab])
keymap.of([
{
key: 'Tab',
preventDefault: true,
shift: indentLess,
run: e => {
if (!completionStatus(e.state)) return indentMore(e);
return acceptCompletion(e);
},
},
])
]
}
};
}
4 changes: 2 additions & 2 deletions packages/editor-codemirror/src/behaviors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { CodeViewOptions, ExtensionContext } from "editor";
import { langModeBehavior } from './langmode';
import { keyboardBehavior } from './keyboard';
import { findBehavior } from './find';
import { indentBehavior } from './indent';
import { tabBehavior } from './indent';
import { trackSelectionBehavior } from './trackselection';
import { themeBehavior } from './theme';
import { prefsBehavior } from './prefs';
Expand Down Expand Up @@ -63,7 +63,7 @@ export function createBehaviors(context: BehaviorContext): Behavior[] {
langModeBehavior(context),
completionBehavior(context),
findBehavior(context),
indentBehavior(),
tabBehavior(),
themeBehavior(context),
prefsBehavior(context),
trackSelectionBehavior(context),
Expand Down