from __future__ import annotations
import os
import re
import sys
from collections import defaultdict
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
MAX_WIDTH = 80
problems: list[str] = []
def report(path: str, line: int, message: str) -> None:
problems.append(f"{os.path.relpath(path)}:{line}: {message}")
def slug(heading: str) -> str:
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", heading.strip())
text = re.sub(r"[`*]", "", text)
text = re.sub(r"[^\w\s-]", "", text.lower())
return re.sub(r"\s", "-", text)
def markdown_files() -> list[str]:
found = []
for directory, _, names in os.walk(ROOT):
found.extend(
os.path.join(directory, name) for name in names if name.endswith(".md")
)
return sorted(found)
files = markdown_files()
anchors: dict[str, set[str]] = {}
key_locations: dict[str, set[str]] = defaultdict(set)
for path in files:
lines = open(path, encoding="utf-8").read().split("\n")
anchors[os.path.normpath(path)] = {
slug(m.group(2))
for m in (re.match(r"^(#+)\s+(.*)$", line) for line in lines)
if m
}
in_fence = False
for number, line in enumerate(lines, start=1):
fence = re.match(r"^\s*```(\S*)", line)
if fence:
if not in_fence and not fence.group(1):
report(path, number, "fenced block declares no language")
in_fence = not in_fence
continue
if line != line.rstrip():
report(path, number, "trailing whitespace")
if in_fence:
continue
if re.match(r"^#+ \d+\.", line):
report(path, number, "numbered heading — use an ordered list instead")
if line.startswith("**`"):
for env in re.findall(r"`(ACME_PROXY_[A-Z0-9_<>]+)`", line):
key_locations[env].add(os.path.normpath(path))
exempt = (
line.lstrip().startswith(("|", "http"))
or line.startswith("#")
or line.startswith("**`")
or re.match(r"^\s*[-*]?\s*\[[^\]]+\]\([^)]+\)[.,]?\s*$", line)
or re.match(r"^\s{4,}\S", line) )
if len(line) > MAX_WIDTH and not exempt:
report(path, number, f"line is {len(line)} columns, over {MAX_WIDTH}")
for path in files:
directory = os.path.dirname(path)
for number, line in enumerate(
open(path, encoding="utf-8").read().split("\n"), start=1
):
for target in re.findall(r"\]\(([^)]+)\)", line):
if target.startswith(("http", "mailto:")):
continue
relative, _, fragment = target.partition("#")
resolved = (
os.path.normpath(os.path.join(directory, relative))
if relative
else os.path.normpath(path)
)
if not os.path.exists(resolved):
report(path, number, f"link target does not exist: {target}")
elif fragment and fragment not in anchors.get(resolved, set()):
report(path, number, f"link anchor does not exist: {target}")
for key, where in sorted(key_locations.items()):
if len(where) > 1:
listed = ", ".join(sorted(os.path.relpath(p) for p in where))
problems.append(f"{key} is documented in more than one file: {listed}")
if problems:
print(f"{len(problems)} problem(s):", file=sys.stderr)
for problem in problems:
print(f" {problem}", file=sys.stderr)
sys.exit(1)
print(f"doc/lint.py: {len(files)} pages, no problems")