"""Generate _modules_generated.cpp from app.md uses_modules + modules/*/module.md. Stand-alone — no dependencies beyond PyYAML. Invoked from CMake at configure time. Reads YAML frontmatter directly (no registry.db dependency, no Go binary). """ from __future__ import annotations import argparse import sys from pathlib import Path from typing import Optional import yaml def _read_frontmatter(md_path: Path) -> dict: if not md_path.exists(): return {} text = md_path.read_text(encoding="utf-8") if not text.startswith("---\n") and not text.startswith("---\r\n"): return {} end = text.find("\n---", 4) if end < 0: return {} raw = text[4:end] try: return yaml.safe_load(raw) or {} except yaml.YAMLError: return {} def _escape_c_string(s: str) -> str: out = [] for ch in s or "": if ch == "\\": out.append("\\\\") elif ch == '"': out.append('\\"') elif ch == "\n": out.append("\\n") elif ch == "\r": out.append("\\r") elif ch == "\t": out.append("\\t") elif ord(ch) < 32: out.append(f"\\x{ord(ch):02x}") else: out.append(ch) return "".join(out) def _resolve_module(modules_root: Path, mod_id: str) -> Optional[dict]: """mod_id is e.g. `data_table_cpp`. Lookup module.md by name (strip _).""" name = mod_id for suffix in ("_cpp", "_py", "_ts", "_bash", "_go"): if name.endswith(suffix): name = name[: -len(suffix)] break md = modules_root / name / "module.md" fm = _read_frontmatter(md) if not fm: return None return { "name": fm.get("name", name), "version": fm.get("version", "0.0.0"), "description": fm.get("description", ""), } def generate(app_md: Path, modules_root: Path, app_name: str, out_path: Path) -> int: fm = _read_frontmatter(app_md) uses_modules = fm.get("uses_modules") or [] if not isinstance(uses_modules, list): uses_modules = [] entries: list[dict] = [] missing: list[str] = [] for mid in uses_modules: info = _resolve_module(modules_root, str(mid)) if info is None: missing.append(str(mid)) continue entries.append(info) lines: list[str] = [] lines.append(f"// Auto-generated by codegen_app_modules.py — do not edit.") lines.append(f"// App: {app_name}") lines.append(f"// Source of truth: {app_md.as_posix()} (uses_modules)") lines.append("") lines.append('#include "app_modules.h"') lines.append("") lines.append("namespace fn {") if entries: lines.append("const ModuleInfo app_modules_array[] = {") for e in entries: lines.append( ' { "%s", "%s", "%s" },' % ( _escape_c_string(e["name"]), _escape_c_string(e["version"]), _escape_c_string(e["description"]), ) ) lines.append("};") lines.append(f"const unsigned long app_modules_count = {len(entries)};") else: lines.append("const ModuleInfo app_modules_array[1] = { { nullptr, nullptr, nullptr } };") lines.append("const unsigned long app_modules_count = 0;") lines.append("} // namespace fn") lines.append("") new_content = "\n".join(lines) # Idempotent: skip rewrite when content matches. if out_path.exists() and out_path.read_text(encoding="utf-8") == new_content: return 0 if not missing else 2 out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(new_content, encoding="utf-8") if missing: sys.stderr.write( f"codegen_app_modules: WARNING — module(s) not found: {', '.join(missing)} " f"(app {app_name})\n" ) return 2 return 0 def main() -> int: ap = argparse.ArgumentParser(description="Generate _modules_generated.cpp from app.md") ap.add_argument("--app-md", required=True, help="Path to app.md") ap.add_argument("--modules-root", required=True, help="Path to modules/ root") ap.add_argument("--app-name", required=True, help="App name (for comment header)") ap.add_argument("--out", required=True, help="Output path for generated .cpp") args = ap.parse_args() rc = generate( app_md=Path(args.app_md), modules_root=Path(args.modules_root), app_name=args.app_name, out_path=Path(args.out), ) return 0 if rc in (0, 2) else rc if __name__ == "__main__": sys.exit(main())