summaryrefslogtreecommitdiffstats
path: root/scripts/check-docs-links.py
blob: fcb39ca05d55e5d2368588aac4085d0f47b74df7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
#!/usr/bin/env python3
"""Check relative links in the project's tracked markdown documentation.

Walks docs/, wiki/ and userspace/cuda/, extracts markdown links, and fails if
a relative target does not exist. External links (http/https/mailto/tel/ftp/
data/javascript), pure anchors and links inside fenced code blocks are
skipped.

The raw imported wiki HTML dumps and the archived wiki copy are excluded:
their markup is not ours and their link shape is not representative.

Usage:
    python3 scripts/check-docs-links.py
"""

import os
import re
import subprocess
import sys
from urllib.parse import unquote

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WATCHED_ROOTS = ("docs", "wiki", "userspace/cuda")

EXCLUDED_FILES = {
    "wiki/GPU_Driver_Internals.md",
    "wiki/GPU_Firmware.md",
    "wiki/GPU_SBOM.md",
    "wiki/Virtual_IO_Internals.md",
}
EXCLUDED_PREFIXES = ("wiki/open-iov.org/",)

SKIPPED_SCHEMES = (
    "http://",
    "https://",
    "mailto:",
    "tel:",
    "ftp://",
    "ftps://",
    "data:",
    "javascript:",
)

LINK_RE = re.compile(r"!?\[[^\]]*\]\(\s*([^)]+?)\s*\)")


def tracked_markdown_files():
    """Return repo-relative paths of tracked markdown files (git first)."""
    try:
        out = subprocess.run(
            ["git", "ls-files", "--", *WATCHED_ROOTS],
            cwd=REPO_ROOT,
            check=True,
            capture_output=True,
            text=True,
        ).stdout
    except (OSError, subprocess.CalledProcessError):
        out = ""
    if out.strip():
        return [line for line in out.splitlines() if line.endswith(".md")]

    files = []
    for root in WATCHED_ROOTS:
        for dirpath, _dirnames, filenames in os.walk(os.path.join(REPO_ROOT, root)):
            for name in filenames:
                if name.endswith(".md"):
                    full = os.path.join(dirpath, name)
                    files.append(os.path.relpath(full, REPO_ROOT))
    return files


def excluded(relpath):
    if relpath in EXCLUDED_FILES:
        return True
    return relpath.startswith(EXCLUDED_PREFIXES)


def strip_code_blocks(text):
    """Blank out fenced code blocks so example links are not checked."""
    lines = []
    fence = None
    for line in text.splitlines():
        stripped = line.lstrip()
        marker = stripped[:3]
        if fence is None:
            if marker in ("```", "~~~"):
                fence = marker
                lines.append("")
                continue
            lines.append(line)
        else:
            if stripped.startswith(fence):
                fence = None
            lines.append("")
    return "\n".join(lines)


def normalize_target(raw):
    target = raw.strip()
    if target.startswith("<") and target.endswith(">"):
        target = target[1:-1].strip()
    if not target:
        return None
    if target.startswith("#"):
        return None
    if target.lower().startswith(SKIPPED_SCHEMES):
        return None
    # Drop an anchor and any markdown title, then percent-decode.
    target = target.split()[0] if " " in target else target
    target = target.split("#", 1)[0]
    target = unquote(target)
    if not target:
        return None
    return target


def check_file(relpath):
    full = os.path.join(REPO_ROOT, relpath)
    with open(full, "r", encoding="utf-8", errors="replace") as handle:
        text = strip_code_blocks(handle.read())

    problems = []
    in_fence = False
    for lineno, line in enumerate(text.splitlines(), start=1):
        if line.lstrip()[:3] in ("```", "~~~"):
            in_fence = not in_fence
            continue
        if in_fence:
            continue
        for raw in LINK_RE.findall(line):
            target = normalize_target(raw)
            if target is None:
                continue
            if target.startswith("/"):
                resolved = os.path.join(REPO_ROOT, target.lstrip("/"))
            else:
                resolved = os.path.normpath(
                    os.path.join(REPO_ROOT, os.path.dirname(relpath), target)
                )
            if not os.path.exists(resolved):
                problems.append((lineno, target, os.path.relpath(resolved, REPO_ROOT)))
    return problems


def main():
    files = sorted(
        relpath
        for relpath in tracked_markdown_files()
        if not excluded(relpath)
    )
    failures = 0
    for relpath in files:
        for lineno, target, resolved in check_file(relpath):
            failures += 1
            print(f"{relpath}:{lineno}: broken link: {target} (resolved: {resolved})")

    if failures:
        print(f"\n{failures} broken relative link(s) in {len(files)} file(s).")
        return 1
    print(f"OK: {len(files)} markdown file(s) checked, no broken relative links.")
    return 0


if __name__ == "__main__":
    sys.exit(main())