You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

214 lines
8.8 KiB

2 days ago
# -*- 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<CustomerBatchFailItem>取类声明的形式参数与实参
逐位绑定替换到字段类型上List<F> List<CustomerBatchFailItem>
"""
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()