Files
Aislo/A00_Common/spreadsheet/spreadsheet_parser.ts
T
eomsangdonandClaude Opus 5.5 736dac4eb5 feat(spreadsheet): 산출근거 스프레드시트 A 엔진 핵심 — 주소 · 파서 · 풀이 · 의존 재계산 · 참조 옮김 · 명령 · 되돌리기
- address · parser — A1 ↔ 행열 · 낱말(편집기 색칠용 자리) · 엑셀 우선순위 나무(-2^2=4 · % · & · 비교) · 나무 → 글
- eval — 분수 풀이 · 오류 값 전파(왼쪽 먼저) · 형 바꾸기(빈 칸 · 수 글 · 참거짓) · 일반 형식 글 15 자리 · 무리수 15 자리
- graph — 의존 그래프 · 고친 칸에 딸린 칸만 재계산 · 순환 = 고리 칸마다 #CYCLE! + 까닭
- refshift — 행열 넣기 · 지우기 · 잘라 붙이기 · 시트 이름 · 시트 지우기 참조 옮김(지운 칸 #REF!) · 복사 상대 옮김 · 구글 R1C1 → A1
- commands · history · recalc — 명령마다 되돌림 짝 · 병합 규칙 · 서식 한 벌 표 · 묶음 원자 · Node 한 벌 풀이
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py — 엑셀 규칙표 9 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:14:31 +09:00

314 lines
12 KiB
TypeScript

/* =============================================================================
* 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<string, number> = { "&": 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(",")})`;
}
}