Files
PiCloud/dashboard/src/lib/rhai-mode.ts
MechaCat02 1dc53a0226 feat(dashboard): go-to-definition, Ctrl+Click, and find-usages panel
`F12` jumps the cursor to the declaration of the identifier under the
caret; `Shift+F12` opens a CodeMirror panel listing every range that
resolves to the same declaration (declaration site plus all usages),
with line-number snippets that click to jump. `Ctrl+Click` (Cmd+Click
on macOS) on an identifier is wired to the same goto path. `Esc`
closes the panel.

All three features read from `rhaiAnalysisField`, so they automatically
follow the cached parse + symbol table. The panel's styling lives in a
CodeMirror `baseTheme` keyed to the dashboard's slate palette.

Bundle delta: +3 KB raw, +1 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:46:30 +02:00

601 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CodeMirror StreamLanguage for Rhai.
//
// Keyword and operator lists are sourced from the upstream TextMate
// grammar maintained by the Rhai authors:
// https://github.com/rhaiscript/vscode-rhai
// syntax/rhai.tmLanguage.json (MPL-2.0)
// This file does NOT copy the upstream grammar bytes — only the
// symbol lists. The matching logic is a simple regex tokenizer
// tailored to CodeMirror's StreamLanguage shape; if richer
// highlighting is wanted later, swap this out for a full tmLanguage
// loader (vscode-textmate + oniguruma) without touching callers.
//
// SDK completions (`ctx.*`, `log::*`) come from our own SDK contract
// in crates/executor-core/tests/sdk_contract.rs — that file is the
// authoritative list of what scripts can do.
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
import { EditorView, keymap, showPanel, type Panel } from '@codemirror/view';
import { parse, buildSymbolTable, type ParseResult, type Range, type SymbolTable } from './rhai';
// Keywords that drive control flow (`if`, `for`, ...) — these get the
// `controlKeyword` tag so the theme can color them distinctly from
// declaration-style keywords like `let` or `fn`.
const CONTROL_KEYWORDS = new Set([
'if',
'else',
'for',
'while',
'loop',
'do',
'switch',
'case',
'default',
'return',
'break',
'continue',
'try',
'catch',
'throw'
]);
const DECLARATION_KEYWORDS = new Set([
'let',
'const',
'fn',
'private',
'in',
'as',
'is'
]);
// Reserved-but-not-currently-valid keywords from the upstream grammar.
// We still highlight them so users notice them; the parser will reject
// at execute time.
const RESERVED_KEYWORDS = new Set([
'var',
'match',
'public',
'protected',
'new',
'use',
'with',
'module',
'package',
'super',
'spawn',
'thread',
'go',
'sync',
'async',
'await',
'yield',
'void',
'null',
'nil',
'debug',
'eval',
'print',
'import',
'export'
]);
const BOOLEAN_LITERALS = new Set(['true', 'false']);
const SPECIAL_VARIABLES = new Set(['ctx']);
const NAMESPACES = new Set(['log']);
interface RhaiState {
inBlockComment: boolean;
inString: false | '"' | '`';
}
export const rhaiLanguage = StreamLanguage.define<RhaiState>({
name: 'rhai',
startState: () => ({ inBlockComment: false, inString: false }),
token(stream, state) {
// --- inside a /* … */ block comment ---
if (state.inBlockComment) {
if (stream.match(/.*?\*\//)) {
state.inBlockComment = false;
} else {
stream.skipToEnd();
}
return 'comment';
}
// --- inside a multi-line string (rare but possible with ` ` strings) ---
if (state.inString) {
const quote = state.inString;
while (!stream.eol()) {
const ch = stream.next();
if (ch === '\\') {
stream.next();
continue;
}
if (ch === quote) {
state.inString = false;
return 'string';
}
}
return 'string';
}
// Skip whitespace
if (stream.eatSpace()) return null;
// --- line comment ---
if (stream.match('//')) {
stream.skipToEnd();
return 'comment';
}
// --- block comment ---
if (stream.match('/*')) {
state.inBlockComment = true;
if (stream.match(/.*?\*\//)) {
state.inBlockComment = false;
} else {
stream.skipToEnd();
}
return 'comment';
}
// --- strings ---
const quote = stream.peek();
if (quote === '"' || quote === '`') {
stream.next();
while (!stream.eol()) {
const ch = stream.next();
if (ch === '\\') {
stream.next();
continue;
}
if (ch === quote) {
return 'string';
}
}
// String continues on next line (only really valid for ` `).
state.inString = quote;
return 'string';
}
// --- numbers (hex, binary, decimal, float) ---
if (stream.match(/^0x[0-9a-fA-F_]+/)) return 'number';
if (stream.match(/^0b[01_]+/)) return 'number';
if (stream.match(/^\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/)) return 'number';
// --- module path: name::name (used for log::info etc.) ---
// Recognized before plain identifiers so we can return 'namespace'.
if (stream.match(/^[a-zA-Z_]\w*(?=::)/)) {
const word = stream.current();
if (NAMESPACES.has(word)) return 'namespace';
return 'variableName';
}
// --- identifiers + keywords ---
if (stream.match(/^[a-zA-Z_]\w*/)) {
const word = stream.current();
if (CONTROL_KEYWORDS.has(word)) return 'controlKeyword';
if (DECLARATION_KEYWORDS.has(word)) return 'keyword';
if (RESERVED_KEYWORDS.has(word)) return 'keyword';
if (BOOLEAN_LITERALS.has(word)) return 'bool';
if (SPECIAL_VARIABLES.has(word)) return 'variableName.special';
if (NAMESPACES.has(word)) return 'namespace';
// Followed by `(` → function call. We highlight as a function name.
if (stream.peek() === '(') return 'function(variableName)';
// Property after `.`
const before = stream.string.slice(0, stream.start);
if (before.endsWith('.')) return 'propertyName';
return 'variableName';
}
// --- operators / punctuation ---
if (stream.match(/^(\?\?|\.\.=|\.\.|::|==|!=|<=|>=|&&|\|\||<<|>>|=>|->|[+\-*/%<>!&|^~=])/)) {
return 'operator';
}
if (stream.match(/^[(){}[\];,.]/)) return 'punctuation';
// Unrecognized — advance one char and bail.
stream.next();
return null;
},
languageData: {
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
closeBrackets: { brackets: ['(', '[', '{', '"', '`'] },
indentOnInput: /^\s*[}\])]$/
}
});
// ---------------------------------------------------------------------------
// Autocomplete
// ---------------------------------------------------------------------------
interface CompletionItem {
label: string;
detail?: string;
type?: 'keyword' | 'variable' | 'function' | 'property' | 'namespace';
}
const KEYWORD_COMPLETIONS: CompletionItem[] = [
...['let', 'const', 'fn'].map((k) => ({ label: k, type: 'keyword' as const, detail: 'declaration' })),
...['if', 'else', 'for', 'while', 'loop', 'switch', 'return', 'break', 'continue', 'try', 'catch', 'throw'].map(
(k) => ({ label: k, type: 'keyword' as const, detail: 'control flow' })
),
...['in', 'as', 'is'].map((k) => ({ label: k, type: 'keyword' as const })),
{ label: 'true', type: 'keyword', detail: 'boolean' },
{ label: 'false', type: 'keyword', detail: 'boolean' }
];
// ctx.* — keep aligned with `build_ctx_map` in
// crates/executor-core/src/engine.rs.
const CTX_TOP_COMPLETIONS: CompletionItem[] = [
{ label: 'execution_id', type: 'property', detail: 'string' },
{ label: 'script_id', type: 'property', detail: 'string' },
{ label: 'script_name', type: 'property', detail: 'string' },
{ label: 'request_id', type: 'property', detail: 'string' },
{ label: 'invocation_type', type: 'property', detail: '"http" | "function" | "scheduled"' },
{ label: 'sdk_version', type: 'property', detail: 'string ("major.minor")' },
{ label: 'request', type: 'property', detail: 'object' }
];
const CTX_REQUEST_COMPLETIONS: CompletionItem[] = [
{ label: 'path', type: 'property', detail: 'string' },
{ label: 'headers', type: 'property', detail: 'map of string→string' },
{ label: 'body', type: 'property', detail: 'parsed JSON value' },
{ label: 'params', type: 'property', detail: 'map (param-route captures, SDK 1.1+)' },
{ label: 'query', type: 'property', detail: 'map (parsed query string, SDK 1.1+)' },
{ label: 'rest', type: 'property', detail: 'string (prefix-route tail, SDK 1.1+)' }
];
const LOG_COMPLETIONS: CompletionItem[] = [
{ label: 'info', type: 'function', detail: 'log::info(msg, data?)' },
{ label: 'warn', type: 'function', detail: 'log::warn(msg, data?)' },
{ label: 'error', type: 'function', detail: 'log::error(msg, data?)' },
{ label: 'trace', type: 'function', detail: 'log::trace(msg, data?) — use instead of "debug" (reserved keyword)' }
];
const TOP_LEVEL_GLOBALS: CompletionItem[] = [
{ label: 'ctx', type: 'variable', detail: 'invocation context' },
{ label: 'log', type: 'namespace', detail: 'log::info/warn/error/trace' }
];
function toCMCompletions(items: CompletionItem[]) {
return items.map((c) => ({
label: c.label,
type: c.type,
detail: c.detail
}));
}
export function rhaiCompletions(context: CompletionContext): CompletionResult | null {
// `log::` namespace
const ns = context.matchBefore(/log::\w*/);
if (ns) {
return {
from: ns.from + 'log::'.length,
options: toCMCompletions(LOG_COMPLETIONS),
validFor: /^\w*$/
};
}
// `ctx.request.` properties
const ctxReq = context.matchBefore(/ctx\.request\.\w*/);
if (ctxReq) {
return {
from: ctxReq.from + 'ctx.request.'.length,
options: toCMCompletions(CTX_REQUEST_COMPLETIONS),
validFor: /^\w*$/
};
}
// `ctx.` properties
const ctx = context.matchBefore(/ctx\.\w*/);
if (ctx) {
return {
from: ctx.from + 'ctx.'.length,
options: toCMCompletions(CTX_TOP_COMPLETIONS),
validFor: /^\w*$/
};
}
// 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;
return {
from: word ? word.from : context.pos,
options: toCMCompletions([...KEYWORD_COMPLETIONS, ...TOP_LEVEL_GLOBALS]),
validFor: /^\w*$/
};
}
// ---------------------------------------------------------------------------
// 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*$/
};
}
// ---------------------------------------------------------------------------
// Go-to-definition, Ctrl/Cmd+Click, find-usages
// ---------------------------------------------------------------------------
interface UsagesPanelState {
name: string;
ranges: Range[];
}
const setUsagesPanel = StateEffect.define<UsagesPanelState | null>();
const usagesPanelField = StateField.define<UsagesPanelState | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) if (e.is(setUsagesPanel)) return e.value;
return value;
},
provide: (f) => showPanel.from(f, (value) => (value ? buildUsagesPanel : null))
});
function buildUsagesPanel(view: EditorView): Panel {
const state = view.state.field(usagesPanelField);
const dom = document.createElement('div');
dom.className = 'cm-rhai-usages';
const head = document.createElement('div');
head.className = 'cm-rhai-usages-head';
const count = state ? state.ranges.length : 0;
const label = document.createElement('span');
label.textContent = `${count} occurrence${count === 1 ? '' : 's'} of "${state?.name ?? ''}"`;
head.appendChild(label);
const close = document.createElement('button');
close.type = 'button';
close.textContent = '×';
close.title = 'Close (Esc)';
close.onclick = () => {
view.dispatch({ effects: setUsagesPanel.of(null) });
view.focus();
};
head.appendChild(close);
dom.appendChild(head);
if (state) {
for (const r of state.ranges) {
const line = view.state.doc.lineAt(r.start);
const row = document.createElement('button');
row.type = 'button';
row.className = 'cm-rhai-usages-row';
const num = document.createElement('span');
num.className = 'cm-rhai-usages-line';
num.textContent = String(line.number);
const snip = document.createElement('span');
snip.className = 'cm-rhai-usages-snip';
snip.textContent = line.text.trim();
row.appendChild(num);
row.appendChild(snip);
row.onclick = () => {
view.dispatch({
selection: EditorSelection.cursor(r.start),
scrollIntoView: true
});
view.focus();
};
dom.appendChild(row);
}
}
return { dom, top: false };
}
const usagesPanelTheme = EditorView.baseTheme({
'.cm-rhai-usages': {
background: '#0f172a',
color: '#e2e8f0',
borderTop: '1px solid #334155',
padding: '0.4rem 0.5rem',
maxHeight: '180px',
overflowY: 'auto',
fontSize: '0.85rem'
},
'.cm-rhai-usages-head': {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: '#94a3b8',
marginBottom: '0.25rem'
},
'.cm-rhai-usages-head button': {
background: 'transparent',
color: '#94a3b8',
border: 'none',
fontSize: '1rem',
cursor: 'pointer',
padding: '0 0.4rem'
},
'.cm-rhai-usages-row': {
display: 'flex',
gap: '0.5rem',
alignItems: 'baseline',
width: '100%',
background: 'transparent',
color: '#e2e8f0',
border: 'none',
textAlign: 'left',
padding: '0.15rem 0.25rem',
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: 'inherit',
borderRadius: '0.25rem'
},
'.cm-rhai-usages-row:hover': {
background: '#1e293b'
},
'.cm-rhai-usages-line': {
color: '#64748b',
minWidth: '2.5rem',
flexShrink: 0
},
'.cm-rhai-usages-snip': {
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}
});
function declAtCursor(view: EditorView, pos: number) {
const analysis = view.state.field(rhaiAnalysisField, false);
if (!analysis) return null;
return analysis.table.declOfUsageAt(pos);
}
export function gotoDefinition(view: EditorView): boolean {
const pos = view.state.selection.main.head;
const decl = declAtCursor(view, pos);
if (!decl) return false;
view.dispatch({
selection: EditorSelection.cursor(decl.nameRange.start),
scrollIntoView: true
});
return true;
}
export function findUsages(view: EditorView): boolean {
const pos = view.state.selection.main.head;
const analysis = view.state.field(rhaiAnalysisField, false);
if (!analysis) return false;
const decl = analysis.table.declOfUsageAt(pos);
if (!decl) return false;
view.dispatch({
effects: setUsagesPanel.of({
name: decl.name,
ranges: analysis.table.usagesOf(decl)
})
});
return true;
}
function closeUsagesPanel(view: EditorView): boolean {
if (!view.state.field(usagesPanelField, false)) return false;
view.dispatch({ effects: setUsagesPanel.of(null) });
return true;
}
const rhaiKeymap = keymap.of([
{ key: 'F12', run: gotoDefinition },
{ key: 'Shift-F12', run: findUsages },
{ key: 'Escape', run: closeUsagesPanel }
]);
// Ctrl+Click (Cmd+Click on macOS) on an identifier → jump to its decl.
// Returning `true` suppresses CodeMirror's default selection behavior so
// the click doesn't simultaneously place the caret at the click site.
const ctrlClickHandler = EditorView.domEventHandlers({
mousedown(event, view) {
if (!(event.metaKey || event.ctrlKey) || event.button !== 0) return false;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null) return false;
const decl = declAtCursor(view, pos);
if (!decl) return false;
view.dispatch({
selection: EditorSelection.cursor(decl.nameRange.start),
scrollIntoView: true
});
event.preventDefault();
return true;
}
});
export function rhai(): LanguageSupport {
const extensions: Extension[] = [
rhaiAnalysisField,
usagesPanelField,
usagesPanelTheme,
rhaiKeymap,
ctrlClickHandler,
autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })
];
return new LanguageSupport(rhaiLanguage, extensions);
}