summaryrefslogtreecommitdiff
path: root/maint/extract-md-links
blob: a874edf8d43814cd1cba456589a28b3533e56971 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
#!/usr/bin/env python3

"""
Extract the reference link definitions, and uses, from a .md file.

They are extracted *without normalisation* - in particular,
without case folding.  This is contrary to markdown semantics,
but it is desirable if we want to retain the original case.

When run as a program, prints a json document

{
   "used": ["anchor", ...],
   "defined"`: {"anchor": ["target", "title"] }
}

("title" can be null instead)
"""

# Basically all markdown parsers seem to treat undefined [foo]
# link references as literal text, including the [ ].
# I investigated several parsers including pandoc, marked (JS),
# and python3-markdown, and none of them seemed to have a way to
# override this or extract a list of apparently-unreferenced links.
#
# mistune has a hook mechanism, which we can abuse to insert
# instrumentation that spots when link definitions are queried,
# during processing.

import mistune  # type: ignore
from typing import Tuple


class Tracking:
    """
    Data structure which tracks used and defined keys.

    You may access the properties `used` and `defined`;
    `defined` mas each key to `(target, title)`.
    `used` is a map from keys to `True`,

    The keys here are *un*normalised, so they have not been lowercased.
    """

    defined: dict[str, Tuple[str, str]] = {}
    used: dict[str, bool] = {}

    def as_json(self):
        return json.dumps(
            {
                "used": list(self.used.keys()),
                "defined": self.defined,
            }
        )


class TrackingBlockParser(mistune.BlockParser):
    def __init__(self, track):
        self.track = track
        super().__init__()

    def parse_def_link(self, m, state):
        k = m.group(1)
        t = m.group(2)
        title = m.group(3)
        self.track.defined[k] = (t, title)
        return super().parse_def_link(m, state)


class TrackingInlineParser(mistune.InlineParser):
    def __init__(self, track):
        self.track = track
        super().__init__()

    def parse_ref_link(self, m, state):
        k = m.group(2) or m.group(1)
        self.track.used[k] = True
        return super().parse_ref_link(m, state)


def extract_links(md_string):
    """
    Given a markdown file, as a string, returns a `TrackingDict`
    containing information about its ref links.
    """

    track = Tracking()

    # Our construction is reaching into the mistune innards more than ideal.
    # It works with Debian's python3-mistune 3.1.3-1.
    md = mistune.Markdown(
        renderer=None,
        block=TrackingBlockParser(track),
        inline=TrackingInlineParser(track),
    )
    md(md_string)
    return track


if __name__ == "__main__":
    # In theory we ought to be able to load file this as a Python module
    # instead of running it as a script.  But this does not work
    # because the Python module loading machinery insists that the filename
    # must end in .py.  But script names ought not to end in .py.
    #
    # The recipe here
    #    https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
    # does not work with a filename not ending in .py:
    # "importlib.util.spec_from_file_location" returns None.

    import sys
    import json
    import argparse

    parser = argparse.ArgumentParser(prog="extract-md-links")
    parser.add_argument("filename", nargs="?", default="-")
    args = parser.parse_args()

    if args.filename == "-":
        in_file = sys.stdin
    else:
        in_file = open(args.filename, "r")

    text = in_file.read()
    print(extract_links(text).as_json())