diff --git a/dashboard/src/lib/rhai-mode.ts b/dashboard/src/lib/rhai-mode.ts index a98135f..f9cf4e4 100644 --- a/dashboard/src/lib/rhai-mode.ts +++ b/dashboard/src/lib/rhai-mode.ts @@ -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({ + 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); }