/* ============================================================================= * spreadsheet_parser.ts (주인 A) * 수식 글 → 낱말 · 나무 · 나무 → 글. `eval` 금지 — 직접 짠 파서만. * 우선순위는 엑셀과 같음: 범위 `:` > 부호 `-` `+` > `%` > `^` > `* /` > `+ -` > `&` > 비교 * (부호가 `^` 보다 먼저 — `-2^2 = 4`). 같은 단은 왼쪽부터(`^` 도 왼쪽부터 · 엑셀과 같음). * 글 `"…"` 안 `""` = 따옴표 하나 · 시트 `'이름'!A1` · `이름!A1` · 오류 값 글자 · TRUE/FALSE. * 참조 낱말 풀기(`parseRef` · `renderRef`)는 참조 옮김(`spreadsheet_refshift.ts`)이 같이 씀. * ========================================================================== */ import { parseDecimal, ZERO } from "@ui/sheet/ui_template_sheet_frac"; import { colIndex, colName, quoteSheet, rowIndex } from "./spreadsheet_address"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { ErrorCode, FormulaNode, ParseResult, RefCell, Token } from "./spreadsheet_types"; // ── 참조 낱말 ──────────────────────────────────────────────────────────────── const IDC = "[\\p{L}\\p{N}_.]"; const END = `(?!${IDC}|\\()`; const CELL = "(\\$?)([A-Za-z]{1,3})(\\$?)(\\d+)"; const RE_PREFIX = /^(?:'((?:[^']|'')+)'|([\p{L}\p{N}_.]+))!/u; const RE_AREA = new RegExp(`^${CELL}(?::${CELL})?${END}`, "u"); const RE_COLS = new RegExp(`^(\\$?)([A-Za-z]{1,3}):(\\$?)([A-Za-z]{1,3})${END}`, "u"); const RE_ROWS = new RegExp(`^(\\$?)(\\d+):(\\$?)(\\d+)${END}`, "u"); const RE_ERROR = /^#(NULL!|DIV\/0!|VALUE!|REF!|NAME\?|NUM!|N\/A|CYCLE!)/i; const RE_IDENT = /^[\p{L}_\\][\p{L}\p{N}_.]*/u; /** 참조 낱말 하나 — `kind` cell = 칸 · area = `A1:B2` · cols = `A:C` · rows = `3:5`. */ export interface RefParts { /** 적힌 시트 머리 글 그대로(`'돌-골막이'!`) · 없으면 "" */ prefix: string; sheet: string | null; kind: "cell" | "area" | "cols" | "rows"; a: RefCell; b: RefCell; } /** 참조 글 → 조각 · 참조가 아니면 null(주소가 격자 밖이어도 null → 이름) */ export function parseRef(text: string): RefParts | null { const pm = RE_PREFIX.exec(text); const prefix = pm ? pm[0] : ""; const sheet = pm ? (pm[1] !== undefined ? pm[1].replace(/''/g, "'") : pm[2]) : null; const body = text.slice(prefix.length); const pt = (sr: string, col: number, sc: string, row: number): RefCell => ({ r: row, c: col, absR: sc === "$", absC: sr === "$", }); let m = RE_AREA.exec(body); if (m && m[0].length === body.length) { const a = pt(m[1], colIndex(m[2]), m[3], rowIndex(m[4])); const b = m[5] === undefined ? { ...a } : pt(m[5], colIndex(m[6]), m[7], rowIndex(m[8])); if (a.r < 0 || a.c < 0 || b.r < 0 || b.c < 0) return null; return { prefix, sheet, kind: m[5] === undefined ? "cell" : "area", a, b }; } m = RE_COLS.exec(body); if (m && m[0].length === body.length) { const c0 = colIndex(m[2]); const c1 = colIndex(m[4]); if (c0 < 0 || c1 < 0) return null; return { prefix, sheet, kind: "cols", a: pt(m[1], c0, "", 0), b: pt(m[3], c1, "", MAX_ROWS - 1), }; } m = RE_ROWS.exec(body); if (m && m[0].length === body.length) { const r0 = rowIndex(m[2]); const r1 = rowIndex(m[4]); if (r0 < 0 || r1 < 0) return null; return { prefix, sheet, kind: "rows", a: pt("", 0, m[1], r0), b: pt("", MAX_COLS - 1, m[3], r1), }; } return null; } const cellText = (p: RefCell): string => `${p.absC ? "$" : ""}${colName(p.c)}${p.absR ? "$" : ""}${p.r + 1}`; /** 조각 → 참조 글(시트 머리는 `prefix` 그대로) */ export function renderRef(p: RefParts): string { if (p.kind === "cols") return `${p.prefix}${p.a.absC ? "$" : ""}${colName(p.a.c)}:${p.b.absC ? "$" : ""}${colName(p.b.c)}`; if (p.kind === "rows") return `${p.prefix}${p.a.absR ? "$" : ""}${p.a.r + 1}:${p.b.absR ? "$" : ""}${p.b.r + 1}`; return p.prefix + cellText(p.a) + (p.kind === "area" ? `:${cellText(p.b)}` : ""); } // ── 낱말 나누기 ─────────────────────────────────────────────────────────────── /** 앞 `=` 뺀 식 글 → 낱말(빈칸 낱말 포함 · 끝까지 · 못 읽는 글은 `unknown`) — 편집기 색칠 · 가리키기용. * 이름(정의 안 된 글자 묶음)도 `unknown` — 파서가 이름 나무로 읽음. */ export function tokenize(formula: string): Token[] { const out: Token[] = []; let i = 0; let ref: string | null; const push = (kind: Token["kind"], len: number) => { out.push({ kind, text: formula.slice(i, i + len), start: i, end: i + len }); i += len; }; while (i < formula.length) { const rest = formula.slice(i); const ch = rest[0]; let m: RegExpExecArray | null; if ((m = /^\s+/.exec(rest))) push("space", m[0].length); else if (ch === '"') { const s = /^"(?:[^"]|"")*"?/.exec(rest)!; push("string", s[0].length); } else if ((m = /^([\p{L}_][\p{L}\p{N}_.]*)\(/u.exec(rest)) && !/^(TRUE|FALSE)$/i.test(m[1])) push("func", m[1].length); else if ((ref = refAt(rest))) push(/#REF!$/i.test(ref) ? "error" : "ref", ref.length); else if ((m = /^(TRUE|FALSE)(?![\p{L}\p{N}_.(])/iu.exec(rest))) push("bool", m[0].length); else if ((m = RE_ERROR.exec(rest))) push("error", m[0].length); else if ((m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(rest))) push("number", m[0].length); else if ((m = RE_IDENT.exec(rest))) push("unknown", m[0].length); else if ((m = /^(<>|<=|>=|[-+*/^&=<>%:])/.exec(rest))) push("op", m[0].length); else if (ch === "(" || ch === ")") push("paren", 1); else if (ch === ",") push("comma", 1); else push("unknown", 1); } return out; } /** 머리 이 자리에서 시작하는 참조(또는 `시트!#REF!`) 글 */ function refAt(rest: string): string | null { const head = RE_PREFIX.exec(rest)?.[0] ?? ""; const body = rest.slice(head.length); if (head && /^#REF!/i.test(body)) return head + body.slice(0, 5); for (const re of [RE_AREA, RE_COLS, RE_ROWS]) { const m = re.exec(body); if (m && parseRef(head + m[0])) return head + m[0]; } return null; } // ── 나무 ──────────────────────────────────────────────────────────────────── class Fail { constructor( readonly message: string, readonly at: number, ) {} } const COMPARE = ["=", "<>", "<", "<=", ">", ">="]; /** 앞 `=` 뺀 식 글 → 나무. 앞에 `=` 가 붙어 와도 됨. 함수 이름은 대문자로. */ export function parseFormula(formula: string): ParseResult { const text = formula.startsWith("=") ? formula.slice(1) : formula; const toks = tokenize(text).filter((t) => t.kind !== "space"); let p = 0; const peek = (): Token | undefined => toks[p]; const isOp = (...ops: string[]) => { const t = toks[p]; return !!t && t.kind === "op" && ops.includes(t.text); }; const where = () => (toks[p] ? toks[p].start : text.length); const binary = (ops: string[], next: () => FormulaNode) => (): FormulaNode => { let left = next(); while (isOp(...ops)) { const op = toks[p++].text as "+"; left = { type: "binary", op, left, right: next() }; } return left; }; const primary = (): FormulaNode => { const t = peek(); if (!t) throw new Fail("식이 끝남", text.length); p++; switch (t.kind) { case "number": return { type: "number", value: parseDecimal(t.text), text: t.text }; case "string": if (t.text.length < 2 || !t.text.endsWith('"')) throw new Fail("따옴표가 안 닫힘", t.start); return { type: "string", value: t.text.slice(1, -1).replace(/""/g, '"') }; case "bool": return { type: "bool", value: t.text.toUpperCase() === "TRUE" }; case "error": return { type: "error", code: t.text.slice(t.text.lastIndexOf("#")).toUpperCase() as ErrorCode, }; case "ref": { const r = parseRef(t.text)!; return r.kind === "cell" ? { type: "cell", sheet: r.sheet, ref: r.a } : { type: "range", sheet: r.sheet, from: r.a, to: r.b }; } case "func": { const name = t.text.toUpperCase().replace(/^_XLFN\./, ""); p++; // `(` const args: FormulaNode[] = []; if (peek()?.text === ")") { p++; return { type: "call", name, args }; } for (;;) { const k = peek(); // 빈 인자(`IF(A1,,1)`) = 0 · 글은 비움 if (k && (k.kind === "comma" || k.text === ")")) args.push({ type: "number", value: ZERO, text: "" }); else args.push(compare()); const s = toks[p++]; if (s?.kind === "comma") continue; if (s?.text === ")") return { type: "call", name, args }; throw new Fail("함수 괄호가 안 닫힘", s ? s.start : text.length); } } case "paren": { if (t.text !== "(") break; const inner = compare(); if (peek()?.text !== ")") throw new Fail("괄호가 안 닫힘", where()); p++; return inner; } case "unknown": if (RE_IDENT.exec(t.text)?.[0] === t.text) return { type: "name", name: t.text }; } throw new Fail(`읽지 못한 글: ${t.text}`, t.start); }; const unary = (): FormulaNode => { if (isOp("-", "+")) { const op = toks[p++].text as "-"; return { type: "unary", op, arg: unary() }; } return primary(); }; const percent = (): FormulaNode => { let node = unary(); while (isOp("%")) { p++; node = { type: "unary", op: "%", arg: node }; } return node; }; const power = binary(["^"], percent); const mul = binary(["*", "/"], power); const add = binary(["+", "-"], mul); const concat = binary(["&"], add); const compare = binary(COMPARE, concat); try { if (!toks.length) throw new Fail("빈 식", 0); const node = compare(); if (p < toks.length) throw new Fail(`남은 글: ${toks[p].text}`, toks[p].start); return { ok: true, node }; } catch (e) { if (e instanceof Fail) return { ok: false, message: e.message, at: e.at }; throw e; } } // ── 나무 → 글 ───────────────────────────────────────────────────────────────── const PREC: Record = { "&": 2, "+": 3, "-": 3, "*": 4, "/": 4, "^": 5 }; for (const op of COMPARE) PREC[op] = 1; function prec(node: FormulaNode): number { if (node.type === "binary") return PREC[node.op]; if (node.type === "unary") return node.op === "%" ? 6 : 7; return 8; } const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`); /** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만 */ export function formulaToText(node: FormulaNode): string { const wrap = (child: FormulaNode, need: boolean) => need ? `(${formulaToText(child)})` : formulaToText(child); switch (node.type) { case "number": return node.text; case "string": return `"${node.value.replace(/"/g, '""')}"`; case "bool": return node.value ? "TRUE" : "FALSE"; case "error": return node.code; case "name": return node.name; case "cell": return sheetHead(node.sheet) + cellText(node.ref); case "range": { const { from, to } = node; const kind = from.r === 0 && to.r === MAX_ROWS - 1 ? "cols" : from.c === 0 && to.c === MAX_COLS - 1 ? "rows" : "area"; return renderRef({ prefix: sheetHead(node.sheet), sheet: node.sheet, kind, a: from, b: to }); } case "unary": return node.op === "%" ? `${wrap(node.arg, prec(node.arg) < 6)}%` : `${node.op}${wrap(node.arg, prec(node.arg) < 7)}`; case "binary": { const p = PREC[node.op]; return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`; } case "call": return `${node.name}(${node.args.map(formulaToText).join(",")})`; } }