Files
PRA-only-demo/packages/rpa-core/src/engine/expression.ts
T
yu caa8e19332 阶段1-rpa-core建包: engine+31个纯Node能力迁入packages/rpa-core, 领域类型单一源化, createEngine工厂收拢模块级状态
- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export)
- 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起
- 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例
- remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows
- 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名
- 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
2026-11-04 12:36:03 +08:00

465 lines
15 KiB
TypeScript

/**
* 轻量表达式求值器:支持 ${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<string, number> = {
"||": 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<string, Ast>();
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<string, unknown>;
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<string, (...args: unknown[]) => 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<string | number, unknown>)[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;
}