from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit
MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+[^)]*)?\)")
HTML_LINK = re.compile(r"\b(?:href|src)\s*=\s*([\"'])(.*?)\1", re.IGNORECASE)
ATX_HEADING = re.compile(r"^ {0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$")
def github_anchor(heading: str) -> str:
text = re.sub(r"`([^`]*)`", r"\1", heading)
text = re.sub(r"!?\[([^]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"<[^>]+>", "", text)
characters = []
for character in text.lower():
if character.isalnum() or character == "-":
characters.append(character)
elif character.isspace():
characters.append(" ")
return re.sub(r" +", "-", "".join(characters).strip())
def collect_anchors(markdown: str) -> set[str]:
anchors: set[str] = set()
counts: dict[str, int] = {}
in_fence = False
for line in markdown.splitlines():
if re.match(r"^\s*(```|~~~)", line):
in_fence = not in_fence
continue
if in_fence:
continue
match = ATX_HEADING.match(line)
if not match:
continue
anchor = github_anchor(match.group(1))
if not anchor:
continue
number = counts.get(anchor, 0)
candidate = anchor if number == 0 else f"{anchor}-{number}"
while candidate in anchors:
number += 1
candidate = f"{anchor}-{number}"
counts[anchor] = number + 1
anchors.add(candidate)
return anchors
def markdown_files(inputs: list[Path]) -> list[Path]:
files: set[Path] = set()
for input_path in inputs:
if input_path.is_dir():
files.update(path for path in input_path.rglob("*.md") if path.is_file())
elif input_path.is_file() and input_path.suffix.lower() == ".md":
files.add(input_path)
return sorted(files)
def _links(line: str) -> list[str]:
return [match.group(1) for match in MARKDOWN_LINK.finditer(line)] + [
match.group(2) for match in HTML_LINK.finditer(line)
]
def _local_destination(destination: str) -> tuple[str, str] | None:
parsed = urlsplit(destination)
if parsed.scheme or parsed.netloc:
return None
path = unquote(parsed.path)
fragment = unquote(parsed.fragment)
if path.startswith("/") and not fragment:
return None
return path, fragment
def validate_paths(files: list[Path]) -> list[str]:
diagnostics: list[str] = []
anchor_cache: dict[Path, set[str]] = {}
for source in markdown_files(files):
try:
lines = source.read_text(encoding="utf-8").splitlines()
except OSError as error:
diagnostics.append(f"{source}: unable to read file: {error}")
continue
for line_number, line in enumerate(lines, start=1):
for destination in _links(line):
local = _local_destination(destination)
if local is None:
continue
raw_path, fragment = local
target = source if not raw_path else source.parent / raw_path
if raw_path and not target.exists():
diagnostics.append(
f"{source}:{line_number}: missing local target {raw_path}"
)
continue
if fragment and target.suffix.lower() == ".md":
resolved_target = target.resolve()
if resolved_target not in anchor_cache:
try:
anchor_cache[resolved_target] = collect_anchors(
target.read_text(encoding="utf-8")
)
except OSError as error:
diagnostics.append(
f"{source}:{line_number}: unable to read target {raw_path}: {error}"
)
continue
if fragment not in anchor_cache[resolved_target]:
description = raw_path or source.name
diagnostics.append(
f"{source}:{line_number}: missing anchor #{fragment} in {description}"
)
return diagnostics
def main(argv: list[str]) -> int:
diagnostics = validate_paths([Path(argument) for argument in argv])
if diagnostics:
print("\n".join(diagnostics), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))