/** * 轻量表达式求值器:支持 ${expr} 插值、变量、成员/下标、算术/比较/逻辑/三元与白名单函数。 * 不使用 eval / new Function;错误带字符位置,便于在节点日志里定位。 */ type TokenType = "num" | "str" | "ident" | "op" | "eof"; interface Token { type: TokenType; value: string; pos: number; } export class ExpressionError extends Error { position: number; constructor(message: string, position = 0) { super(message); this.name = "ExpressionError"; this.position = position; } } const OPERATORS = [ "===", "!==", "==", "!=", ">=", "<=", "&&", "||", "??", "+", "-", "*", "/", "%", ">", "<", "!", "?", ":", "(", ")", "[", "]", ".", "," ]; function tokenize(source: string): Token[] { const tokens: Token[] = []; let i = 0; while (i < source.length) { const ch = source[i]; if (/\s/.test(ch)) { i += 1; continue; } if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(source[i + 1] || ""))) { const start = i; while (i < source.length && /[0-9.]/.test(source[i])) i += 1; tokens.push({ type: "num", value: source.slice(start, i), pos: start }); continue; } if (ch === '"' || ch === "'") { const quote = ch; const start = i; i += 1; let value = ""; while (i < source.length && source[i] !== quote) { if (source[i] === "\\") { i += 1; const next = source[i]; if (next === "n") value += "\n"; else if (next === "t") value += "\t"; else if (next === "r") value += "\r"; else value += next ?? ""; i += 1; } else { value += source[i]; i += 1; } } if (source[i] !== quote) throw new ExpressionError("字符串未闭合", start); i += 1; tokens.push({ type: "str", value, pos: start }); continue; } if (/[A-Za-z_\u4e00-\u9fa5$]/.test(ch)) { const start = i; while (i < source.length && /[A-Za-z0-9_\u4e00-\u9fa5$]/.test(source[i])) i += 1; tokens.push({ type: "ident", value: source.slice(start, i), pos: start }); continue; } const op = OPERATORS.find((candidate) => source.startsWith(candidate, i)); if (op) { tokens.push({ type: "op", value: op, pos: i }); i += op.length; continue; } throw new ExpressionError(`无法识别的字符「${ch}」`, i); } tokens.push({ type: "eof", value: "", pos: source.length }); return tokens; } type Ast = | { type: "literal"; value: unknown } | { type: "identifier"; name: string; pos: number } | { type: "member"; object: Ast; property: Ast; computed: boolean; pos: number } | { type: "call"; callee: Ast; args: Ast[]; pos: number } | { type: "unary"; op: string; argument: Ast; pos: number } | { type: "binary"; op: string; left: Ast; right: Ast; pos: number } | { type: "ternary"; test: Ast; consequent: Ast; alternate: Ast; pos: number } | { type: "array"; items: Ast[]; pos: number }; const PRECEDENCE: Record = { "||": 1, "??": 1, "&&": 2, "==": 3, "!=": 3, "===": 3, "!==": 3, ">": 4, ">=": 4, "<": 4, "<=": 4, "+": 5, "-": 5, "*": 6, "/": 6, "%": 6 }; class Parser { private tokens: Token[]; private index = 0; constructor(source: string) { this.tokens = tokenize(source); } parse(): Ast { const expression = this.parseExpression(0); const token = this.peek(); if (token.type !== "eof") throw new ExpressionError(`多余的内容「${token.value}」`, token.pos); return expression; } private peek(): Token { return this.tokens[this.index]; } private next(): Token { return this.tokens[this.index++]; } private expect(value: string): Token { const token = this.next(); if (token.value !== value) throw new ExpressionError(`期望「${value}」,实际「${token.value || "结束"}」`, token.pos); return token; } private parseExpression(minPrecedence: number): Ast { let left = this.parsePrefix(); for (;;) { const token = this.peek(); if (token.type === "op" && token.value === "?") { if (minPrecedence > 0) break; this.next(); const consequent = this.parseExpression(0); this.expect(":"); const alternate = this.parseExpression(0); left = { type: "ternary", test: left, consequent, alternate, pos: token.pos }; continue; } const precedence = token.type === "op" ? PRECEDENCE[token.value] : undefined; if (precedence === undefined || precedence < minPrecedence) break; this.next(); const right = this.parseExpression(precedence + 1); left = { type: "binary", op: token.value, left, right, pos: token.pos }; } return left; } private parsePrefix(): Ast { const token = this.next(); if (token.type === "num") return { type: "literal", value: Number(token.value) }; if (token.type === "str") return { type: "literal", value: token.value }; if (token.type === "ident") { if (token.value === "true") return { type: "literal", value: true }; if (token.value === "false") return { type: "literal", value: false }; if (token.value === "null") return { type: "literal", value: null }; if (token.value === "undefined") return { type: "literal", value: undefined }; return this.parsePostfix({ type: "identifier", name: token.value, pos: token.pos }); } if (token.type === "op") { if (token.value === "(") { const expression = this.parseExpression(0); this.expect(")"); return this.parsePostfix(expression); } if (token.value === "[" ) { const items: Ast[] = []; if (this.peek().value !== "]") { for (;;) { items.push(this.parseExpression(0)); if (this.peek().value === ",") { this.next(); continue; } break; } } this.expect("]"); return { type: "array", items, pos: token.pos }; } if (token.value === "!" || token.value === "-") { return { type: "unary", op: token.value, argument: this.parseExpression(7), pos: token.pos }; } } throw new ExpressionError(`意外的「${token.value || "结束"}」`, token.pos); } private parsePostfix(base: Ast): Ast { let expression = base; for (;;) { const token = this.peek(); if (token.value === ".") { this.next(); const property = this.next(); if (property.type !== "ident") throw new ExpressionError("属性名缺失", property.pos); expression = { type: "member", object: expression, property: { type: "literal", value: property.value }, computed: false, pos: token.pos }; } else if (token.value === "[") { this.next(); const property = this.parseExpression(0); this.expect("]"); expression = { type: "member", object: expression, property, computed: true, pos: token.pos }; } else if (token.value === "(") { this.next(); const args: Ast[] = []; if (this.peek().value !== ")") { for (;;) { args.push(this.parseExpression(0)); if (this.peek().value === ",") { this.next(); continue; } break; } } this.expect(")"); expression = { type: "call", callee: expression, args, pos: token.pos }; } else { break; } } return expression; } } const astCache = new Map(); function parse(source: string): Ast { const cached = astCache.get(source); if (cached) return cached; const ast = new Parser(source).parse(); if (astCache.size > 500) astCache.clear(); astCache.set(source, ast); return ast; } type Scope = Record; function hasOwn(scope: Scope, name: string): boolean { return Object.prototype.hasOwnProperty.call(scope, name); } function formatDate(value: unknown, pattern = "YYYY-MM-DD HH:mm:ss"): string { const date = value instanceof Date ? value : new Date(value as string | number); if (Number.isNaN(date.getTime())) return ""; const pad = (n: number) => String(n).padStart(2, "0"); return pattern .replace(/YYYY/g, String(date.getFullYear())) .replace(/MM/g, pad(date.getMonth() + 1)) .replace(/DD/g, pad(date.getDate())) .replace(/HH/g, pad(date.getHours())) .replace(/mm/g, pad(date.getMinutes())) .replace(/ss/g, pad(date.getSeconds())); } const FUNCTIONS: Record unknown> = { len: (value) => (value == null ? 0 : (value as string | unknown[]).length ?? 0), upper: (value) => String(value ?? "").toUpperCase(), lower: (value) => String(value ?? "").toLowerCase(), trim: (value) => String(value ?? "").trim(), replace: (value, from, to) => String(value ?? "").split(String(from ?? "")).join(String(to ?? "")), split: (value, separator) => String(value ?? "").split(String(separator ?? "")), join: (value, separator = ",") => (Array.isArray(value) ? value.join(String(separator)) : ""), contains: (value, needle) => String(value ?? "").includes(String(needle ?? "")), startsWith: (value, prefix) => String(value ?? "").startsWith(String(prefix ?? "")), endsWith: (value, suffix) => String(value ?? "").endsWith(String(suffix ?? "")), matches: (value, pattern) => { try { return new RegExp(String(pattern ?? "")).test(String(value ?? "")); } catch { return false; } }, number: (value) => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }, string: (value) => (value == null ? "" : String(value)), round: (value, digits = 0) => { const factor = 10 ** Number(digits || 0); return Math.round(Number(value) * factor) / factor; }, abs: (value) => Math.abs(Number(value)), min: (...args) => Math.min(...args.map(Number)), max: (...args) => Math.max(...args.map(Number)), random: () => Math.random(), now: () => new Date().toISOString(), today: () => formatDate(new Date(), "YYYY-MM-DD"), formatDate: (value, pattern) => formatDate(value, pattern == null ? undefined : String(pattern)), json: (value) => JSON.stringify(value), parseJson: (value) => { try { return JSON.parse(String(value ?? "")); } catch { return null; } }, sum: (value) => (Array.isArray(value) ? value.reduce((sum, item) => sum + Number(item || 0), 0) : 0), avg: (value) => (Array.isArray(value) && value.length ? value.reduce((sum, item) => sum + Number(item || 0), 0) / value.length : 0), coalesce: (...args) => args.find((item) => item !== undefined && item !== null && item !== "") ?? "" }; function evaluateAst(ast: Ast, scope: Scope): unknown { switch (ast.type) { case "literal": return ast.value; case "identifier": { if (hasOwn(scope, ast.name)) return scope[ast.name]; if (ast.name in FUNCTIONS) return FUNCTIONS[ast.name]; return undefined; } case "array": return ast.items.map((item) => evaluateAst(item, scope)); case "member": { const object = evaluateAst(ast.object, scope); const property = evaluateAst(ast.property, scope); if (object == null) return undefined; if (Array.isArray(object) && property === "length") return object.length; if (typeof object === "string" && property === "length") return object.length; return (object as Record)[property as string | number]; } case "call": { if (ast.callee.type !== "identifier") throw new ExpressionError("只允许调用白名单函数", ast.pos); const fn = FUNCTIONS[ast.callee.name]; if (!fn) throw new ExpressionError(`未知函数「${ast.callee.name}」`, ast.pos); return fn(...ast.args.map((arg) => evaluateAst(arg, scope))); } case "unary": { const value = evaluateAst(ast.argument, scope); if (ast.op === "!") return !value; return -Number(value); } case "ternary": { return evaluateAst(ast.test, scope) ? evaluateAst(ast.consequent, scope) : evaluateAst(ast.alternate, scope); } case "binary": { if (ast.op === "&&") { const left = evaluateAst(ast.left, scope); return left ? evaluateAst(ast.right, scope) : left; } if (ast.op === "||") { const left = evaluateAst(ast.left, scope); return left ? left : evaluateAst(ast.right, scope); } if (ast.op === "??") { const left = evaluateAst(ast.left, scope); return left ?? evaluateAst(ast.right, scope); } const left = evaluateAst(ast.left, scope); const right = evaluateAst(ast.right, scope); switch (ast.op) { case "+": return typeof left === "string" || typeof right === "string" ? String(left ?? "") + String(right ?? "") : Number(left) + Number(right); case "-": return Number(left) - Number(right); case "*": return Number(left) * Number(right); case "/": return Number(left) / Number(right); case "%": return Number(left) % Number(right); case ">": return left! > right!; case ">=": return left! >= right!; case "<": return left! < right!; case "<=": return left! <= right!; case "==": return left == right; case "!=": return left != right; case "===": return left === right; case "!==": return left !== right; default: throw new ExpressionError(`不支持的运算符「${ast.op}」`, ast.pos); } } } } export function evaluate(expression: string, scope: Scope = {}): unknown { const source = expression.trim(); if (!source) return ""; return evaluateAst(parse(source), scope); } /** 整个字符串是 ${expr} 时保留类型;否则按字符串插值。 */ export function interpolate(value: unknown, scope: Scope): unknown { if (typeof value !== "string") return value; const whole = value.match(/^\s*\$\{([\s\S]*)\}\s*$/); if (whole) return evaluate(whole[1], scope); if (!value.includes("${")) return value; return value.replace(/\$\{([\s\S]*?)\}/g, (_match, expression: string) => { const result = evaluate(expression, scope); return result == null ? "" : typeof result === "object" ? JSON.stringify(result) : String(result); }); } /** 条件表达式允许直接写变量名或 ${...} 包裹。 */ export function evaluateCondition(value: unknown, scope: Scope): boolean { if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value !== "string") return Boolean(value); const trimmed = value.trim(); if (!trimmed) return false; const inner = trimmed.match(/^\$\{([\s\S]*)\}$/); const result = interpolate(inner ? trimmed : `\${${trimmed}}`, scope); return Boolean(result); } export function toNumber(value: unknown, fallback = 0): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }