feat(dashboard): scope-aware autocomplete for user-defined symbols

Adds a second CompletionSource that reads the Rhai parser's symbol
table. On a plain word it surfaces in-scope `let`/`const`/`fn` names
(with the function signature in the popup's detail line); on `obj.`
it suggests the field names of an object-map literal that initialized
`obj`. Composes with the existing static `ctx.*` / `log::*` source via
`autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })`,
which CodeMirror merges. The static source now bows out on generic
`name.` rather than flooding the popup with keywords.

A new StateField caches one parse + symbol-table per editor state and
rebuilds on doc change. Bundle delta: +18 KB raw, +4.7 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 23:44:41 +02:00
parent bc8b512b56
commit 6cdb1244b8

View File

@@ -16,6 +16,8 @@
import { StreamLanguage, LanguageSupport } from '@codemirror/language'; import { StreamLanguage, LanguageSupport } from '@codemirror/language';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { StateField, type Extension } from '@codemirror/state';
import { parse, buildSymbolTable, type ParseResult, type SymbolTable } from './rhai';
// Keywords that drive control flow (`if`, `for`, ...) — these get the // Keywords that drive control flow (`if`, `for`, ...) — these get the
// `controlKeyword` tag so the theme can color them distinctly from // `controlKeyword` tag so the theme can color them distinctly from
@@ -297,6 +299,11 @@ export function rhaiCompletions(context: CompletionContext): CompletionResult |
}; };
} }
// Member access on something other than `ctx`/`log` — let the
// script-aware source decide whether it has fields to offer. We bow
// out so we don't flood the popup with keywords after a `.`.
if (context.matchBefore(/\.\w*$/)) return null;
// Plain word at the cursor → keywords + top-level names. // Plain word at the cursor → keywords + top-level names.
const word = context.matchBefore(/\w+/); const word = context.matchBefore(/\w+/);
if (!word && !context.explicit) return null; if (!word && !context.explicit) return null;
@@ -307,6 +314,92 @@ export function rhaiCompletions(context: CompletionContext): CompletionResult |
}; };
} }
export function rhai(): LanguageSupport { // ---------------------------------------------------------------------------
return new LanguageSupport(rhaiLanguage, [autocompletion({ override: [rhaiCompletions] })]); // Script-aware analysis
// ---------------------------------------------------------------------------
// One AST + symbol table per editor state. Rebuilt on every doc change.
// Scripts in the dashboard are small (20200 lines is the target); the
// parse + walk is sub-millisecond at that size, so we don't bother
// debouncing for now.
interface RhaiAnalysis {
parse: ParseResult;
table: SymbolTable;
}
function analyze(source: string): RhaiAnalysis {
const parsed = parse(source);
return { parse: parsed, table: buildSymbolTable(parsed) };
}
export const rhaiAnalysisField = StateField.define<RhaiAnalysis>({
create: (state) => analyze(state.doc.toString()),
update: (value, tr) => (tr.docChanged ? analyze(tr.newDoc.toString()) : value)
});
/**
* Script-aware completion source.
*
* Two things on top of the static `ctx.*` / `log::*` list:
* 1. After `name.`, if `name` was initialized to an object-map
* literal, suggest that literal's field names.
* 2. At a plain word position, suggest user-defined symbols in scope
* (locals, function parameters, top-level `fn` decls). `fn` decls
* get their signature in the `detail` field so the popup shows
* `process(order, user)` rather than just `process`.
*
* Composes with `rhaiCompletions` via `autocompletion({ override:
* [scopeCompletionSource, rhaiCompletions] })` — CodeMirror merges the
* results.
*/
export function scopeCompletionSource(context: CompletionContext): CompletionResult | null {
const analysis = context.state.field(rhaiAnalysisField, false);
if (!analysis) return null;
// Member access — `obj.fie|`. Only fire if `obj` resolves to a known
// object-literal in scope; otherwise leave the popup empty rather
// than guess wrong fields.
const member = context.matchBefore(/(\w+)\.(\w*)/);
if (member) {
const dotIdx = member.text.indexOf('.');
const objectName = member.text.slice(0, dotIdx);
// `ctx.*` is handled by the static source — don't double up.
if (objectName !== 'ctx') {
const fields = analysis.table.objectFieldsOf(objectName, member.from);
if (fields.length > 0) {
return {
from: member.from + dotIdx + 1,
options: fields.map((label) => ({ label, type: 'property' })),
validFor: /^\w*$/
};
}
}
return null;
}
// Skip when right after `::` (handled by the static source for log::).
if (context.matchBefore(/::\w*$/)) return null;
// Plain word — surface in-scope decls.
const word = context.matchBefore(/\w+/);
if (!word && !context.explicit) return null;
const decls = analysis.table.scopeCompletions(context.pos);
if (decls.length === 0) return null;
return {
from: word ? word.from : context.pos,
options: decls.map((d) => ({
label: d.name,
type: d.kind === 'fn' ? 'function' : 'variable',
detail: d.signature ?? d.kind
})),
validFor: /^\w*$/
};
}
export function rhai(): LanguageSupport {
const extensions: Extension[] = [
rhaiAnalysisField,
autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })
];
return new LanguageSupport(rhaiLanguage, extensions);
} }