`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>
601 lines
18 KiB
TypeScript
601 lines
18 KiB
TypeScript
// 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 (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*$/
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
}
|