Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
356 lines
9.6 KiB
TypeScript
356 lines
9.6 KiB
TypeScript
/**
|
||
* Safe math-expression evaluator for the generic-formula simulator.
|
||
* Tokenizer + recursive-descent parser — NO eval/Function, no object access,
|
||
* no loops. Identifiers resolve only against an explicit environment;
|
||
* functions come from a fixed allowlist.
|
||
*/
|
||
|
||
type Token =
|
||
| { t: 'num'; v: number }
|
||
| { t: 'id'; v: string }
|
||
| { t: 'op'; v: string }
|
||
| { t: 'lparen' }
|
||
| { t: 'rparen' }
|
||
| { t: 'comma' }
|
||
|
||
const CONSTANTS: Record<string, number> = {
|
||
pi: Math.PI,
|
||
e: Math.E,
|
||
}
|
||
|
||
type Fn = (...args: number[]) => number
|
||
const FUNCTIONS: Record<string, { fn: Fn; minArgs: number; maxArgs: number }> = {
|
||
sqrt: { fn: Math.sqrt, minArgs: 1, maxArgs: 1 },
|
||
abs: { fn: Math.abs, minArgs: 1, maxArgs: 1 },
|
||
exp: { fn: Math.exp, minArgs: 1, maxArgs: 1 },
|
||
ln: { fn: Math.log, minArgs: 1, maxArgs: 1 },
|
||
log: { fn: Math.log10, minArgs: 1, maxArgs: 1 },
|
||
round: { fn: Math.round, minArgs: 1, maxArgs: 1 },
|
||
floor: { fn: Math.floor, minArgs: 1, maxArgs: 1 },
|
||
ceil: { fn: Math.ceil, minArgs: 1, maxArgs: 1 },
|
||
min: { fn: Math.min, minArgs: 1, maxArgs: 8 },
|
||
max: { fn: Math.max, minArgs: 1, maxArgs: 8 },
|
||
}
|
||
|
||
const ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||
|
||
export type SimExprError = { message: string }
|
||
|
||
type Ast =
|
||
| { k: 'num'; v: number }
|
||
| { k: 'id'; v: string }
|
||
| { k: 'call'; name: string; args: Ast[] }
|
||
| { k: 'un'; op: '-'; a: Ast }
|
||
| { k: 'bin'; op: string; a: Ast; b: Ast }
|
||
|
||
function tokenize(src: string): Token[] | SimExprError {
|
||
const tokens: Token[] = []
|
||
let i = 0
|
||
while (i < src.length) {
|
||
const ch = src[i]
|
||
if (ch === ' ' || ch === '\t' || ch === '\n') {
|
||
i++
|
||
continue
|
||
}
|
||
if (ch >= '0' && ch <= '9') {
|
||
let j = i
|
||
while (j < src.length && /[0-9.]/.test(src[j])) j++
|
||
const raw = src.slice(i, j)
|
||
const v = Number(raw)
|
||
if (!Number.isFinite(v)) return { message: `invalid number "${raw}"` }
|
||
tokens.push({ t: 'num', v })
|
||
i = j
|
||
continue
|
||
}
|
||
if (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9') {
|
||
let j = i + 1
|
||
while (j < src.length && /[0-9]/.test(src[j])) j++
|
||
tokens.push({ t: 'num', v: Number(src.slice(i, j)) })
|
||
i = j
|
||
continue
|
||
}
|
||
if (/[A-Za-z_]/.test(ch)) {
|
||
let j = i
|
||
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++
|
||
tokens.push({ t: 'id', v: src.slice(i, j) })
|
||
i = j
|
||
continue
|
||
}
|
||
if ('+-*/^%'.includes(ch)) {
|
||
tokens.push({ t: 'op', v: ch })
|
||
i++
|
||
continue
|
||
}
|
||
if (ch === '(') {
|
||
tokens.push({ t: 'lparen' })
|
||
i++
|
||
continue
|
||
}
|
||
if (ch === ')') {
|
||
tokens.push({ t: 'rparen' })
|
||
i++
|
||
continue
|
||
}
|
||
if (ch === ',') {
|
||
tokens.push({ t: 'comma' })
|
||
i++
|
||
continue
|
||
}
|
||
return { message: `unexpected character "${ch}"` }
|
||
}
|
||
return tokens
|
||
}
|
||
|
||
class Parser {
|
||
private pos = 0
|
||
constructor(private tokens: Token[]) {}
|
||
|
||
private peek(): Token | undefined {
|
||
return this.tokens[this.pos]
|
||
}
|
||
private next(): Token | undefined {
|
||
return this.tokens[this.pos++]
|
||
}
|
||
private expectOp(): string | null {
|
||
const t = this.peek()
|
||
return t?.t === 'op' ? t.v : null
|
||
}
|
||
|
||
parseExpr(): Ast | SimExprError {
|
||
let left = this.parseTerm()
|
||
if ('message' in left) return left
|
||
for (;;) {
|
||
const op = this.expectOp()
|
||
if (op !== '+' && op !== '-') break
|
||
this.next()
|
||
const right = this.parseTerm()
|
||
if ('message' in right) return right
|
||
left = { k: 'bin', op, a: left, b: right }
|
||
}
|
||
return left
|
||
}
|
||
|
||
private parseTerm(): Ast | SimExprError {
|
||
let left = this.parseUnary()
|
||
if ('message' in left) return left
|
||
for (;;) {
|
||
const op = this.expectOp()
|
||
if (op !== '*' && op !== '/' && op !== '%') break
|
||
this.next()
|
||
const right = this.parseUnary()
|
||
if ('message' in right) return right
|
||
left = { k: 'bin', op, a: left, b: right }
|
||
}
|
||
return left
|
||
}
|
||
|
||
private parseUnary(): Ast | SimExprError {
|
||
if (this.expectOp() === '-') {
|
||
this.next()
|
||
const a = this.parseUnary()
|
||
if ('message' in a) return a
|
||
return { k: 'un', op: '-', a }
|
||
}
|
||
if (this.expectOp() === '+') {
|
||
this.next()
|
||
return this.parseUnary()
|
||
}
|
||
return this.parsePower()
|
||
}
|
||
|
||
private parsePower(): Ast | SimExprError {
|
||
const base = this.parseAtom()
|
||
if ('message' in base) return base
|
||
if (this.expectOp() === '^') {
|
||
this.next()
|
||
const exp = this.parseUnary() // right-assoc
|
||
if ('message' in exp) return exp
|
||
return { k: 'bin', op: '^', a: base, b: exp }
|
||
}
|
||
return base
|
||
}
|
||
|
||
private parseAtom(): Ast | SimExprError {
|
||
const t = this.next()
|
||
if (!t) return { message: 'unexpected end of expression' }
|
||
if (t.t === 'num') return { k: 'num', v: t.v }
|
||
if (t.t === 'lparen') {
|
||
const inner = this.parseExpr()
|
||
if ('message' in inner) return inner
|
||
const close = this.next()
|
||
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
|
||
return inner
|
||
}
|
||
if (t.t === 'id') {
|
||
if (this.peek()?.t === 'lparen') {
|
||
this.next() // consume (
|
||
const args: Ast[] = []
|
||
if (this.peek()?.t !== 'rparen') {
|
||
for (;;) {
|
||
const arg = this.parseExpr()
|
||
if ('message' in arg) return arg
|
||
args.push(arg)
|
||
if (this.peek()?.t === 'comma') {
|
||
this.next()
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
}
|
||
const close = this.next()
|
||
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
|
||
return { k: 'call', name: t.v, args }
|
||
}
|
||
return { k: 'id', v: t.v }
|
||
}
|
||
return { message: `unexpected token "${'v' in t ? t.v : t.t}"` }
|
||
}
|
||
|
||
parseTop(): Ast | SimExprError {
|
||
const ast = this.parseExpr()
|
||
if ('message' in ast) return ast
|
||
if (this.pos < this.tokens.length) {
|
||
return { message: 'trailing tokens after expression' }
|
||
}
|
||
return ast
|
||
}
|
||
}
|
||
|
||
function evalAst(ast: Ast, env: Record<string, number>): number {
|
||
switch (ast.k) {
|
||
case 'num':
|
||
return ast.v
|
||
case 'id': {
|
||
if (ast.v in CONSTANTS) return CONSTANTS[ast.v]
|
||
const v = env[ast.v]
|
||
return typeof v === 'number' ? v : NaN
|
||
}
|
||
case 'un':
|
||
return -evalAst(ast.a, env)
|
||
case 'bin': {
|
||
const a = evalAst(ast.a, env)
|
||
const b = evalAst(ast.b, env)
|
||
switch (ast.op) {
|
||
case '+':
|
||
return a + b
|
||
case '-':
|
||
return a - b
|
||
case '*':
|
||
return a * b
|
||
case '/':
|
||
return b === 0 ? NaN : a / b
|
||
case '%':
|
||
return b === 0 ? NaN : a % b
|
||
case '^':
|
||
return Math.pow(a, b)
|
||
default:
|
||
return NaN
|
||
}
|
||
}
|
||
case 'call': {
|
||
const def = FUNCTIONS[ast.name]
|
||
if (!def) return NaN
|
||
const args = ast.args.map((a) => evalAst(a, env))
|
||
if (args.some((x) => Number.isNaN(x))) return NaN
|
||
return def.fn(...args)
|
||
}
|
||
}
|
||
}
|
||
|
||
function collectIds(ast: Ast, out: Set<string>): void {
|
||
switch (ast.k) {
|
||
case 'num':
|
||
return
|
||
case 'id':
|
||
out.add(ast.v)
|
||
return
|
||
case 'un':
|
||
collectIds(ast.a, out)
|
||
return
|
||
case 'bin':
|
||
collectIds(ast.a, out)
|
||
collectIds(ast.b, out)
|
||
return
|
||
case 'call':
|
||
ast.args.forEach((a) => collectIds(a, out))
|
||
return
|
||
}
|
||
}
|
||
|
||
export type ParsedSimExpr = {
|
||
/** Evaluate against an environment; NaN when uncomputable. */
|
||
evaluate(env: Record<string, number>): number
|
||
/** Identifiers used (params/computed refs), excluding constants. */
|
||
identifiers: string[]
|
||
}
|
||
|
||
/**
|
||
* Parse a safe math expression. Returns error message on syntax problems.
|
||
* Unknown function names are rejected at parse time.
|
||
*/
|
||
export function parseSimExpr(src: string): ParsedSimExpr | SimExprError {
|
||
const trimmed = src.trim()
|
||
if (!trimmed || trimmed.length > 200) {
|
||
return { message: 'expression empty or too long (max 200 chars)' }
|
||
}
|
||
const tokens = tokenize(trimmed)
|
||
if ('message' in tokens) return tokens
|
||
const ast = new Parser(tokens).parseTop()
|
||
if ('message' in ast) return ast
|
||
|
||
const ids = new Set<string>()
|
||
collectIds(ast, ids)
|
||
for (const id of ids) {
|
||
if (id in FUNCTIONS) {
|
||
return { message: `"${id}" is a function name — call it with (...)` }
|
||
}
|
||
}
|
||
// Validate function calls (unknown names, arity)
|
||
const checkCalls = (node: Ast): SimExprError | null => {
|
||
if (node.k === 'call') {
|
||
const def = FUNCTIONS[node.name]
|
||
if (!def) return { message: `unknown function "${node.name}"` }
|
||
if (node.args.length < def.minArgs || node.args.length > def.maxArgs) {
|
||
return { message: `function "${node.name}" expects ${def.minArgs}–${def.maxArgs} args` }
|
||
}
|
||
for (const a of node.args) {
|
||
const err = checkCalls(a)
|
||
if (err) return err
|
||
}
|
||
} else if (node.k === 'un') {
|
||
return checkCalls(node.a)
|
||
} else if (node.k === 'bin') {
|
||
return checkCalls(node.a) ?? checkCalls(node.b)
|
||
}
|
||
return null
|
||
}
|
||
const callErr = checkCalls(ast)
|
||
if (callErr) return callErr
|
||
|
||
const identifiers = [...ids].filter((id) => !(id in CONSTANTS))
|
||
return {
|
||
evaluate: (env) => evalAst(ast, env),
|
||
identifiers,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validation helper: parse + require every identifier ∈ allowedIds.
|
||
* Returns a list of issue messages (empty = OK).
|
||
*/
|
||
export function validateSimExprRefs(
|
||
src: string,
|
||
allowedIds: Set<string>
|
||
): string[] {
|
||
const parsed = parseSimExpr(src)
|
||
if ('message' in parsed) return [parsed.message]
|
||
return parsed.identifiers
|
||
.filter((id) => !allowedIds.has(id))
|
||
.map((id) => `unknown identifier "${id}"`)
|
||
}
|
||
|
||
/** Type guard helper for zod refinements. */
|
||
export function isValidSimParamId(id: string): boolean {
|
||
return ID_RE.test(id) && !(id in FUNCTIONS) && !(id in CONSTANTS)
|
||
}
|