PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
generate_xref_index.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Distill Doxygen XML references into an optional, bounded review index."""
3
4from __future__ import annotations
5
6import argparse
7import hashlib
8import json
9import subprocess
10import sys
11import xml.etree.ElementTree as ET
12from pathlib import Path
13
14
15SCHEMA_VERSION = 1
16SOURCE_ROOTS = ("include", "src", "picurv_cli", "generators")
17SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".h", ".hpp", ".py", ".sh", ".flow"}
18
19
20def source_files(repo_root: Path) -> list[Path]:
21 """!
22 @brief Discover source inputs whose dirty bytes determine cross-reference freshness.
23 @param[in] repo_root Repository root.
24 @return Sorted repository-relative paths.
25 """
26
27 found: list[Path] = []
28 for root_name in SOURCE_ROOTS:
29 root = repo_root / root_name
30 if not root.is_dir():
31 continue
32 found.extend(
33 path.relative_to(repo_root)
34 for path in root.rglob("*")
35 if path.is_file() and path.suffix in SOURCE_SUFFIXES and "__pycache__" not in path.parts
36 )
37 return sorted(set(found), key=lambda path: path.as_posix())
38
39
40def digest_files(repo_root: Path, paths: list[Path]) -> str:
41 """!
42 @brief Hash path names and current file bytes deterministically.
43 @param[in] repo_root Repository root.
44 @param[in] paths Repository-relative paths to hash.
45 @return SHA-256 digest with algorithm prefix.
46 """
47
48 accumulator = hashlib.sha256()
49 for relative in sorted(paths, key=lambda path: path.as_posix()):
50 accumulator.update(relative.as_posix().encode("utf-8"))
51 accumulator.update(b"\0")
52 accumulator.update((repo_root / relative).read_bytes())
53 accumulator.update(b"\0")
54 return f"sha256:{accumulator.hexdigest()}"
55
56
57def file_digest(path: Path) -> str:
58 """!
59 @brief Hash one configuration file.
60 @param[in] path File to hash.
61 @return SHA-256 digest with algorithm prefix.
62 """
63
64 return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}"
65
66
67def repository_path(repo_root: Path, raw: str) -> str:
68 """!
69 @brief Normalize a Doxygen location to a repository-relative POSIX path when possible.
70 @param[in] repo_root Repository root.
71 @param[in] raw Location value emitted by Doxygen.
72 @return Normalized path string.
73 """
74
75 path = Path(raw)
76 if path.is_absolute():
77 try:
78 path = path.resolve().relative_to(repo_root.resolve())
79 except ValueError:
80 return path.as_posix()
81 return path.as_posix().lstrip("./")
82
83
84def parse_xml(repo_root: Path, xml_dir: Path) -> dict[str, dict]:
85 """!
86 @brief Extract definition locations and Doxygen reference edges from XML output.
87 @param[in] repo_root Repository root used to normalize locations.
88 @param[in] xml_dir Doxygen XML directory.
89 @return Mapping from Doxygen member id to distilled symbol record.
90 """
91
92 symbols: dict[str, dict] = {}
93 for xml_path in sorted(xml_dir.glob("*.xml")):
94 try:
95 document = ET.parse(xml_path)
96 except ET.ParseError as error:
97 raise RuntimeError(f"cannot parse {xml_path}: {error}") from error
98 for member in document.findall(".//memberdef"):
99 refid = member.get("id")
100 location = member.find("location")
101 name = member.findtext("name")
102 if not refid or location is None or not name or not location.get("file"):
103 continue
104 outgoing = sorted(
105 {ref.get("refid") for ref in member.findall("references") if ref.get("refid")}
106 )
107 incoming = sorted(
108 {ref.get("refid") for ref in member.findall("referencedby") if ref.get("refid")}
109 )
110 symbols[refid] = {
111 "name": name,
112 "qualified_name": member.findtext("qualifiedname") or name,
113 "kind": member.get("kind", "unknown"),
114 "definition": {
115 "path": repository_path(repo_root, location.get("file", "")),
116 "line": int(location.get("line", "0") or 0),
117 },
118 "outgoing": outgoing,
119 "incoming": incoming,
120 }
121 document.getroot().clear()
122 return distill_symbols(symbols)
123
124
125def distill_symbols(symbols: dict[str, dict]) -> dict[str, dict]:
126 """!
127 @brief Keep production definitions and remap header references to source definitions.
128 @param[in] symbols Raw Doxygen member records.
129 @return Compact production-oriented symbol graph.
130 """
131
132 source_functions = {
133 record["name"]: refid
134 for refid, record in symbols.items()
135 if record["kind"] == "function" and record["definition"]["path"].startswith("src/")
136 }
137 retained: set[str] = set()
138 for refid, record in symbols.items():
139 path = record["definition"]["path"]
140 kind = record["kind"]
141 if path.startswith(("src/", "picurv_cli/", "generators/")) and kind in {
142 "function",
143 "variable",
144 "enum",
145 "typedef",
146 }:
147 retained.add(refid)
148 elif path.startswith("include/") and (
149 kind in {"enum", "typedef"}
150 or (kind == "function" and record["name"] not in source_functions)
151 ):
152 retained.add(refid)
153
154 def canonical(refid: str) -> str | None:
155 """!
156 @brief Resolve a raw edge to a retained implementation-oriented member.
157 @param[in] refid Raw Doxygen member id.
158 @return Retained member id, or None when the edge is outside the compact index.
159 """
160
161 if refid in retained:
162 return refid
163 record = symbols.get(refid)
164 if record and record["kind"] == "function":
165 return source_functions.get(record["name"])
166 return None
167
168 distilled: dict[str, dict] = {}
169 for refid in sorted(retained):
170 record = dict(symbols[refid])
171 record["incoming"] = sorted(
172 {mapped for edge in record["incoming"] if (mapped := canonical(edge)) is not None}
173 )
174 record["outgoing"] = sorted(
175 {mapped for edge in record["outgoing"] if (mapped := canonical(edge)) is not None}
176 )
177 distilled[refid] = record
178 return distilled
179
180
181def command_output(command: list[str], cwd: Path) -> str:
182 """!
183 @brief Return the first line from an orientation-only metadata command.
184 @param[in] command Executable and arguments.
185 @param[in] cwd Working directory.
186 @return First stdout line, or `unavailable` when the command fails.
187 """
188
189 completed = subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False)
190 if completed.returncode != 0 or not completed.stdout.strip():
191 return "unavailable"
192 return completed.stdout.strip().splitlines()[0]
193
194
195def generate(repo_root: Path, xml_dir: Path, output: Path) -> None:
196 """!
197 @brief Generate the stamped JSON cross-reference index.
198 @param[in] repo_root Repository root.
199 @param[in] xml_dir Doxygen XML directory.
200 @param[in] output Destination JSON path.
201 @return None.
202 @throws RuntimeError when XML or required configuration is unavailable.
203 """
204
205 if not xml_dir.is_dir() or not any(xml_dir.glob("*.xml")):
206 raise RuntimeError(
207 f"Doxygen XML is unavailable at {xml_dir}; run doxygen docs/Doxyfile first"
208 )
209 doxyfile = repo_root / "docs" / "Doxyfile"
210 if not doxyfile.is_file():
211 raise RuntimeError(f"missing Doxygen configuration: {doxyfile}")
212 inputs = source_files(repo_root)
213 payload = {
214 "schema_version": SCHEMA_VERSION,
215 "source_digest": digest_files(repo_root, inputs),
216 "source_files": [path.as_posix() for path in inputs],
217 "doxyfile_digest": file_digest(doxyfile),
218 "doxygen_version": command_output(["doxygen", "--version"], repo_root),
219 "git_commit": command_output(["git", "rev-parse", "HEAD"], repo_root),
220 "symbols": parse_xml(repo_root, xml_dir),
221 }
222 output.parent.mkdir(parents=True, exist_ok=True)
223 output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
224
225
226def parse_args(argv: list[str]) -> argparse.Namespace:
227 """!
228 @brief Parse generator command-line arguments.
229 @param[in] argv Arguments excluding the executable name.
230 @return Parsed command-line namespace.
231 """
232
233 parser = argparse.ArgumentParser(description=__doc__)
234 parser.add_argument("--repo-root", type=Path, default=Path.cwd())
235 parser.add_argument("--xml-dir", type=Path)
236 parser.add_argument("--output", type=Path)
237 return parser.parse_args(argv)
238
239
240def main(argv: list[str] | None = None) -> int:
241 """!
242 @brief Generate the optional index and report actionable failures.
243 @param[in] argv Optional arguments excluding the executable name.
244 @return Zero on success and one on failure.
245 """
246
247 args = parse_args(sys.argv[1:] if argv is None else argv)
248 repo_root = args.repo_root.resolve()
249 xml_dir = (args.xml_dir or repo_root / "docs_build" / "xml").resolve()
250 output = (args.output or repo_root / "docs_build" / "xref.json").resolve()
251 try:
252 generate(repo_root, xml_dir, output)
253 except (OSError, RuntimeError) as error:
254 print(f"Cross-reference generation failed: {error}", file=sys.stderr)
255 return 1
256 try:
257 relative = output.relative_to(repo_root)
258 except ValueError:
259 relative = output
260 print(f"Doxygen cross-reference index generated: {relative}")
261 return 0
262
263
264if __name__ == "__main__":
265 raise SystemExit(main())
str digest_files(Path repo_root, list[Path] paths)
Hash path names and current file bytes deterministically.
int main(list[str]|None argv=None)
Generate the optional index and report actionable failures.
list[Path] source_files(Path repo_root)
Discover source inputs whose dirty bytes determine cross-reference freshness.
str repository_path(Path repo_root, str raw)
Normalize a Doxygen location to a repository-relative POSIX path when possible.
str command_output(list[str] command, Path cwd)
Return the first line from an orientation-only metadata command.
argparse.Namespace parse_args(list[str] argv)
Parse generator command-line arguments.
dict[str, dict] parse_xml(Path repo_root, Path xml_dir)
Extract definition locations and Doxygen reference edges from XML output.
dict[str, dict] distill_symbols(dict[str, dict] symbols)
Keep production definitions and remap header references to source definitions.
None generate(Path repo_root, Path xml_dir, Path output)
Generate the stamped JSON cross-reference index.
str file_digest(Path path)
Hash one configuration file.