Files
dexpr/editor/src/completions.ts
2026-04-07 15:16:19 +03:00

472 lines
13 KiB
TypeScript

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<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);
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<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.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<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") {
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 };