mirror of
https://github.com/duhanbalci/dexpr.git
synced 2026-07-02 00:29:15 +00:00
initial commit
This commit is contained in:
442
editor/src/completions.ts
Normal file
442
editor/src/completions.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
autocompletion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type {
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
Completion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
|
||||
// --- dexpr type system ---
|
||||
|
||||
export type DexprType =
|
||||
| "String"
|
||||
| "Number"
|
||||
| "Boolean"
|
||||
| "NumberList"
|
||||
| "StringList"
|
||||
| "Object";
|
||||
|
||||
export interface FunctionInfo {
|
||||
name: string;
|
||||
signature: string;
|
||||
doc?: string;
|
||||
}
|
||||
|
||||
export interface MethodInfo {
|
||||
name: string;
|
||||
signature: string;
|
||||
doc?: string;
|
||||
}
|
||||
|
||||
export interface FieldInfo {
|
||||
name: string;
|
||||
type: DexprType;
|
||||
}
|
||||
|
||||
export interface VariableInfo {
|
||||
name: string;
|
||||
type: DexprType;
|
||||
doc?: string;
|
||||
fields?: FieldInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Language metadata — generated by Rust `LanguageInfo::to_json()`,
|
||||
* extended with host-registered functions/methods/variables.
|
||||
*/
|
||||
export interface DexprLanguageInfo {
|
||||
functions: FunctionInfo[];
|
||||
methods: Partial<Record<DexprType, MethodInfo[]>>;
|
||||
variables?: VariableInfo[];
|
||||
}
|
||||
|
||||
// --- Build completions from metadata ---
|
||||
|
||||
function funcToCompletion(f: FunctionInfo): Completion {
|
||||
return {
|
||||
label: f.name,
|
||||
type: "function",
|
||||
detail: f.signature,
|
||||
info: f.doc,
|
||||
};
|
||||
}
|
||||
|
||||
function methodToCompletion(m: MethodInfo): Completion {
|
||||
return {
|
||||
label: m.name,
|
||||
type: "method",
|
||||
detail: m.signature,
|
||||
info: m.doc,
|
||||
};
|
||||
}
|
||||
|
||||
function varToCompletion(v: VariableInfo): Completion {
|
||||
return {
|
||||
label: v.name,
|
||||
type: "variable",
|
||||
detail: v.type,
|
||||
info: v.doc,
|
||||
};
|
||||
}
|
||||
|
||||
const KEYWORDS: Completion[] = [
|
||||
{ label: "if", type: "keyword" },
|
||||
{ label: "then", type: "keyword" },
|
||||
{ label: "else", type: "keyword" },
|
||||
{ label: "end", type: "keyword" },
|
||||
{ label: "true", type: "keyword", detail: "Boolean" },
|
||||
{ label: "false", type: "keyword", detail: "Boolean" },
|
||||
{ label: "in", type: "keyword", detail: "membership test" },
|
||||
];
|
||||
|
||||
// --- Type inference from Lezer tree ---
|
||||
|
||||
/**
|
||||
* Walk the tree to infer variable types from assignments.
|
||||
* Scans `Assignment` nodes: `VariableName AssignOp expression`
|
||||
* and determines the type of the right-hand side expression.
|
||||
*/
|
||||
function inferVariableTypes(
|
||||
context: CompletionContext,
|
||||
knownVars: Map<string, DexprType>
|
||||
): Map<string, DexprType> {
|
||||
const types = new Map(knownVars);
|
||||
const tree = syntaxTree(context.state);
|
||||
const doc = context.state.doc;
|
||||
|
||||
tree.iterate({
|
||||
enter(node) {
|
||||
if (node.name !== "Assignment") return;
|
||||
|
||||
// First child: VariableName
|
||||
const varNode = node.node.firstChild;
|
||||
if (!varNode || varNode.name !== "VariableName") return;
|
||||
const varName = doc.sliceString(varNode.from, varNode.to);
|
||||
|
||||
// Third child (skip AssignOp): expression
|
||||
const assignOp = varNode.nextSibling;
|
||||
if (!assignOp) return;
|
||||
const exprNode = assignOp.nextSibling;
|
||||
if (!exprNode) return;
|
||||
|
||||
const exprType = inferExprType(exprNode, doc, types);
|
||||
if (exprType) types.set(varName, exprType);
|
||||
},
|
||||
});
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
function inferExprType(
|
||||
node: { name: string; from: number; to: number; firstChild: any },
|
||||
doc: { sliceString(from: number, to: number): string },
|
||||
knownTypes: Map<string, DexprType>
|
||||
): DexprType | null {
|
||||
switch (node.name) {
|
||||
case "String":
|
||||
return "String";
|
||||
case "Number":
|
||||
return "Number";
|
||||
case "BooleanLiteral":
|
||||
return "Boolean";
|
||||
case "VariableName": {
|
||||
const name = doc.sliceString(node.from, node.to);
|
||||
return knownTypes.get(name) ?? null;
|
||||
}
|
||||
case "MethodCall": {
|
||||
// Infer return type from method name
|
||||
const propNode = findChild(node, "PropertyName");
|
||||
if (!propNode) return null;
|
||||
const method = doc.sliceString(propNode.from, propNode.to);
|
||||
return inferMethodReturnType(method);
|
||||
}
|
||||
case "BinaryExpression": {
|
||||
// String + anything = String, Number ops = Number
|
||||
const first = node.firstChild;
|
||||
if (first) {
|
||||
const t = inferExprType(first, doc, knownTypes);
|
||||
if (t === "String") return "String";
|
||||
if (t === "Number") return "Number";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "PropertyAccess": {
|
||||
// Resolve: obj.field → look up field type from config
|
||||
const objNode = node.firstChild;
|
||||
const propNode = findChild(node, "PropertyName");
|
||||
if (!objNode || !propNode) return null;
|
||||
if (objNode.name === "VariableName") {
|
||||
const varName = doc.sliceString(objNode.from, objNode.to);
|
||||
const fieldName = doc.sliceString(propNode.from, propNode.to);
|
||||
// Look up from objectFieldTypes via the global lookup
|
||||
// (We use knownTypes to check if root is Object, then check field)
|
||||
const rootType = knownTypes.get(varName);
|
||||
if (rootType === "Object") {
|
||||
// Field type needs to come from config — stored as "varName.fieldName" key
|
||||
// We can't access objectFieldTypes here, so use the convention
|
||||
// that knownTypes may contain "varName.fieldName" entries
|
||||
return knownTypes.get(`${varName}.${fieldName}`) ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "FunctionCall":
|
||||
case "ParenExpression":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findChild(
|
||||
node: { firstChild: any },
|
||||
name: string
|
||||
): { name: string; from: number; to: number } | null {
|
||||
let child = node.firstChild;
|
||||
while (child) {
|
||||
if (child.name === name) return child;
|
||||
child = child.nextSibling;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Infer return type from known method names */
|
||||
function inferMethodReturnType(method: string): DexprType | null {
|
||||
switch (method) {
|
||||
// String -> String
|
||||
case "upper":
|
||||
case "lower":
|
||||
case "trim":
|
||||
case "trimStart":
|
||||
case "trimEnd":
|
||||
case "replace":
|
||||
case "charAt":
|
||||
case "substring":
|
||||
return "String";
|
||||
// String -> Boolean
|
||||
case "contains":
|
||||
case "startsWith":
|
||||
case "endsWith":
|
||||
case "isEmpty":
|
||||
return "Boolean";
|
||||
// String -> Number
|
||||
case "length":
|
||||
case "len":
|
||||
case "indexOf":
|
||||
return "Number";
|
||||
// String -> StringList
|
||||
case "split":
|
||||
return "StringList";
|
||||
// List -> aggregate
|
||||
case "sum":
|
||||
case "avg":
|
||||
case "min":
|
||||
case "max":
|
||||
case "first":
|
||||
case "last":
|
||||
return "Number";
|
||||
// List methods returning lists
|
||||
case "reverse":
|
||||
case "sort":
|
||||
case "slice":
|
||||
return null; // depends on input type
|
||||
case "join":
|
||||
return "String";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Autocomplete ---
|
||||
|
||||
function dedup(items: Completion[]): Completion[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.label)) return false;
|
||||
seen.add(item.label);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function dexprCompletion(info: DexprLanguageInfo): Extension {
|
||||
const functionCompletions = info.functions.map(funcToCompletion);
|
||||
const variableCompletions = (info.variables ?? []).map(varToCompletion);
|
||||
|
||||
// Methods per type
|
||||
const methodsByType: Record<string, Completion[]> = {};
|
||||
for (const [type, methods] of Object.entries(info.methods)) {
|
||||
methodsByType[type] = (methods ?? []).map(methodToCompletion);
|
||||
}
|
||||
|
||||
const allMethods = dedup(
|
||||
Object.values(methodsByType).flat()
|
||||
);
|
||||
const allIdentifiers = [
|
||||
...KEYWORDS,
|
||||
...functionCompletions,
|
||||
...variableCompletions,
|
||||
];
|
||||
|
||||
// Build known variable type map from config
|
||||
const configVarTypes = new Map<string, DexprType>();
|
||||
for (const v of info.variables ?? []) {
|
||||
configVarTypes.set(v.name, v.type);
|
||||
}
|
||||
|
||||
// Build Object field type lookup: "varName.fieldName" → DexprType
|
||||
// and field completions per Object variable
|
||||
const objectFieldCompletions = new Map<string, Completion[]>();
|
||||
for (const v of info.variables ?? []) {
|
||||
if (v.type === "Object" && v.fields) {
|
||||
const fieldItems: Completion[] = [];
|
||||
for (const f of v.fields) {
|
||||
// Store "customer.name" → "String" in configVarTypes for type inference
|
||||
configVarTypes.set(`${v.name}.${f.name}`, f.type);
|
||||
fieldItems.push({
|
||||
label: f.name,
|
||||
type: "property",
|
||||
detail: f.type,
|
||||
});
|
||||
}
|
||||
objectFieldCompletions.set(v.name, fieldItems);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the type of a dotted path expression before the cursor.
|
||||
* Walks the Lezer tree backwards from a dot position to build
|
||||
* the full path (e.g. "customer.address") and looks up field types.
|
||||
*/
|
||||
function resolveDotPath(
|
||||
context: CompletionContext,
|
||||
dotPos: number,
|
||||
varTypes: Map<string, DexprType>
|
||||
): { type: DexprType | null; path: string[] } {
|
||||
const tree = syntaxTree(context.state);
|
||||
const doc = context.state.doc;
|
||||
|
||||
// Collect the chain of identifiers before the dot
|
||||
// e.g. for "customer.address.|" we want ["customer", "address"]
|
||||
const path: string[] = [];
|
||||
let pos = dotPos;
|
||||
|
||||
// Walk backwards through PropertyAccess / MethodCall nodes
|
||||
while (true) {
|
||||
const nodeAtPos = tree.resolveInner(pos, -1);
|
||||
|
||||
if (nodeAtPos.name === "PropertyName") {
|
||||
path.unshift(doc.sliceString(nodeAtPos.from, nodeAtPos.to));
|
||||
// Skip backwards past the "." to the expression before it
|
||||
const dotCharPos = nodeAtPos.from - 1;
|
||||
if (dotCharPos >= 0 && doc.sliceString(dotCharPos, dotCharPos + 1) === ".") {
|
||||
pos = dotCharPos;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
} else if (nodeAtPos.name === "VariableName") {
|
||||
path.unshift(doc.sliceString(nodeAtPos.from, nodeAtPos.to));
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (path.length === 0) return { type: null, path };
|
||||
|
||||
// Resolve the type by walking the path
|
||||
const rootType = varTypes.get(path[0]) ?? null;
|
||||
if (path.length === 1) return { type: rootType, path };
|
||||
|
||||
// For multi-segment paths, look up field types
|
||||
// e.g. path=["customer","name"] → look up "customer.name" in varTypes
|
||||
let currentType = rootType;
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
if (currentType !== "Object") {
|
||||
return { type: currentType, path };
|
||||
}
|
||||
// "customer.name" key convention
|
||||
const key = `${path[i - 1]}.${path[i]}`;
|
||||
const fieldType = varTypes.get(key) ?? null;
|
||||
currentType = fieldType;
|
||||
}
|
||||
|
||||
return { type: currentType, path };
|
||||
}
|
||||
|
||||
function completions(context: CompletionContext): CompletionResult | null {
|
||||
const tree = syntaxTree(context.state);
|
||||
const node = tree.resolveInner(context.pos, -1);
|
||||
|
||||
// Don't complete inside strings or comments
|
||||
if (
|
||||
node.name === "String" ||
|
||||
node.name === "LineComment" ||
|
||||
node.name === "BlockComment"
|
||||
)
|
||||
return null;
|
||||
|
||||
// Method/property completion: after "."
|
||||
const dotMatch = context.matchBefore(/\.\w*/);
|
||||
if (dotMatch) {
|
||||
const dotPos = dotMatch.from;
|
||||
const beforeNode = tree.resolveInner(dotPos, -1);
|
||||
|
||||
const varTypes = inferVariableTypes(context, configVarTypes);
|
||||
|
||||
if (beforeNode.name === "Number") {
|
||||
return null; // could be decimal
|
||||
}
|
||||
|
||||
// Try to resolve the full dotted path for type info
|
||||
const { type: resolvedType, path } = resolveDotPath(context, dotPos, varTypes);
|
||||
|
||||
// For literal types, resolve directly
|
||||
let finalType = resolvedType;
|
||||
if (!finalType) {
|
||||
if (beforeNode.name === "String") finalType = "String";
|
||||
else if (beforeNode.name === "BooleanLiteral") finalType = "Boolean";
|
||||
}
|
||||
|
||||
let options: Completion[];
|
||||
|
||||
if (finalType === "Object") {
|
||||
// Show field names + Object methods
|
||||
const rootVarName = path[0];
|
||||
const fieldItems = objectFieldCompletions.get(rootVarName) ?? [];
|
||||
const objMethods = methodsByType["Object"] ?? [];
|
||||
options = [...fieldItems, ...objMethods];
|
||||
} else if (finalType) {
|
||||
options = methodsByType[finalType] ?? allMethods;
|
||||
} else {
|
||||
options = allMethods;
|
||||
}
|
||||
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return {
|
||||
from: dotMatch.from + 1,
|
||||
options,
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
|
||||
// Identifier/keyword completion
|
||||
const wordMatch = context.matchBefore(/[a-zA-Z_]\w*/);
|
||||
if (!wordMatch && !context.explicit) return null;
|
||||
if (wordMatch && wordMatch.from === wordMatch.to && !context.explicit)
|
||||
return null;
|
||||
|
||||
return {
|
||||
from: wordMatch?.from ?? context.pos,
|
||||
options: allIdentifiers,
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
|
||||
return autocompletion({ override: [completions] });
|
||||
}
|
||||
|
||||
export { KEYWORDS };
|
||||
Reference in New Issue
Block a user