"""STmate 산근 글 식 파서(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-B` 3 장 · eval 없음). 단가산출근거 시트 숨은 G 열 원문 한 줄 → 줄 나무(dict). 셈은 `M02_San_Eval.run_stmate`. 줄 꼴(`종류`): - 빈 · 글 — 셈 없음(`'글'` · 단위 조각 · `// 덧글` 뺀 뒤 빈 것) - 계 — `(=)`~`(=====)` 단 1~5 · `(-=)` 제외(위 합에서 뺌) · `(=):T01` 누계번지 - 대입 — `X = 식` 전 자리 · `X = 식 =` 소수 2 자리 반올림 · 한 줄 여러 대입(`,`) · 맨 식(끼움 `[A]` 만 먹음) - 참조 — `&L..&` · `&M..&` · `&S..&` · `&X..L/M/S(:L/M/S)&` · `&D..&` · `&B..&` 단가 × 계수 식 (특수 % 코드 L% · M% · T% 는 목록표 UNT 로 셀 때 가림) - 율 — `&P{A01|A0102..|T01|C_|S_}{L|M|S|}:{L|M|S|}& × n'%'` 범위 금액 × n % · `&R..&` × n - 공통 칸 — 숨김(`;;`) · 번지(`:=A01` · `:=T01`) · 빼기(`->` = 번지엔 쌓되 합에서 뺌) · 찍기(끝 `=` 또는 번지) · 식(계수 나무 · `("참조",)` = 단가 자리 1) · 오류(읽기 실패 까닭) """ from __future__ import annotations import re from typing import Any from M02_MasterTemplete import M02_San_Expr as ex REF = re.compile(r"&([A-Z]+)([^&]*)&") _TOK = re.compile(r"\s*(?:(\d+\.?\d*|\.\d+)|([A-Za-z][A-Za-z0-9_]*)|(@R)|(.))") _TOTAL = re.compile(r"^\((-?)(=+)\)(?::(T\d+))?") _NAME_EQ = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_]*\s*=(?!=)") _ASSIGN = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)\s*=(?!=)(.*)$") _ADDR = re.compile(r":=\s*([AT]\d+)\s*$") _RATE_TAIL = re.compile(r"^(\d*|_)([LMS]?)(?::([LMS]?))?$") _CODE_TAIL = re.compile(r"^([LMS]?)(?::([LMS]))?$") RATE_KINDS = ("PA", "PT", "PC", "PS", "RA", "RT", "RC", "RS") OPS = "+-*/^" def tokens(text: str) -> list[tuple]: """STmate 식 글 → 토큰(끼움 `[..]` 안은 다시 토큰) · 모르는 기호 = ExprError.""" out: list[tuple] = [] at = 0 while at < len(text): if text[at].isspace(): at += 1 continue if text[at] == "[": end = text.find("]", at) if end < 0: raise ex.ExprError("끼움 괄호 짝 없음") inner = text[at + 1 : end] if "=" in inner: name, body = inner.split("=", 1) out.append(("끼움", name.strip(), tokens(body))) else: out.append(("끼움", None, tokens(inner))) at = end + 1 continue m = _TOK.match(text, at) if not m or m.end() == at: break at = m.end() num, name, ref, other = m.groups() if num: out.append(("수", float(num))) elif name: out.append(("이름", name)) elif ref: out.append(("참조",)) elif other and other.strip(): if other in OPS: out.append(("연산", other)) elif other in "(),": out.append((other,)) else: raise ex.ExprError(f"모르는 기호 {other!r}") return out def expr(text: str) -> tuple: """식 글 → 나무 · 실패 = `("오류", 까닭)`(셀 때 터짐 — 측정 도구와 같은 차례).""" try: return ex.parse(tokens(text)) except ex.ExprError as error: return ("오류", str(error)) def split_top(text: str, sep: str = ",") -> list[str]: """괄호 밖 `sep` 로 나눔.""" out, depth, cur = [], 0, "" for ch in text: if ch in "([": depth += 1 elif ch in ")]": depth -= 1 if ch == sep and depth == 0: out.append(cur) cur = "" else: cur += ch out.append(cur) return out def _clean(g: str) -> tuple[str, bool, str | None]: """원문 → (셈 글, 숨김, 덧글) — `//` 뒤 덧글 · `;;` 숨은 줄 · `'글'` 지움.""" raw, _, note = g.partition("//") hidden = raw.lstrip().startswith(";;") s = raw.lstrip()[2:] if hidden else raw s = re.sub(r"'[^']*'", " ", s).replace("'", " ") return s.strip(), hidden, (note.strip() or None) if _ else None def _ref_line(st: str, m: re.Match) -> dict[str, Any]: kind, tail = m.group(1), m.group(2) pre = _NAME_EQ.sub(" ", st[: m.start()]) body = pre + " @R " + st[m.end() :] out: dict[str, Any] = {"빼기": "->" in body} body = body.replace("->", " ") addr = _ADDR.search(body) out["번지"] = addr.group(1) if addr else None if addr: body = body[: addr.start()] out["찍기"] = body.rstrip().endswith("=") or bool(addr) out["식"] = expr(body.rstrip().rstrip("=")) if kind in RATE_KINDS: mt = _RATE_TAIL.match(tail) if not mt: return {**out, "종류": "율", "오류": f"율 꼬리 {tail}"} num = mt.group(1) area = kind[1] if area == "A": spots = ["A" + num[i : i + 2] for i in range(0, len(num), 2)] elif area == "T": spots = ["T" + num] else: spots = [] rate = {"곱": kind[0] == "R", "범위": area, "번지": spots} rate |= {"비목": mt.group(2) or None, "옮김": mt.group(3) or None} return {**out, "종류": "율", "율": rate} digits = re.match(r"^\d*", tail).group(0) mc = _CODE_TAIL.match(tail[len(digits) :]) if not mc: return {**out, "종류": "참조", "오류": f"참조 꼬리 {tail[len(digits) :]}"} src = {"코드": kind + digits, "비목": mc.group(1) or None, "옮김": mc.group(2)} return {**out, "종류": "참조", "자원": src} def _assign_line(st: str) -> dict[str, Any]: parts: list[dict[str, Any]] = [] for part in split_top(st): p = part.strip() if not p: continue ma = _ASSIGN.match(p) if ma: body = ma.group(2).strip() show = body.endswith("=") body = body.rstrip("=").strip() if body: parts.append({"이름": ma.group(1).lower(), "식": expr(body), "찍기": show}) else: node = expr(p.rstrip("=").strip()) if node[0] not in ("오류", "이름"): # 맨 낱말 · 못 읽는 글 = 셈 없음 parts.append({"이름": None, "식": node, "찍기": False}) if not parts: return {"종류": "글"} return {"종류": "대입", "대입": parts} def parse_line(g: str | None) -> dict[str, Any]: """G 열 원문 한 줄 → 줄 나무.""" st, hidden, note = _clean(g or "") head = {"숨김": hidden, "덧글": note} tot = _TOTAL.match(st.replace(" ", "")) if tot: return {**head, "종류": "계", "단": len(tot.group(2)), "제외": tot.group(1) == "-"} | { "누계번지": tot.group(3) } if not st: return {**head, "종류": "빈"} refs = list(REF.finditer(st)) if len(refs) > 1: return {**head, "종류": "참조", "오류": "참조 둘 이상", "식": ("오류", "참조 둘 이상")} if refs: return {**head, **_ref_line(st, refs[0])} return {**head, **_assign_line(st)} def parse_block(lines: list[dict[str, Any]]) -> list[dict[str, Any]]: """[{행, G}] → 줄 나무 목록(행 · 원문 붙임).""" return [ {"행": ln.get("행"), "글": ln.get("G") or "", **parse_line(ln.get("G"))} for ln in lines ]