/* RFC-0570 formula evaluation module — extracts numeric values from field strings, scans text for =(...) formula expressions, and evaluates arithmetic over content references using a sandboxed math parser (expr-eval). RFC-0729: pipe syntax for post-evaluation formatting, plugin-registration formatter registry, and money formatter built on Intl.NumberFormat. Do not resolve content references directly — use resolveReference from @warpgogol/werkstatt-shared/content/content-reference. Do not support non-arithmetic expressions (string concatenation, conditionals, date math). Do not add formatters other than money and duration — date, percent, unit formatters are follow-up RFCs. RFC-0729: Add pipe syntax for post-evaluation formatting, formatter registry, and money formatter. RFC-0730: Add formatRecurrence utility for ISO 8601 duration → locale-specific suffix mapping. RFC-0731: Add sourceRef optional parameter for this. self-reference expansion in formulas. RFC-1156: Add duration pipe formatter (ISO 8601 → locale prose via Intl.DurationFormat) with raw-input single-ref passthrough. RFC-1156: duration-case extension — `case=` param (nominative|accusative|genitive|dative) on the duration pipe, CLDR word-table backend for non-nominative long forms. RFC-0570 */ import { Parser } from "expr-eval"; import type { ContentRefIndex, SourceRef } from "../content/content-reference.ts"; import { resolveReference } from "../content/content-reference.ts"; import { formatBusinessDays, formatDuration, parseIsoDuration, type DurationCase, } from "./duration.ts"; export interface FormulaResolution { value: string; resolved: boolean; error?: string; } export interface FormulaMatch { start: number; end: number; expression: string; } export type PipeFormatter = ( value: number | string, params: Record, context: PipeFormatterContext, ) => string; export interface PipeFormatterContext { lang: string; defaultLang: string; } interface PipeFormatterEntry { formatter: PipeFormatter; /** "raw" formatters (RFC-1156 duration) receive the resolved value untouched; * "numeric" formatters receive the evaluated number (extractNumeric path). */ input: "raw" | "numeric"; } const pipeFormatterRegistry = new Map(); export function registerPipeFormatter( name: string, formatter: PipeFormatter, options?: { input?: "raw" | "numeric" }, ): void { pipeFormatterRegistry.set(name, { formatter, input: options?.input ?? "numeric", }); } export function getPipeFormatter(name: string): PipeFormatter | undefined { return pipeFormatterRegistry.get(name)?.formatter; } registerPipeFormatter("money", (value, params, context) => { const currency = params.currency ?? "EUR"; const locale = params.locale ?? context.lang; const targetCurrency = params.targetCurrency; const rate = params.rate ? Number(params.rate) : undefined; let amount = typeof value === "number" ? value : Number(value); let code = currency; if (targetCurrency && rate !== undefined && Number.isFinite(rate)) { amount = amount * rate; code = targetCurrency; } return new Intl.NumberFormat(locale, { style: "currency", currency: code, currencyDisplay: "narrowSymbol", minimumFractionDigits: 0, maximumFractionDigits: 2, }).format(amount); }); // RFC-1156: `=(ref | duration)` — ISO 8601 duration → locale-aware prose. // input:"raw" passes the resolved string through untouched (see resolveFormula). registerPipeFormatter( "duration", (value, params, context) => { const ALLOWED_PARAMS = new Set(["locale", "style", "dayType", "case"]); for (const key of Object.keys(params)) { if (!ALLOWED_PARAMS.has(key)) { throw new Error(`duration: unknown param "${key}"`); } } const locale = params.locale || context.lang || context.defaultLang || "de"; const style = params.style ?? "long"; if (style !== "long" && style !== "short") { throw new Error(`duration: invalid style "${style}" (expected long|short)`); } const dayType = params.dayType; if (dayType !== undefined && dayType !== "business") { throw new Error(`duration: invalid dayType "${dayType}" (expected business)`); } const grammaticalCase = (params.case ?? "nominative") as DurationCase; if (!["nominative", "accusative", "genitive", "dative"].includes(grammaticalCase)) { throw new Error( `duration: invalid case "${grammaticalCase}" (expected nominative|accusative|genitive|dative)`, ); } const parts = parseIsoDuration(value); if (!parts) { throw new Error(`duration: value "${String(value)}" is not an ISO 8601 duration`); } if (dayType === "business") { const nonDay = { ...parts, days: 0 }; if (parts.days === 0 || Object.values(nonDay).some((v) => v > 0)) { throw new Error("duration: dayType=business requires a pure-days duration"); } return formatBusinessDays(parts.days, locale, grammaticalCase); } return formatDuration(parts, locale, style, grammaticalCase); }, { input: "raw" }, ); const parser = new Parser(); const REF_IN_FORMULA_PATTERN = /[a-z][a-z-]*\.[a-z0-9-/]+\.[a-zA-Z0-9_.-]+/g; const THIS_IN_FORMULA_PATTERN = /this\.[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*/g; const THIS_PREFIX = "this."; const FORMULA_PREFIX = "=("; /** * Extracts a numeric value from a field string. * Handles: leading number, thin-space (\u202f) / period / comma thousands separators, * comma decimal separator (German), negative numbers. * Strips non-numeric prefix/suffix. * Returns null for non-numeric values. */ export function extractNumeric(value: unknown): number | null { if (value === null || value === undefined) return null; if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "boolean") return null; if (typeof value !== "string") return null; const trimmed = value.trim(); if (trimmed === "") return null; // Remove currency symbols, units, and non-numeric prefix/suffix // Keep: digits, period, comma, minus, plus, thin space, regular space const cleaned = trimmed .replace(/^[^0-9\-+]+/, "") // strip non-numeric prefix .replace(/[^0-9.,\-\u202f\s+]+$/, "") // strip non-numeric suffix .replace(/\u202f/g, "") // thin space thousands separator .replace(/\s/g, "") // regular space thousands separator .trim(); if (cleaned === "" || cleaned === "-" || cleaned === "+") return null; // Detect format: if both period and comma are present, period=thousands, comma=decimal (German) // If only comma is present, it's a decimal separator (German) // If only period is present, it's a decimal separator (English) let normalized: string; const hasPeriod = cleaned.includes("."); const hasComma = cleaned.includes(","); if (hasPeriod && hasComma) { // Period = thousands, comma = decimal (German format: 1.040,50) normalized = cleaned.replace(/\./g, "").replace(",", "."); } else if (hasComma) { // Comma = decimal (German format: 70,50) normalized = cleaned.replace(",", "."); } else if (hasPeriod) { // Period only: could be thousands (German: 1.040) or decimal (English: 70.50) // Heuristic: if the period is followed by exactly 3 digits and nothing after, // treat as thousands separator. Otherwise treat as decimal. const periodIndex = cleaned.lastIndexOf("."); const afterPeriod = cleaned.slice(periodIndex + 1); if (/^\d{3}$/.test(afterPeriod) && cleaned.indexOf(".") === periodIndex) { // Single period followed by exactly 3 digits → thousands separator normalized = cleaned.replace(/\./g, ""); } else { normalized = cleaned; } } else { normalized = cleaned; } const result = Number.parseFloat(normalized); return Number.isFinite(result) ? result : null; } /** * Scans text for =(...) formula patterns using a paren-depth counter * to handle nested parentheses: =(a + (b * c)) is matched correctly. * Returns an array of { start, end, expression } for each formula found. * Fast path: returns [] immediately if text does not contain "=(". */ export function scanFormulas(text: string): FormulaMatch[] { if (!text.includes(FORMULA_PREFIX)) return []; const matches: FormulaMatch[] = []; let i = 0; while (i < text.length - 1) { // Look for "=(" prefix if (text[i] === "=" && text[i + 1] === "(") { const start = i; let depth = 1; let j = i + 2; while (j < text.length && depth > 0) { if (text[j] === "(") { depth++; } else if (text[j] === ")") { depth--; } j++; } if (depth === 0) { const expression = text.slice(start + 2, j - 1); matches.push({ start, end: j, expression }); i = j; } else { // Unbalanced parens — skip this match i = start + 1; } } else { i++; } } return matches; } /** * Evaluates a formula expression after substituting all content references. * Uses expr-eval for sandboxed arithmetic. * RFC-0729: Supports pipe syntax `arithmetic | formatter key=value` for post-evaluation formatting. * Returns { value, resolved, error? } with REF-06..10 error codes. */ export function resolveFormula( index: ContentRefIndex, expression: string, lang: string, defaultLang: string, sourceRef?: SourceRef, ): FormulaResolution { const pipeIndex = expression.indexOf("|"); const hasPipe = pipeIndex !== -1; const arithmeticExpr = hasPipe ? expression.slice(0, pipeIndex) : expression; const formatterSpec = hasPipe ? expression.slice(pipeIndex + 1) : ""; // Parsed early (RFC-1156): raw-input formatters must be identified BEFORE the // reference loop, because extractNumeric would mangle ISO strings ("P30D"→30). const specTokens = formatterSpec .trim() .split(/\s+/) .filter((t) => t.length > 0); const formatterName = specTokens[0] ?? ""; const formatterWantsRaw = formatterName !== "" && pipeFormatterRegistry.get(formatterName)?.input === "raw"; // RFC-0731: Expand this. references before REF_IN_FORMULA_PATTERN matching let expandedArithmeticExpr = arithmeticExpr; if (sourceRef && arithmeticExpr.includes(THIS_PREFIX)) { const thisPattern = new RegExp(THIS_IN_FORMULA_PATTERN.source, "g"); expandedArithmeticExpr = expandedArithmeticExpr.replace(thisPattern, (match) => { const fieldPath = match.slice(THIS_PREFIX.length); return `${sourceRef.collection}.${sourceRef.file}.${fieldPath}`; }); } else if (!sourceRef && arithmeticExpr.includes(THIS_PREFIX)) { return { value: "", resolved: false, error: `REF-12: this. reference used without sourceRef context`, }; } // Find all content references in the arithmetic expression const refs: string[] = []; const refPattern = new RegExp(REF_IN_FORMULA_PATTERN.source, "g"); let match: RegExpExecArray | null; while ((match = refPattern.exec(expandedArithmeticExpr)) !== null) { const candidate = match[0]; const collectionMatch = candidate.match(/^([a-z][a-z-]*)\./); if (!collectionMatch) continue; const collection = collectionMatch[1]; if (!index.entries[collection]) continue; refs.push(candidate); } // Resolve each reference and extract numeric value let substitutedExpression = expandedArithmeticExpr; let rawPipeValue: string | undefined; for (const ref of refs) { const result = resolveReference(index, ref, lang, defaultLang); if (!result.resolved) { return { value: "", resolved: false, error: `REF-06: Formula reference unresolved: ${ref}`, }; } // RFC-0723: If the expression is a single reference (no arithmetic, no pipe), // return the string value directly — enables =(ref) for string interpolation. // This must be checked BEFORE extractNumeric to avoid extracting numbers from // string values like "Fertig in 12 Werktagen" → 12. if (!hasPipe && refs.length === 1 && expandedArithmeticExpr.trim() === ref) { return { value: String(result.value), resolved: true, }; } // RFC-1156: single-ref + raw-input pipe → pass the resolved string through // untouched; ISO durations like "P30D" must reach the formatter intact. if (formatterWantsRaw && refs.length === 1 && expandedArithmeticExpr.trim() === ref) { rawPipeValue = String(result.value); continue; } const numeric = extractNumeric(result.value); if (numeric === null) { return { value: "", resolved: false, error: `REF-07: Formula operand not numeric: ${ref} resolved to "${String(result.value)}"`, }; } substitutedExpression = substitutedExpression.replaceAll(ref, String(numeric)); } // Evaluate the arithmetic expression — skipped when a raw-input formatter // captured the single reference above (RFC-1156). let evaluatedValue: number | string | undefined = rawPipeValue; if (rawPipeValue === undefined) { try { const expr = parser.parse(substitutedExpression); const evaluated = expr.evaluate(); if (typeof evaluated !== "number" || !Number.isFinite(evaluated)) { // Check for division by zero (expr-eval returns Infinity, not an error) if (typeof evaluated === "number" && !Number.isFinite(evaluated)) { return { value: "", resolved: false, error: `REF-09: Formula division by zero: ${expression}`, }; } return { value: "", resolved: false, error: `REF-08: Formula produced non-numeric result: ${expression}`, }; } // No pipe — return numeric string as before (RFC-0570, RFC-0723) if (!hasPipe) { return { value: String(evaluated), resolved: true, }; } evaluatedValue = evaluated; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("divide") && message.includes("zero")) { return { value: "", resolved: false, error: `REF-09: Formula division by zero: ${expression}`, }; } return { value: "", resolved: false, error: `REF-08: Formula syntax error: ${message}`, }; } } // RFC-0729/1156: Apply pipe formatter (numeric result or raw single-ref value). const formatter = getPipeFormatter(formatterName); if (!formatter) { return { value: "", resolved: false, error: `REF-10: Unknown pipe formatter: ${formatterName}`, }; } const params: Record = {}; for (const token of specTokens.slice(1)) { const eqIndex = token.indexOf("="); if (eqIndex > 0) { params[token.slice(0, eqIndex)] = token.slice(eqIndex + 1); } } const context: PipeFormatterContext = { lang, defaultLang }; try { return { value: formatter(evaluatedValue!, params, context), resolved: true, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { value: "", resolved: false, error: `REF-08: Formula pipe error: ${message}`, }; } } const RECURRENCE_SUFFIXES: Record> = { P1M: { de: "/ Monat", uk: "/ місяць" }, P1Y: { de: "/ Jahr", uk: "/ рік" }, }; export function formatRecurrence(recurrence: string | undefined, lang: string): string { if (!recurrence || recurrence.trim() === "") return ""; const suffixes = RECURRENCE_SUFFIXES[recurrence.trim()]; if (!suffixes) return ""; return suffixes[lang] ?? suffixes["de"] ?? ""; }