PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
check_markdown_links.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Local markdown link checker across every Markdown file the current commit carries."""
3
4from __future__ import annotations
5
6import argparse
7import subprocess
8import re
9import sys
10from pathlib import Path
11
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13from repo_files import enumerate_repository_files # noqa: E402
14
15
16LINK_PATTERN = re.compile(r"!\[[^\]]*\]\‍(([^)\s]+)(?:\s+\"[^\"]*\")?\‍)|\[[^\]]*\]\‍(([^)\s]+)(?:\s+\"[^\"]*\")?\‍)")
17
18
19def parse_args() -> argparse.Namespace:
20 """!
21 @brief Parse command-line arguments.
22 @return Value returned by `parse_args()`.
23 """
24 parser = argparse.ArgumentParser(
25 description=(
26 "Check local markdown links across every repository Markdown file.\n"
27 "HTTP(S), mailto, and in-page anchor links are skipped."
28 ),
29 formatter_class=argparse.RawTextHelpFormatter,
30 epilog=(
31 "Examples:\n"
32 " python3 tests/tooling/check_markdown_links.py\n"
33 " python3 tests/tooling/check_markdown_links.py --repo-root .\n"
34 " python3 tests/tooling/check_markdown_links.py --no-readme"
35 ),
36 )
37 parser.add_argument(
38 "--repo-root",
39 type=Path,
40 default=Path(__file__).resolve().parents[2],
41 help="Repository root directory (default: parent of this script).",
42 )
43 parser.add_argument(
44 "--no-readme",
45 action="store_true",
46 help="Skip README.md in scan set.",
47 )
48 return parser.parse_args()
49
50
51SKIP_DIRS = {".git", "docs_build", "obj", "bin", "stubs", "runs", "studies", "__pycache__", ".pytest_cache"}
52
53
54def git_markdown(repo_root: Path) -> list[Path] | None:
55 """!
56 @brief List tracked plus non-ignored untracked Markdown files, so the scan set is
57 reproducible for a given commit regardless of local build or scratch output.
58 @param[in] repo_root Repository root directory.
59 @return Sorted Markdown paths, or None when git enumeration is unavailable.
60 """
61 return enumerate_repository_files(repo_root, ".md")
62
63
64def iter_markdown_files(repo_root: Path, include_readme: bool):
65 """!
66 @brief Yield every repository Markdown file the current commit would carry.
67 @param[in] repo_root Repository root directory.
68 @param[in] include_readme Whether the top-level README participates in the scan.
69 @return Generator of Markdown paths.
70 """
71 candidates = git_markdown(repo_root)
72 if candidates is None:
73 # Fallback for a non-git export: walk the tree and skip generated/scratch trees.
74 candidates = sorted(repo_root.rglob("*.md"))
75 for markdown in candidates:
76 if not markdown.is_file():
77 continue
78 parts = markdown.relative_to(repo_root).parts
79 if SKIP_DIRS.intersection(parts):
80 continue
81 if not include_readme and markdown == repo_root / "README.md":
82 continue
83 yield markdown
84
85
86def should_skip_link(target: str) -> bool:
87 """!
88 @brief Perform should skip link.
89 @param[in] target Argument passed to `should_skip_link()`.
90 @return Value returned by `should_skip_link()`.
91 """
92 lower = target.lower()
93 return (
94 lower.startswith("http://")
95 or lower.startswith("https://")
96 or lower.startswith("mailto:")
97 or lower.startswith("#")
98 )
99
100
101def normalize_target(raw_target: str) -> str:
102 """!
103 @brief Remove Markdown angle brackets, fragments, and query strings before resolving a link target.
104 @param[in] raw_target Argument passed to `normalize_target()`.
105 @return Value returned by `normalize_target()`.
106 """
107 cleaned = raw_target.strip().strip("<>").split("#", 1)[0].split("?", 1)[0]
108 return cleaned
109
110
111def main() -> int:
112 """!
113 @brief Entry point for this script.
114 @return Value returned by `main()`.
115 """
116 args = parse_args()
117 repo_root = args.repo_root.resolve()
118 failures = []
119
120 scanned = list(iter_markdown_files(repo_root, include_readme=not args.no_readme))
121 for md_file in scanned:
122 if not md_file.is_file():
123 failures.append((str(md_file), "<file>", "Markdown file not found"))
124 continue
125 content = md_file.read_text(encoding="utf-8", errors="replace")
126 for match in LINK_PATTERN.findall(content):
127 target = match[0] or match[1]
128 if should_skip_link(target):
129 continue
130 normalized = normalize_target(target)
131 if not normalized:
132 continue
133 resolved = (md_file.parent / normalized).resolve()
134 try:
135 exists = resolved.exists()
136 except OSError as exc:
137 failures.append((str(md_file.relative_to(repo_root)), target, f"{resolved} ({exc})"))
138 continue
139 if not exists:
140 failures.append((str(md_file.relative_to(repo_root)), target, str(resolved)))
141
142 if failures:
143 print("Broken markdown links detected:")
144 for src, target, resolved in failures:
145 print(f" - {src}: '{target}' -> missing '{resolved}'")
146 return 1
147
148 print(f"Markdown link check passed for {len(scanned)} repository Markdown files.")
149 return 0
150
151
152if __name__ == "__main__":
153 sys.exit(main())
Head of a generic C-style linked list.
Definition variables.h:469