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:
@@ -16,6 +16,8 @@
|
||||
|
||||
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||
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
|
||||
// `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.
|
||||
const word = context.matchBefore(/\w+/);
|
||||
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 (20–200 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user