diff --git a/l4d2web/scripts/editor-src/autocomplete.js b/l4d2web/scripts/editor-src/autocomplete.js new file mode 100644 index 0000000..151297f --- /dev/null +++ b/l4d2web/scripts/editor-src/autocomplete.js @@ -0,0 +1,50 @@ +import { autocompletion } from "@codemirror/autocomplete"; + +const WORD_RE = /[A-Za-z0-9_]{2,}/; + +function rank(query, label) { + const q = query.toLowerCase(); + const l = label.toLowerCase(); + if (l === q) return 0; + if (l.startsWith(q)) return 1 + l.length; // shorter prefix matches first + const i = l.indexOf(q); + if (i !== -1) return 10000 + i; // substring matches after all prefix matches + return -1; +} + +export function vocabCompletions(vocab) { + // vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] } + const entries = [ + ...vocab.cvars.map(e => ({ ...e, kind: "cvar" })), + ...vocab.commands.map(e => ({ ...e, kind: "command" })), + ]; + + return (context) => { + const word = context.matchBefore(WORD_RE); + if (!word || (word.from === word.to && !context.explicit)) return null; + const q = word.text; + + const scored = []; + for (const e of entries) { + const r = rank(q, e.name); + if (r === -1) continue; + scored.push([r, e]); + if (scored.length > 200) break; // bound work; we cap to 50 below + } + scored.sort((a, b) => a[0] - b[0]); + const options = scored.slice(0, 50).map(([, e]) => ({ + label: e.name, + info: e.desc || e.kind, + type: e.kind === "command" ? "function" : "variable", + })); + return { from: word.from, options, validFor: WORD_RE }; + }; +} + +export function autocompleteExtension(vocab) { + return autocompletion({ + override: [vocabCompletions(vocab)], + activateOnTyping: true, + maxRenderedOptions: 8, + }); +}