// an expression language function run(code: string, ctx: { [name: string]: any } = {}): any { const tokens = lex(code); const tree = parse(tokens); const result = expr(tree, ctx); return result; } function expr(tree, ctx: { [name: string]: any } = {}): any { switch (tree.type) { case "int": return parseInt(tree.val); case "float": return parseFloat(tree.val); case "str": return tree.val.slice(1, -1); case "ident": switch (tree.val) { case "true": return true; case "false": return false; case "null": return null; default: return Object.hasOwn(ctx, tree.val) && ctx[tree.val]; } } if (tree.op) { if (tree.op.val === ".") { if (tree.cons[1].type !== "ident") throw "expected ident"; const obj = expr(tree.cons[0], ctx); const idx = tree.cons[1].val; if (!Object.hasOwn(obj, idx)) throw `${idx} doesn't exist on object`; return obj[idx]; } const op = ({ "**": (a, b) => a ** b, "*": (a, b) => a * b, "/": (a, b) => a / b, "%": (a, b) => a % b, "+": (a, b) => a + b, "-": (a, b) => b === undefined ? -a : a - b, "==": (a, b) => a == b, "!=": (a, b) => a != b, ">=": (a, b) => a >= b, "<=": (a, b) => a <= b, ">": (a, b) => a > b, "<": (a, b) => a < b, "in": (a, b) => { if (!Array.isArray(b)) throw "can only use `in` on arrays"; return b.includes(a); }, "&&": (a, b) => a && b, "||": (a, b) => a || b, "?": (cond, a, b) => cond ? a : b, ",": (a, b) => (a, b), })[tree.op.val]; if (!op) throw `invalid operator ${op}`; return op(...tree.cons.map(i => expr(i, ctx))); } if (tree.array) return tree.array.map(i => expr(i, ctx)); if (tree.func) return expr(tree.func, ctx)(...tree.cons.map(i => expr(i, ctx))); if (tree.index) { const obj = expr(tree.index, ctx); const idx = expr(tree.with, ctx); if (!Object.hasOwn(obj, idx)) throw `${idx} doesn't exist on object`; return obj[idx]; } } function parse(tokens, min = 0) { const bp = { ".": [24, 25], "(": [22, 23], "[": [20, 21], // negation/not: 19 "**": [18, 17], "*": [15, 16], "/": [15, 16], "%": [15, 16], "+": [13, 14], "-": [13, 14], "==": [11, 12], "!=": [11, 12], ">=": [11, 12], "<=": [11, 12], ">": [11, 12], "<": [11, 12], "in": [9, 10], "&&": [7, 8], "||": [5, 6], "?": [4, 3], ",": [2, 1], }; let lhs = tokens.next(); if (lhs.val === "-" || lhs.val === "!") { lhs = { op: lhs, cons: [parse(tokens, 19)] }; } else if (lhs.val === "(") { if (tokens.peek()?.val === ")") throw "empty paranthases"; const newlhs = parse(tokens, 0); if (tokens.next()?.val !== ")") throw "missing a closing paranthase"; lhs = newlhs; } else if (lhs.val === "[") { const cons = []; let next; if (tokens.peek()?.val === "]") { next = tokens.peek(); } else { do { cons.push(parse(tokens, 0)); next = tokens.next(); } while (next.val === ","); } if (next?.val !== "]") throw "missing a closing bracket"; lhs = { array: cons }; } else if (lhs.type === "symb" || lhs.val === "in") { throw "unexpected token " + lhs.val; } while (true) { const next = tokens.peek(); if (!next) break; if (next.type !== "symb" && next.type !== "paran" && next.val !== "in") throw "unexpected token " + next.val; if ([")", "]", ",", ":"].includes(next.val)) break; if (!bp[next.val]) throw "invalid symbol"; const [left, right] = bp[next.val]; if (left < min) break; if (next.val === "(" || next.val === "[") { const cons = []; do { tokens.next(); if (tokens.peek()?.val === ")") break; cons.push(parse(tokens, 0)); } while (next.val === "(" && tokens.peek()?.val === ","); if (next.val === "(") { if (tokens.next()?.val !== ")") throw "missing a closing paranthase"; lhs = { func: lhs, cons }; } else { if (tokens.next()?.val !== "]") throw "missing a closing bracket"; lhs = { index: lhs, with: cons[0] }; } continue; } else if (next.val === "?") { tokens.next(); const middle = parse(tokens, 0); if (tokens.next()?.val !== ":") throw "missing a colon"; lhs = { op: next, cons: [lhs, middle, parse(tokens, right)] }; } else { tokens.next(); lhs = { op: next, cons: [lhs, parse(tokens, right)] }; } } return lhs; } function lex(input: string) { let code = input; const tokens = []; while (code.length) { for (let [name, reg] of [ ["space", /^[ \n\t]+/], ["int", /^0(x[0-9a-f]+|b[01]+|o[0-7]+)/i], ["float", /^[0-9]+(\.[0-9]+)?/], ["str", /^"(.*?[^\\])?"/], ["str", /^'(.*?[^\\])?'/], ["ident", /^[a-z_][a-z0-9_]*/i], ["paran", /^[\[\]\(\)]/i], ["symb", /^[^ \n\ta-z0-9_]{1,2}/i], ]) { const match = code.match(reg); if (match) { tokens.push({ type: name, val: match[0] }); code = code.slice(match[0].length); break; } } } return { i: 0, tokens: tokens.filter(i => i.type !== "space"), peek() { return this.tokens[this.i] }, next() { return this.tokens[this.i++] }, }; } function test(code: string) { const ctx = { math: { rand: () => Math.random(), abs: (n) => Math.abs(n), sin: (n) => Math.sin(n), cos: (n) => Math.cos(n), tan: (n) => Math.tan(n), log: (n) => Math.log10(n), log2: (n) => Math.log2(n), sqrt: (n) => Math.sqrt(n), floor: (n) => Math.floor(n), ceil: (n) => Math.ceil(n), round: (n) => Math.round(n), max: (...n) => Math.max(...n), min: (...n) => Math.min(...n), sum: (...n) => n.reduce((a, i) => a + i), avg: (...n) => n.reduce((a, i) => a + i) / n, pi: Math.PI, tau: Math.PI * 2, e: Math.E, }, obj: { keys: (obj) => [...Object.keys(obj)], vals: (obj) => [...Object.values(obj)], entries: (obj) => [...Object.entries(obj)], has: (obj) => Object.hasOwn(obj), }, str: { capitalize: (str) => str.split(" ").map(i => i[0].toUpperCase() + i.slice(1).toLowerCase()).join(" "), upper: (str) => str.toUpperCase(), lower: (str) => str.toLowerCase(), trim: (str) => str.trim(), trimStart: (str) => str.trimStart(), trimEnd: (str) => str.trimEnd(), split: (str, by = " ") => str.split(by), slice: (str, start, end) => str.slice(by), }, }; console.log(code, "\x1b[90m=>\x1b[0m", run(code, ctx)); } test("2 + 3 * 4"); test("(2 + 3) * 4"); test("math.sin"); test("math.sin(10 * math.pi)"); test('"foo" in ["fooo", "bar", "baz"]'); test('"foo" ? 1 : 4'); test('"hello" + ", " + "world"'); test('"hello".length'); test('[1, 2, 3].length'); test('str.capitalize("hello world")'); test('"apple" in str.split("apple banana orange") ? "apple exists" : "apple doesn\'t exist"');