#!/usr/bin/env python3 """Rebuild the bundled TSL API index from the codegen markdown tree. The markdown tree is the source of truth. The TSV is a derived product; regenerate it whenever the leaf Markdown changes rather than editing it by hand. Columns (tab-separated, LF line endings, UTF-8): name scope module signature page anchor tags summary kind binding visibility owner qualified_name - name: signature text up to the first '(' - scope: first path segment under the codegen root (for example project) - module: second path segment for nested pages, else the flat file stem - signature: verbatim from the heading, backticks stripped - page: POSIX path relative to the codegen root - anchor: top-level declarations use the historic name slug; typed members slug the complete visible API title. Per-page duplicate slugs get -1/-2 suffixes in document order. - tags: space-separated keywords from `` - summary: first prose line under the entry heading - kind: function/class/method/property/field/constant/unit/variable - binding: instance/class/static/unit, or empty when not applicable - visibility: public/protected for class members; public for unit interface - owner: dot-separated containing API path, excluding the entry name - qualified_name: dot-separated stable API identity Usage (run from repo root; --skill-dir is required): SKILL=skills/tsl-api-reference python tools/tsl-codegen/scripts/build_index.py \ --skill-dir "$SKILL" # rewrite the TSV in place python tools/tsl-codegen/scripts/build_index.py \ --skill-dir "$SKILL" --check # verify against md tree, no write """ import argparse import sys from pathlib import Path import re SCRIPT_DIR = Path(__file__).resolve().parent if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) from api_markdown import DECLARATION_LINE_RE, iter_api_entries, slug RETURN_RE = re.compile(r"^返回[::]") DECLARATION_TYPE_RE = re.compile( r"^类型[::]\s*(function|class|unit)\s*$", re.IGNORECASE ) TAGS_RE = re.compile(r"^$") VISIBILITY_RE = re.compile( r"^可见性[::]\s*`?(public|protected|private)`?\s*$", re.IGNORECASE, ) HEADER = [ "name", "scope", "module", "signature", "page", "anchor", "tags", "summary", "kind", "binding", "visibility", "owner", "qualified_name", ] def extract_metadata(lines, heading_idx, end_idx=None): """Return tags and the first prose line under one API heading.""" tags = "" summary = "" summary_open = True for line in lines[heading_idx + 1:end_idx]: text = line.strip() if not text: continue if DECLARATION_LINE_RE.fullmatch(text): continue tag_match = TAGS_RE.match(text) if tag_match: tags = " ".join(tag_match.group(1).split()).replace("\t", " ") continue if summary: continue if ( text.startswith("|") or text.startswith("#") or RETURN_RE.match(text) or DECLARATION_TYPE_RE.match(text) ): summary_open = False continue if summary_open: summary = text.replace("\t", " ") return tags, summary def extract_visibility(lines, start, end): for line in lines[start + 1:end]: match = VISIBILITY_RE.match(line.strip()) if match: return match.group(1).casefold() return "" def next_anchor(base, seen): count = seen.get(base, 0) seen[base] = count + 1 return base if count == 0 else f"{base}-{count}" def parse_page(codegen_root, md): """Yield index rows for one Markdown page.""" page = md.relative_to(codegen_root).as_posix() scope, module = scope_module(page) seen = {} rows = [] lines = md.read_text(encoding="utf-8").splitlines() entries = list(iter_api_entries(lines)) root_kind = "" root_name = "" unit_class_owner = "" for entry in entries: heading = entry.heading if not heading.valid: continue if heading.level == 2: root_kind = heading.kind root_name = heading.name unit_class_owner = "" owner = "" anchor_base = slug(heading.name) elif root_kind == "class": owner = root_name anchor_base = slug(heading.visible_title) elif root_kind == "unit" and heading.level == 3: anchor_base = slug(heading.visible_title) owner = root_name unit_class_owner = ( f"{root_name}.{heading.name}" if heading.kind == "class" else "" ) elif root_kind == "unit" and heading.level == 4: owner = unit_class_owner anchor_base = slug(heading.visible_title) else: continue qualified_name = ( f"{owner}.{heading.name}" if owner else heading.name ) if root_kind == "unit" and heading.level == 3: visibility = "public" elif ( root_kind == "class" and heading.level == 3 ) or (root_kind == "unit" and heading.level == 4): visibility = extract_visibility(lines, entry.start, entry.end) else: visibility = "" tags, summary = extract_metadata(lines, entry.start, entry.end) rows.append( [ heading.name, scope, module, heading.signature, page, next_anchor(anchor_base, seen), tags, summary, heading.kind, heading.binding, visibility, owner, qualified_name, ] ) return rows def scope_module(page): parts = page.split("/") scope = parts[0] module = parts[1] if len(parts) >= 3 else Path(parts[-1]).stem return scope, module def build_rows(codegen_root): """Scan the whole codegen tree and return sorted rows.""" rows = [] for md in sorted(codegen_root.rglob("*.md")): rows.extend(parse_page(codegen_root, md)) rows.sort(key=lambda r: (r[0].lower(), r[4], r[3])) return rows def render_tsv(rows): lines = ["\t".join(HEADER)] lines.extend("\t".join(r) for r in rows) return "\n".join(lines) + "\n" def read_tsv(tsv_path): text = tsv_path.read_text(encoding="utf-8") return [line.split("\t") for line in text.splitlines() if line.strip()] def main(argv=None): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser( description=__doc__.splitlines()[0], add_help=False, allow_abbrev=False, ) parser.add_argument( "--help", action="help", help="show this help message and exit (no -h short option)", ) parser.add_argument( "--skill-dir", required=True, help="tsl-api-reference skill dir; codegen root defaults to " "/references/codegen and tsv to " "/data/function_index.tsv", ) parser.add_argument( "--root", help="explicit codegen root, overriding the one derived from --skill-dir", ) parser.add_argument( "--tsv", help="explicit output tsv, overriding the one derived from --skill-dir", ) parser.add_argument( "--check", action="store_true", help="verify the tsv matches the md tree; exit 1 if not (no write)", ) args = parser.parse_args(argv) skill_root = Path(args.skill_dir) codegen_root = ( Path(args.root) if args.root else skill_root / "references" / "codegen" ) if not codegen_root.is_dir(): print(f"ERROR: codegen root not found: {codegen_root}", file=sys.stderr) return 1 tsv_path = ( Path(args.tsv) if args.tsv else skill_root / "data" / "function_index.tsv" ) rows = build_rows(codegen_root) new_text = render_tsv(rows) if args.check: if not tsv_path.is_file(): print(f"MISMATCH: tsv does not exist: {tsv_path}", file=sys.stderr) return 1 current = tsv_path.read_text(encoding="utf-8") tsv_ok = current == new_text if tsv_ok: print(f"OK: {tsv_path} matches md tree ({len(rows)} rows)") return 0 if not tsv_ok: cur_rows = read_tsv(tsv_path)[1:] cur_keys = {tuple(r) for r in cur_rows} new_keys = {tuple(r) for r in rows} print( f"MISMATCH: tsv out of date " f"(tsv {len(cur_rows)} rows, md {len(rows)} rows; " f"+{len(new_keys - cur_keys)} -{len(cur_keys - new_keys)}). " f"Run without --check to rebuild.", file=sys.stderr, ) return 1 tsv_path.parent.mkdir(parents=True, exist_ok=True) tsv_path.write_text(new_text, encoding="utf-8", newline="\n") print(f"wrote {tsv_path}: {len(rows)} rows") return 0 if __name__ == "__main__": raise SystemExit(main())