Repository Public MIT
1#!/usr/bin/env python32"""Cross-check in-text citations against bibliography entries.34Reads only the files named on the command line. No network access, no writes.56usage: check_citations.py MANUSCRIPT [MANUSCRIPT ...] --bib BIBFILE [BIBFILE ...] [--json]7exit: 0 consistent, 1 problems found, 2 unreadable input8"""9import argparse10import json11import re12import sys1314LATEX_CITE = re.compile(15 r"\\(?:[Cc]ite[a-zA-Z]*|[Pp]arencite|[Tt]extcite|[Aa]utocite|[Ff]ootcite|[Ff]ullcite|nocite)\*?"16 r"(?:\s*\[[^\]]*\]){0,2}\s*\{([^}]*)\}"17)18MD_CITE = re.compile(r"(?<![\w.@/])-?@([A-Za-z0-9_][A-Za-z0-9_:.#$%&+?<>~/-]*)")19BIB_ENTRY = re.compile(r"^\s*@([A-Za-z]+)\s*[{(]\s*([^,\s{}()]+)\s*,", re.MULTILINE)20NON_ENTRIES = {"comment", "string", "preamble"}21MD_CODE_SPAN = re.compile(r"`[^`]*`")222324def read(path):25 try:26 with open(path, "r", encoding="utf-8", errors="replace") as handle:27 return handle.read()28 except OSError as exc:29 print(f"cannot read {path}: {exc.strerror}", file=sys.stderr)30 sys.exit(2)313233def cited_keys(path, text):34 """Yield (key, line_number) for each citation in a manuscript file."""35 is_latex = path.lower().endswith((".tex", ".ltx", ".rnw"))36 in_fence = False37 for number, line in enumerate(text.splitlines(), start=1):38 if is_latex:39 line = re.sub(r"(?<!\\)%.*$", "", line)40 for match in LATEX_CITE.finditer(line):41 for key in match.group(1).split(","):42 key = key.strip()43 if key:44 yield key, number45 else:46 if line.lstrip().startswith(("```", "~~~")):47 in_fence = not in_fence48 continue49 if in_fence:50 continue51 for match in MD_CITE.finditer(MD_CODE_SPAN.sub("", line)):52 yield match.group(1).rstrip(".,;:?"), number535455def bibliography_keys(path, text):56 """Yield (key, line_number) for each entry in a bibliography file."""57 if path.lower().endswith(".json"):58 try:59 data = json.loads(text)60 except ValueError:61 print(f"cannot parse {path} as CSL JSON", file=sys.stderr)62 sys.exit(2)63 for item in data if isinstance(data, list) else []:64 if isinstance(item, dict) and "id" in item:65 yield str(item["id"]), 166 return67 for match in BIB_ENTRY.finditer(text):68 if match.group(1).lower() not in NON_ENTRIES:69 yield match.group(2), text.count("\n", 0, match.start()) + 1707172def main(argv=None):73 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])74 parser.add_argument("manuscripts", nargs="+", help="Markdown or LaTeX files to check")75 parser.add_argument("--bib", nargs="+", required=True, help="BibTeX/BibLaTeX or CSL JSON files")76 parser.add_argument("--json", action="store_true", help="print a JSON report")77 args = parser.parse_args(argv)7879 cited = {}80 for path in args.manuscripts:81 for key, line in cited_keys(path, read(path)):82 cited.setdefault(key, {"file": path, "line": line})83 entries = {}84 for path in args.bib:85 for key, line in bibliography_keys(path, read(path)):86 entries.setdefault(key, {"file": path, "line": line})8788 cite_all = "*" in cited89 cited.pop("*", None)90 missing = sorted(key for key in cited if key not in entries)91 uncited = [] if cite_all else sorted(key for key in entries if key not in cited)92 report = {93 "cited": len(cited),94 "entries": len(entries),95 "missing_entries": [{"key": key, **cited[key]} for key in missing],96 "uncited_entries": [{"key": key, **entries[key]} for key in uncited],97 }9899 if args.json:100 print(json.dumps(report, indent=2))101 else:102 print(f"{len(cited)} distinct keys cited, {len(entries)} bibliography entries")103 print(f"Cited without a bibliography entry ({len(missing)}):")104 for item in report["missing_entries"]:105 print(f" {item['key']} ({item['file']}:{item['line']})")106 print(f"Bibliography entries never cited ({len(uncited)}):")107 for item in report["uncited_entries"]:108 print(f" {item['key']} ({item['file']}:{item['line']})")109 if not missing and not uncited:110 print("Citations and bibliography are consistent.")111 return 1 if missing or uncited else 0112113114if __name__ == "__main__":115 sys.exit(main())116