File indexing completed on 2026-01-10 09:16:57
0001
0002
0003
0004
0005
0006
0007
0008
0009
0010
0011
0012 import logging
0013 import re
0014 import shlex
0015 from pathlib import Path
0016 from typing import Annotated
0017
0018 import livereload
0019 import subprocess
0020 import typer
0021 from rich.console import Console
0022 from rich.logging import RichHandler
0023
0024
0025 logger = logging.getLogger("docs.serve")
0026
0027 app = typer.Typer()
0028 console = Console()
0029
0030
0031 def build_docs(build_dir: Path) -> None:
0032 subprocess.run(["cmake", "--build", build_dir, "--target", "docs"], check=True)
0033
0034
0035 def find_source_dir(build_dir: Path) -> Path:
0036 cmake_cache = build_dir / "CMakeCache.txt"
0037 if not cmake_cache.is_file():
0038 logger.error("CMake cache file %s is missing", cmake_cache)
0039 raise typer.Exit(1)
0040
0041 source_dir: Path | None = None
0042 with cmake_cache.open(encoding="utf-8") as handle:
0043 for line in handle:
0044 if line.startswith("Acts_SOURCE_DIR:STATIC="):
0045 source_dir = Path(line.split("=", 1)[1].strip())
0046 break
0047
0048 if source_dir is None or not source_dir.is_dir():
0049 logger.error("Could not determine source directory from CMake cache")
0050 raise typer.Exit(1)
0051
0052 return source_dir
0053
0054
0055 @app.command()
0056 def main(
0057 build_dir: Annotated[Path, typer.Option(file_okay=False, exists=True)] = Path(
0058 "build"
0059 ),
0060 port: int = 8000,
0061 verbose: Annotated[bool, typer.Option("-v", "--verbose")] = False,
0062 ):
0063
0064 logger.setLevel(logging.DEBUG if verbose else logging.INFO)
0065 logger.addHandler(RichHandler())
0066
0067 build_dir = build_dir.resolve()
0068 logger.info("Using build directory: %s", build_dir)
0069 source_dir = find_source_dir(build_dir)
0070 logger.debug("Source directory: %s", source_dir)
0071 docs_dir = (source_dir / "docs").resolve()
0072 if not docs_dir.is_dir():
0073 logger.error("Docs directory %s is missing", docs_dir)
0074 raise typer.Exit(1)
0075
0076 logger.info("Docs directory: %s", docs_dir)
0077
0078 logger.info("Initial documentation build...")
0079 build_docs(build_dir)
0080
0081 output_dir = build_dir / "docs/html"
0082 if not output_dir.exists():
0083 console.print("[red]Error: output directory does not exist.[/red]")
0084 raise typer.Exit(1)
0085
0086 doxygen_inputs, file_patterns = parse_doxygen_lists(build_dir)
0087 logger.debug(doxygen_inputs)
0088 logger.debug(file_patterns)
0089
0090 server = livereload.Server()
0091
0092 def rebuild(changed=None):
0093 if changed is not None:
0094 logger.info("Changes detected: %s => rebuilding...", changed)
0095 else:
0096 logger.info("Rebuilding documentation...")
0097 build_docs(build_dir)
0098
0099 for target in expand_watch_patterns(docs_dir, doxygen_inputs, file_patterns):
0100 logger.debug("Watching %s", target)
0101 server.watch(target, rebuild)
0102
0103 doxy_template = docs_dir / "Doxyfile.in"
0104 if not doxy_template.exists():
0105 logger.error("Doxygen template %s is missing", doxy_template)
0106 raise typer.Exit(1)
0107
0108 server.watch(str(doxy_template), rebuild)
0109
0110 layout_file = docs_dir / "DoxygenLayout.xml"
0111 if layout_file.exists():
0112 server.watch(str(layout_file), rebuild)
0113
0114 server.watch(str(source_dir / "CONTRIBUTING.md"), rebuild)
0115
0116 server.watch(str(docs_dir / "examples/**/*.cpp"), rebuild)
0117 server.watch(str(docs_dir / "examples/**/*.py"), rebuild)
0118 server.watch(str(docs_dir / "*.bib"), rebuild)
0119
0120 server.serve(root=str(output_dir), port=port)
0121
0122
0123 def parse_doxygen_lists(build_dir: Path) -> tuple[list[str], list[str]]:
0124 """Return the INPUT and FILE_PATTERNS entries from the generated Doxyfile."""
0125 doxyfile = (build_dir / "docs" / "Doxyfile").resolve()
0126 if not doxyfile.is_file():
0127 raise FileNotFoundError(f"Could not find Doxyfile at {doxyfile}")
0128
0129 targets = {"INPUT": [], "FILE_PATTERNS": []}
0130 collecting: str | None = None
0131
0132 def _rstrip_continuation(value: str) -> tuple[str, bool]:
0133 value = value.rstrip()
0134 if value.endswith("\\"):
0135 return value[:-1].rstrip(), True
0136 return value, False
0137
0138 def _extend(target_list: list[str], chunk: str) -> None:
0139 if not chunk:
0140 return
0141 tokens = shlex.split(chunk, comments=False, posix=True)
0142 for token in tokens:
0143 cleaned = token.rstrip(",")
0144 if cleaned:
0145 target_list.append(cleaned)
0146
0147 with doxyfile.open(encoding="utf-8") as handle:
0148 for raw_line in handle:
0149 line = raw_line.strip()
0150 if not line or line.startswith("#"):
0151 continue
0152
0153 if collecting:
0154 chunk, carry_on = _rstrip_continuation(line)
0155 _extend(targets[collecting], chunk)
0156 if not carry_on:
0157 collecting = None
0158 continue
0159
0160 match = re.match(r"^(INPUT|FILE_PATTERNS)\s*(\+?=)\s*(.*)$", line)
0161 if not match:
0162 continue
0163
0164 tag = match.group(1)
0165 chunk, carry_on = _rstrip_continuation(match.group(3))
0166 _extend(targets[tag], chunk)
0167 if carry_on:
0168 collecting = tag
0169
0170 return targets["INPUT"], targets["FILE_PATTERNS"]
0171
0172
0173 def expand_watch_patterns(
0174 docs_dir: Path, inputs: list[str], file_patterns: list[str]
0175 ) -> list[str]:
0176 """Return file or glob patterns for each Doxygen INPUT entry."""
0177 patterns = [pattern for pattern in (file_patterns or []) if pattern]
0178 resolved_docs = docs_dir.resolve()
0179 watch_targets: dict[str, None] = {}
0180
0181 for entry in inputs:
0182 if not entry:
0183 continue
0184
0185 path = Path(entry)
0186 if not path.is_absolute():
0187 path = (resolved_docs / path).resolve()
0188 else:
0189 path = path.resolve()
0190
0191 if path.is_file():
0192 watch_targets[str(path)] = None
0193 continue
0194
0195 if path.is_dir():
0196 if patterns:
0197 for pattern in patterns:
0198 watch_targets[str(path / "**" / pattern)] = None
0199 else:
0200 watch_targets[str(path / "**" / "*")] = None
0201 continue
0202
0203 logger.warning("Doxygen input %s does not exist; skipping.", path)
0204
0205 return list(watch_targets.keys())
0206
0207
0208 if "__main__" == __name__:
0209 app()