From 3440bbc131dc7932147d8c6149d0fee51754da91 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 01:56:45 +0200 Subject: [PATCH] feat(editor-v2): autocomplete completion source CompletionSource over the srccfg-vocab.json shape. Word fragment matched via /[A-Za-z0-9_]{2,}/ at the caret; ranking is prefix-match-first (shorter prefixes preferred) then substring; cap 50 candidates, top 8 rendered. Each option carries the kind ('cvar'/'command') as cm6's autocomplete `type` so the popup shows the appropriate icon. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/scripts/editor-src/autocomplete.js | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 l4d2web/scripts/editor-src/autocomplete.js 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, + }); +}