web/src/lib/template-expr.ts
4.4 KB · sha256:d835ea191cddd55841908f5e0b0e7a05f0d3163140f3c93ac477e8d59d59f8a8
import { compileMath, evalMath, MathParseError } from "./math-expr";
export interface TemplateEnv {
/** Variables visible to math expressions inside `{...}`. */
vars: Record<string, number>;
/**
* Decimal places to format substituted numbers with. `"auto"` (default)
* keeps integers integer and renders floats with up to 6 significant
* digits trimmed of trailing zeros.
*/
decimals?: number | "auto";
}
const RE_BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
/**
* Expand a template string containing both PAPI placeholders and math
* expressions inside `{...}`. Rules per brace group:
*
* - **Bare (dotted) identifier** like `{vault_eco_balance}` or `{i}`:
* * if the name is a defined math var (or constant), substitute its
* numeric value;
* * otherwise leave the `{name}` verbatim -- it's a runtime PAPI
* placeholder for Ward's expression evaluator to resolve at
* check-time.
* - **Anything with operators / functions / numbers** like
* `{base * (1.1 ^ i)}`, `{floor(100 * idx)}`: parse + evaluate as
* math; substitute the numeric result. Unknown variables in math
* contexts throw -- the template author needs to know what's bound.
*
* Brace groups can nest (`{floor({base} * 1.1)}`) because we match
* balanced pairs. Unmatched `{` is left verbatim.
*/
export function expandMathTemplate(template: string, env: TemplateEnv): string {
let out = "";
let i = 0;
while (i < template.length) {
const ch = template[i];
// Allow `\{` / `\}` escapes for literal braces.
if (ch === "\\" && (template[i + 1] === "{" || template[i + 1] === "}")) {
out += template[i + 1];
i += 2;
continue;
}
if (ch !== "{") {
out += ch;
i++;
continue;
}
const end = findMatchingBrace(template, i);
if (end === -1) {
out += ch;
i++;
continue;
}
const content = template.slice(i + 1, end).trim();
const original = template.slice(i, end + 1);
out += processBrace(content, env, original);
i = end + 1;
}
return out;
}
function findMatchingBrace(s: string, from: number): number {
let depth = 0;
for (let i = from; i < s.length; i++) {
const c = s[i];
if (c === "\\" && (s[i + 1] === "{" || s[i + 1] === "}")) {
i++;
continue;
}
if (c === "{") depth++;
else if (c === "}" && --depth === 0) return i;
}
return -1;
}
function processBrace(content: string, env: TemplateEnv, original: string): string {
if (RE_BARE_IDENT.test(content)) {
if (Object.prototype.hasOwnProperty.call(env.vars, content)) {
return formatNum(env.vars[content], env.decimals);
}
// Not a known math var -- treat as a PAPI placeholder, leave intact.
return original;
}
// Math expression: parse + eval. We DON'T silently fall back to "leave
// verbatim" here because that would mask user typos (e.g. writing
// `{base * 1.1^i}` but mis-naming `base`). The thrown error bubbles
// up to the API and the user sees what's wrong.
let ast;
try {
ast = compileMath(content);
} catch (e) {
if (e instanceof MathParseError) {
throw new Error(`bad math in template '${original}': ${e.message}`);
}
throw e;
}
let value: number;
try {
value = evalMath(ast, { vars: env.vars });
} catch (e) {
throw new Error(`eval error in template '${original}': ${(e as Error).message}`);
}
return formatNum(value, env.decimals);
}
function formatNum(n: number, dec?: number | "auto"): string {
if (!Number.isFinite(n)) return String(n);
if (typeof dec === "number") return n.toFixed(dec);
// Auto: integers stay integer; floats get trimmed precision.
if (Number.isInteger(n)) return String(n);
// toPrecision can yield exponential for huge/tiny numbers; restrict
// exponent for sanity, otherwise use toString.
const str =
Math.abs(n) >= 1e-4 && Math.abs(n) < 1e15
? Number(n.toPrecision(10)).toString()
: n.toString();
return str;
}
/**
* Per-row variant: same template, expanded with a per-row var override
* (typically `{ i, n, idx }`). Returned in row order.
*/
export function expandPerRow(
template: string,
rows: number,
baseVars: Record<string, number>,
): string[] {
const out: string[] = [];
for (let i = 0; i < rows; i++) {
out.push(
expandMathTemplate(template, {
vars: { ...baseVars, i, n: rows, idx: i + 1 },
}),
);
}
return out;
}