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" | "List"; 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>; 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 ): Map { 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 ): 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); const rootType = knownTypes.get(varName); if (rootType === "Object") { return knownTypes.get(`${varName}.${fieldName}`) ?? null; } if (rootType === "List") { // Property projection: list.field → typed list based on field type const fieldType = knownTypes.get(`${varName}.${fieldName}`) ?? null; return projectedListType(fieldType); } } 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 */ export 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"; // List methods case "map": return null; // depends on field type (NumberList, StringList, or List) case "filter": return "List"; case "find": return null; // returns single element default: return null; } } /** * Given a field type from an Object element within a List, * return the projected list type after property access. * e.g. List with Number field "tutar" → kalemler.tutar → NumberList */ export function projectedListType(fieldType: DexprType | null): DexprType { if (fieldType === "Number") return "NumberList"; if (fieldType === "String") return "StringList"; return "List"; } // --- Autocomplete --- function dedup(items: Completion[]): Completion[] { const seen = new Set(); 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 = {}; 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(); 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(); for (const v of info.variables ?? []) { if ((v.type === "Object" || v.type === "List") && 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 ): { 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") { const key = `${path[i - 1]}.${path[i]}`; currentType = varTypes.get(key) ?? null; } else if (currentType === "List") { // Property projection: list.field → typed list const key = `${path[0]}.${path[i]}`; const fieldType = varTypes.get(key) ?? null; currentType = projectedListType(fieldType); } else { return { type: currentType, path }; } } 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 === "List") { // Show field names (property projection) + List methods const rootVarName = path[0]; const fieldItems = objectFieldCompletions.get(rootVarName) ?? []; const listMethods = methodsByType["List"] ?? []; options = [...fieldItems, ...listMethods]; } 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 };