# -*- coding: utf-8 -*- """票 04 · 嵌套 DTO 字段表展开 v2:按类声明切片解析,修静态嵌套类撞名/外层字段混入。 对「响应 data 结构」表里 list/object 形且类型可解析的字段,追加 `字段[].子字段` 点路径行, 深度 = 顶一层 + 嵌套一层。类解析: - 全仓扫描每个 class/record/interface/enum 声明,花括号配对取类体; - 同名多候选(如 ImportPreviewDTO.Row 与 ContactImportPreviewDTO.Row)→ 声明方同文件优先; - 解析类体前剥除其内部嵌套类片段,避免外层/内层字段合并; - extends 父类同样按此解析(同文件优先)。 默认 dry-run;--apply 才写回(只动「响应 data 结构」表,响应示例块原样保留,示例真值化归票 05)。 """ from __future__ import annotations import argparse import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "a4-doc-fields")) import complete_a4_doc_fields as R # noqa: E402 NEST_PARENT_HINT = ",元素结构如下" DECL_RE = re.compile(r'\b(class|record|interface|enum)\s+(\w+)') def brace_span(text: str, start: int) -> tuple[int, int]: """text[start:] 应以 '{' 开头(跳过空白后),返回 (open_idx, close_idx)。""" i = text.index('{', start) depth = 0 for j in range(i, len(text)): if text[j] == '{': depth += 1 elif text[j] == '}': depth -= 1 if depth == 0: return i, j raise ValueError("花括号不配对") def type_bodies(sources: list[Path]) -> dict[str, list[tuple[Path, str]]]: """name → [(file, 含声明的类体切片)]。逐个声明独立切片(不跳过外层区间),嵌套类不漏。""" out: dict[str, list[tuple[Path, str]]] = {} for src in sources: try: text = src.read_text(encoding="utf-8-sig") except Exception: continue pos = 0 while True: m = DECL_RE.search(text, pos) if not m: break pos = m.end() try: o, c = brace_span(text, m.start()) except ValueError: continue out.setdefault(m.group(2), []).append((src, text[m.start():c + 1])) return out def strip_nested(text: str) -> str: """剥除类体切片里第一个类声明之外的其他类片段(避免嵌套类字段混入外层解析)。""" first = DECL_RE.search(text) pos = first.end() while True: m = DECL_RE.search(text, pos) if not m: break try: o, c = brace_span(text, m.start()) except ValueError: break text = text[:m.start()] + text[c + 1:] pos = m.start() return text def fields_of(name: str, bodies: dict[str, list[tuple[Path, str]]], ctx: Path | None = None, seen: set[str] | None = None, bindings: dict[str, str] | None = None) -> list[tuple[str, str, str]]: """解析名为 name 的类字段(含 extends 链与泛型绑定);ctx=声明方文件,同文件候选优先。 name 可带泛型实参(如 BatchResult):取类声明的形式参数与实参 逐位绑定,替换到字段类型上(List → List)。 """ seen = seen or set() bindings = dict(bindings or {}) outer, generic_args = R.split_generic(name) key = outer.split(".")[-1] cands = bodies.get(key) if not cands or key in seen: return [] pick = cands[0] for src, slice_ in cands: if src == ctx: pick = (src, slice_) break src, slice_ = pick seen.add(key) text = strip_nested(slice_) # 类声明的形式参数 → 与调用方实参逐位绑定 pm = re.search(r'\b(?:class|record|interface)\s+\w+\s*<([^>]*)>', text) if pm and generic_args: params = [p.strip() for p in R.split_top_commas(pm.group(1))] actuals = [a.strip() for a in R.split_top_commas(generic_args)] for p, a in zip(params, actuals): if p and a: bindings[p] = a rows = R.parse_record(text, key) if rows is None: rows = R.parse_class_fields(text) def subst(t: str) -> str: for p, a in bindings.items(): t = re.sub(r'\b' + re.escape(p) + r'\b', a, t) return t rows = [(n, subst(t), d) for n, t, d in rows] pm2 = re.search(r'\bclass\s+\w+\s+extends\s+([\w.<>]+)', text) if pm2: rows = fields_of(pm2.group(1).split(".")[-1], bodies, src, seen, bindings) + rows return rows def nested_pass(rows: list[tuple[str, str, str]], item_name: str, bodies: dict[str, list[tuple[Path, str]]], item_src: Path | None, top_prefix: str = "") -> tuple[list[tuple[str, str, str]], list[str]]: """顶一层字段(带 top_prefix,page 形态为 content[].)+ 嵌套一层点路径子行。""" out: list[tuple[str, str, str]] = [] missing: list[str] = [] top = fields_of(item_name, bodies, item_src) for name, ftype, desc in top: parent_desc = desc shape, inner = R.type_shape(ftype) expandable = shape in ("list", "object") and inner sub: list[tuple[str, str, str]] = [] if expandable: sub = fields_of(inner, bodies, item_src) if sub and parent_desc and not parent_desc.endswith(NEST_PARENT_HINT): parent_desc += NEST_PARENT_HINT out.append((top_prefix + name, ftype, parent_desc)) if sub: base = f"{top_prefix}{name}" prefix = f"{base}[]." if shape == "list" else f"{base}." for sname, stype, sdesc in sub: if not sdesc: missing.append(f"{inner.split('.')[-1]}.{sname}") out.append((prefix + sname, stype, sdesc)) return out, missing def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true") parser.add_argument("--from-list", help="文件清单(每行一个 .bru 相对路径)") parser.add_argument("files", nargs="*", help="限定 .bru 文件相对路径(默认扫全仓嵌套引用)") args = parser.parse_args() sources = R.java_sources() endpoints = R.endpoint_index(sources) bodies = type_bodies(sources) if args.from_list: targets = [R.DOCS / l.strip() for l in open(args.from_list, encoding="utf-8") if l.strip().endswith(".bru")] elif args.files: targets = [R.DOCS / f for f in args.files] else: targets = [f for f in sorted(R.DOCS.rglob("*.bru")) if f.name != "folder.bru" and re.search(r"^\s*\| [a-zA-Z\[\].]+ \| (?:List<)?[A-Z][\w.]*", f.read_text(encoding="utf-8-sig"), re.M)] changed = 0 for file in targets: text = file.read_text(encoding="utf-8-sig") key = R.endpoint_key(text) if not key or key not in endpoints: print(f"SKIP 无对账钥匙或未映射: {file.relative_to(R.DOCS)}") continue ep = endpoints[key] shape, item = R.type_shape(ep.return_type) if shape in ("void", "binary", "scalar") or not item: continue it = item.split(".")[-1] rows, missing = nested_pass([], it, bodies, ep.source, top_prefix="content[]." if shape == "page" else "") if not rows: print(f"SKIP 顶层类型未解析出字段(防清空表,不写回): {file.relative_to(R.DOCS)} item={item}") continue if shape == "page": rows = [("content[]", "array", "分页内容,元素结构如下")] + rows + [ ("total", "Long", "总条数"), ("size", "Long", "每页条数"), ("current", "Long", "当前页"), ("pages", "Long", "总页数"), ("empty", "Boolean", "是否为空")] old_rows, _, _ = R.data_structure(ep.return_type, {}) if rows == old_rows: continue rel = file.relative_to(R.DOCS).as_posix() old_names = [r[0] for r in old_rows] added = [r for r in rows if r[0] not in old_names] print(f"{'WRITE' if args.apply else 'DRY '} {rel} +{len(added)} 行 缺@Schema={missing or '无'}") for n, t, d in added: print(f" | {n} | {t} | {d} |") if args.apply: table_only = "\n".join(R.section(rows, None, keep_sample_hint=False).split("\n\n ## 响应示例", 1)[0].splitlines()) pattern = re.compile(r" ## 响应 data 结构[\s\S]*?(?=\n ## 响应示例)") new_text, count = pattern.subn(table_only, text, count=1) if count != 1: raise SystemExit(f"未找到唯一响应区块: {rel}") file.write_text(new_text, encoding="utf-8", newline="") changed += 1 print(f"changed={changed} / targets={len(targets)}") if __name__ == "__main__": main()