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 { 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 (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