/* ============================================================================= * ui_template_sheet_formula.ts * 표 식 읽기 · 풀기 — 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 파서를 되살려 표 참조를 더함. * * 식 말: 사칙 · 괄호 · 비교(= <> < <= > >=) · 함수 SUM · INT · ROUND · ROUNDUP · ROUNDDOWN · * MIN · MAX · IF · 참조 `[열id]`(같은 줄) · `[열id@줄id]`(한 칸) · `[$이름]`(문서 변수). * `SUM([열id])` = 그 열의 본문 줄 전부. 빈 칸은 0(엑셀과 같음). * ⚠ `eval` 을 쓰지 않음 — 식은 사용자 글이라 직접 짠 파서로만 읽음. * 우선순위는 엑셀과 같음 — 부호 > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터. * ========================================================================== */ import { add, cmp, div, type Frac, frac, FormulaError, type IntMode, mul, parseDecimal, roundAt, sub, toInteger, ZERO, } from "./ui_template_sheet_frac"; type Token = | { kind: "num"; text: string } | { kind: "str"; text: string } | { kind: "id"; text: string } | { kind: "ref"; text: string } | { kind: "op"; text: string }; export type Node = | { type: "num"; value: Frac } | { type: "str"; value: string } | { type: "ref"; col: string; row: string | null } | { type: "var"; name: string } | { type: "unary"; op: string; arg: Node } | { type: "binary"; op: string; left: Node; right: Node } | { type: "call"; name: string; args: Node[] }; export type Value = Frac | string | boolean; /** 풀 때 참조를 대 주는 쪽 — 표 풀이(`_recalc`)가 채움. */ export interface Scope { cell(col: string, row: string | null): Value; column(col: string): Frac[]; variable(name: string): Value; } const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "(", ")", ",", "=", "<", ">"]; const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]); const ROUNDERS: Record = { ROUND: "round", ROUNDUP: "away", ROUNDDOWN: "trunc" }; 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; } const number = /^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(source.slice(i)); if (number) { tokens.push({ kind: "num", text: number[0] }); i += number[0].length; continue; } if (ch === "[") { const end = source.indexOf("]", i + 1); if (end < 0) throw new FormulaError("「[」가 닫히지 않음"); tokens.push({ kind: "ref", text: source.slice(i + 1, end).trim() }); i = end + 1; continue; } if (ch === "'" || ch === '"') { const end = source.indexOf(ch, i + 1); if (end < 0) throw new FormulaError("따옴표가 닫히지 않음"); tokens.push({ kind: "str", text: source.slice(i + 1, end) }); i = end + 1; continue; } const identifier = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i)); if (identifier) { tokens.push({ kind: "id", text: identifier[0].toUpperCase() }); i += identifier[0].length; continue; } const op = OPERATORS.find((candidate) => source.startsWith(candidate, i)); if (!op) throw new FormulaError(`읽을 수 없는 글자: ${ch}`); tokens.push({ kind: "op", text: op }); i += op.length; } return tokens; } function refNode(text: string): Node { if (!text) throw new FormulaError("빈 참조 []"); if (text.startsWith("$")) return { type: "var", name: text.slice(1).trim() }; const at = text.indexOf("@"); if (at < 0) return { type: "ref", col: text, row: null }; return { type: "ref", col: text.slice(0, at).trim(), row: text.slice(at + 1).trim() }; } /** 앞에 `=` 가 붙어 있어도 됨(엑셀 버릇). */ export function parseFormula(source: string): Node { const tokens = tokenize(source.trim().replace(/^=/, "")); let at = 0; const peek = (): Token | undefined => tokens[at]; const isOp = (text: string): boolean => peek()?.kind === "op" && peek()?.text === text; const expect = (text: string): void => { if (!isOp(text)) throw new FormulaError(`「${text}」가 있어야 함`); at += 1; }; const comparison = (): Node => { let left = additive(); while (peek()?.kind === "op" && COMPARISONS.has(peek()!.text)) { const op = tokens[at++].text; left = { type: "binary", op, left, right: additive() }; } return left; }; const additive = (): Node => { let left = term(); while (isOp("+") || isOp("-")) { const op = tokens[at++].text; left = { type: "binary", op, left, right: term() }; } return left; }; const term = (): Node => { let left = unary(); while (isOp("*") || isOp("/")) { const op = tokens[at++].text; left = { type: "binary", op, left, right: unary() }; } return left; }; const unary = (): Node => { if (isOp("-") || isOp("+")) { const op = tokens[at++].text; return { type: "unary", op, arg: unary() }; } return primary(); }; const primary = (): Node => { const token = peek(); if (!token) throw new FormulaError("식이 중간에 끝남"); at += 1; if (token.kind === "num") return { type: "num", value: parseDecimal(token.text) }; if (token.kind === "str") return { type: "str", value: token.text }; if (token.kind === "ref") return refNode(token.text); if (token.kind === "id") { if (!isOp("(")) throw new FormulaError(`모르는 이름: ${token.text} — 칸은 [열id] 로`); at += 1; const args: Node[] = []; if (!isOp(")")) { args.push(comparison()); while (isOp(",")) { at += 1; args.push(comparison()); } } expect(")"); return { type: "call", name: token.text, args }; } if (token.text === "(") { const inner = comparison(); expect(")"); return inner; } throw new FormulaError(`여기에 올 수 없음: ${token.text}`); }; if (!tokens.length) throw new FormulaError("빈 식"); const tree = comparison(); if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`); return tree; } /** 식이 가리키는 참조 — 순환 · 없는 열 검사용. */ export function* refsOf(node: Node): Generator { if (node.type === "ref" || node.type === "var") yield node; else if (node.type === "unary") yield* refsOf(node.arg); else if (node.type === "binary") { yield* refsOf(node.left); yield* refsOf(node.right); } else if (node.type === "call") for (const arg of node.args) yield* refsOf(arg); } const isFrac = (v: Value): v is Frac => typeof v === "object"; export function asFrac(value: Value): Frac { if (isFrac(value)) return value; if (typeof value === "boolean") return frac(value ? 1n : 0n); throw new FormulaError(`수 자리에 글이 옴: ${value}`); } function truthy(value: Value): boolean { if (typeof value === "boolean") return value; if (isFrac(value)) return value.n !== 0n; throw new FormulaError(`조건 자리에 글이 옴: ${value}`); } function compareValues(a: Value, b: Value): number { if (typeof a === "string" || typeof b === "string") { if (typeof a !== "string" || typeof b !== "string") { throw new FormulaError("글과 수를 견줄 수 없음"); } return a === b ? 0 : a < b ? -1 : 1; } return cmp(asFrac(a), asFrac(b)); } function digitsOf(node: Node | undefined, scope: Scope): number { if (!node) return 0; const value = asFrac(evaluate(node, scope)); if (value.d !== 1n) throw new FormulaError("자리 수는 정수"); return Number(value.n); } export function evaluate(node: Node, scope: Scope): Value { switch (node.type) { case "num": case "str": return node.value; case "ref": return scope.cell(node.col, node.row); case "var": return scope.variable(node.name); case "unary": { const value = asFrac(evaluate(node.arg, scope)); return node.op === "-" ? sub(ZERO, value) : value; } case "binary": { const left = evaluate(node.left, scope); const right = evaluate(node.right, scope); if (COMPARISONS.has(node.op)) { const order = compareValues(left, right); return { "=": order === 0, "<>": order !== 0, "<": order < 0, "<=": order <= 0, ">": order > 0, ">=": order >= 0, }[node.op]!; } const a = asFrac(left); const b = asFrac(right); if (node.op === "+") return add(a, b); if (node.op === "-") return sub(a, b); if (node.op === "*") return mul(a, b); return div(a, b); } case "call": { const { name, args } = node; if (name === "IF") { if (args.length !== 3) throw new FormulaError("IF 는 인자가 셋(조건, 참, 거짓)"); // 고른 갈래만 풂 — 안 고른 갈래의 오류가 칸을 막지 않게 return evaluate(truthy(evaluate(args[0], scope)) ? args[1] : args[2], scope); } if (name === "SUM") { let total = ZERO; for (const arg of args) { const values = arg.type === "ref" && arg.row === null ? scope.column(arg.col) : [asFrac(evaluate(arg, scope))]; for (const value of values) total = add(total, value); } return total; } if (name === "INT") { if (args.length !== 1) throw new FormulaError("INT 는 인자가 하나"); return frac(toInteger(asFrac(evaluate(args[0], scope)), "floor")); } if (name in ROUNDERS) { if (args.length < 1 || args.length > 2) throw new FormulaError(`${name} 는 (값, 자리)`); return roundAt(asFrac(evaluate(args[0], scope)), digitsOf(args[1], scope), ROUNDERS[name]); } if (name === "MIN" || name === "MAX") { const values = args.map((arg) => asFrac(evaluate(arg, scope))); if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`); return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best)); } throw new FormulaError(`모르는 함수: ${name}`); } } }