SPB Git

Repository Public MIT

check_citations.py 4.4 KB 116 lines python

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
Files main
Menu

Theme