from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
CITATION = re.compile(r"`(?P<path>[A-Za-z0-9_./-]+\.rs):(?P<symbol>[A-Za-z0-9_]+(?:::[A-Za-z0-9_]+)*)`")
LINE_CITATION = re.compile(r"`(?P<path>[A-Za-z0-9_./-]+\.rs):(?P<lines>[0-9]+(?:-[0-9]+)?)`")
def definition_patterns(symbol: str) -> list[re.Pattern[str]]:
s = re.escape(symbol)
return [
re.compile(rf"\bfn\s+{s}\b"),
re.compile(rf"\b(?:struct|enum|trait|union|type|const|static|mod)\s+{s}\b"),
re.compile(rf"\bmacro_rules!\s*{s}\b"),
re.compile(rf"\bimpl\b[^\n]*\bfor\s+{s}\b"),
re.compile(rf"\bimpl\b(?:<[^>]*>)?\s+{s}\b"),
re.compile(rf"^\s+{s}\s*[({{:,]", re.MULTILINE),
re.compile(rf"^\s+{s}\s*$", re.MULTILINE),
re.compile(rf"\b{s}\s*\("),
]
def resolve(path: str, root: Path) -> Path | None:
for candidate in (root / path, root / "src" / path):
if candidate.is_file():
return candidate
return None
def self_test() -> int:
import tempfile
cases: list[tuple[str, str, bool]] = [
("a symbol that exists", "See `src/lib.rs:kept_fn`.", True),
("a renamed symbol", "See `src/lib.rs:renamed_away`.", False),
("a line number", "See `src/lib.rs:42`.", False),
("a missing file", "See `src/nope.rs:kept_fn`.", False),
("a struct", "See `src/lib.rs:KeptStruct`.", True),
("an enum variant", "See `src/lib.rs:KeptVariant`.", True),
("a const", "See `src/lib.rs:KEPT_CONST`.", True),
("a macro", "See `src/lib.rs:kept_macro`.", True),
("a Type::method form", "See `src/lib.rs:KeptStruct::kept_fn`.", True),
("prose with no citation", "Just `kept_fn` in prose.", True),
]
source = (
"pub fn kept_fn() {}\n"
"pub struct KeptStruct;\n"
"pub enum E {\n KeptVariant,\n}\n"
"pub const KEPT_CONST: u8 = 1;\n"
"macro_rules! kept_macro { () => {}; }\n"
)
failures = []
for description, body, expected_ok in cases:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "src").mkdir()
(root / "src" / "lib.rs").write_text(source, encoding="utf-8")
(root / "specs").mkdir()
(root / "specs" / "t.md").write_text(body, encoding="utf-8")
argv = sys.argv
stderr, stdout = sys.stderr, sys.stdout
try:
sys.argv = ["check", "--root", str(root)]
with open("/dev/null", "w") as devnull:
sys.stderr = sys.stdout = devnull
actual_ok = main() == 0
finally:
sys.argv, sys.stderr, sys.stdout = argv, stderr, stdout
if actual_ok != expected_ok:
verb = "accepted" if actual_ok else "rejected"
failures.append(f" {description}: {verb}, expected the opposite")
if failures:
print("self-test failed:", file=sys.stderr)
print("\n".join(failures), file=sys.stderr)
return 1
print(f"self-test ok ({len(cases)} cases)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--specs", default="specs", help="directory of spec markdown")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument(
"--self-test",
action="store_true",
help="check that the checker rejects renamed symbols and line numbers",
)
args = parser.parse_args()
if args.self_test:
return self_test()
root = Path(args.root).resolve()
specs = (root / args.specs).resolve()
if not specs.is_dir():
print(f"error: no spec directory at {specs}", file=sys.stderr)
return 2
sources: dict[Path, str] = {}
failures: list[str] = []
checked = 0
for doc in sorted(specs.rglob("*.md")):
text = doc.read_text(encoding="utf-8")
rel = doc.relative_to(root)
for lineno, line in enumerate(text.splitlines(), start=1):
for match in LINE_CITATION.finditer(line):
failures.append(
f"{rel}:{lineno}: cites a line number "
f"`{match.group('path')}:{match.group('lines')}`; "
f"cite the enclosing symbol instead"
)
for match in CITATION.finditer(line):
path, symbol = match.group("path"), match.group("symbol")
if symbol.isdigit():
continue
checked += 1
resolved = resolve(path, root)
if resolved is None:
failures.append(f"{rel}:{lineno}: no such file `{path}`")
continue
if resolved not in sources:
sources[resolved] = resolved.read_text(encoding="utf-8")
body = sources[resolved]
leaf = symbol.split("::")[-1]
if not any(p.search(body) for p in definition_patterns(leaf)):
failures.append(
f"{rel}:{lineno}: `{path}` defines no `{leaf}` "
f"(cited as `{path}:{symbol}`)"
)
if failures:
print(f"{len(failures)} bad spec citation(s):", file=sys.stderr)
for failure in failures:
print(f" {failure}", file=sys.stderr)
return 1
print(f"spec citations ok ({checked} checked across {len(sources)} source files)")
return 0
if __name__ == "__main__":
sys.exit(main())