2"""Distill Doxygen XML references into an optional, bounded review index."""
4from __future__
import annotations
11import xml.etree.ElementTree
as ET
12from pathlib
import Path
16SOURCE_ROOTS = (
"include",
"src",
"picurv_cli",
"generators")
17SOURCE_SUFFIXES = {
".c",
".cc",
".cpp",
".h",
".hpp",
".py",
".sh",
".flow"}
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.
27 found: list[Path] = []
28 for root_name
in SOURCE_ROOTS:
29 root = repo_root / root_name
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
37 return sorted(set(found), key=
lambda path: path.as_posix())
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.
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()}"
59 @brief Hash one configuration file.
60 @param[in] path File to hash.
61 @return SHA-256 digest with algorithm prefix.
64 return f
"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}"
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.
76 if path.is_absolute():
78 path = path.resolve().relative_to(repo_root.resolve())
80 return path.as_posix()
81 return path.as_posix().lstrip(
"./")
84def parse_xml(repo_root: Path, xml_dir: Path) -> dict[str, dict]:
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.
92 symbols: dict[str, dict] = {}
93 for xml_path
in sorted(xml_dir.glob(
"*.xml")):
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"):
105 {ref.get(
"refid")
for ref
in member.findall(
"references")
if ref.get(
"refid")}
108 {ref.get(
"refid")
for ref
in member.findall(
"referencedby")
if ref.get(
"refid")}
112 "qualified_name": member.findtext(
"qualifiedname")
or name,
113 "kind": member.get(
"kind",
"unknown"),
116 "line": int(location.get(
"line",
"0")
or 0),
118 "outgoing": outgoing,
119 "incoming": incoming,
121 document.getroot().clear()
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.
133 record[
"name"]: refid
134 for refid, record
in symbols.items()
135 if record[
"kind"] ==
"function" and record[
"definition"][
"path"].startswith(
"src/")
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 {
148 elif path.startswith(
"include/")
and (
149 kind
in {
"enum",
"typedef"}
150 or (kind ==
"function" and record[
"name"]
not in source_functions)
154 def canonical(refid: str) -> str |
None:
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.
161 if refid
in retained:
163 record = symbols.get(refid)
164 if record
and record[
"kind"] ==
"function":
165 return source_functions.get(record[
"name"])
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}
174 record[
"outgoing"] = sorted(
175 {mapped
for edge
in record[
"outgoing"]
if (mapped := canonical(edge))
is not None}
177 distilled[refid] = record
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.
189 completed = subprocess.run(command, cwd=cwd, capture_output=
True, text=
True, check=
False)
190 if completed.returncode != 0
or not completed.stdout.strip():
192 return completed.stdout.strip().splitlines()[0]
195def generate(repo_root: Path, xml_dir: Path, output: Path) ->
None:
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.
202 @throws RuntimeError when XML or required configuration is unavailable.
205 if not xml_dir.is_dir()
or not any(xml_dir.glob(
"*.xml")):
207 f
"Doxygen XML is unavailable at {xml_dir}; run doxygen docs/Doxyfile first"
209 doxyfile = repo_root /
"docs" /
"Doxyfile"
210 if not doxyfile.is_file():
211 raise RuntimeError(f
"missing Doxygen configuration: {doxyfile}")
214 "schema_version": SCHEMA_VERSION,
216 "source_files": [path.as_posix()
for path
in inputs],
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),
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")
228 @brief Parse generator command-line arguments.
229 @param[in] argv Arguments excluding the executable name.
230 @return Parsed command-line namespace.
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)
240def main(argv: list[str] |
None =
None) -> int:
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.
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()
252 generate(repo_root, xml_dir, output)
253 except (OSError, RuntimeError)
as error:
254 print(f
"Cross-reference generation failed: {error}", file=sys.stderr)
257 relative = output.relative_to(repo_root)
260 print(f
"Doxygen cross-reference index generated: {relative}")
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.