summaryrefslogtreecommitdiffstats
path: root/mdit_py_plugins/attrs/parse.py
blob: 4a303530e4c56176fb7be91a824b31c5e1f79496 (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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""Parser for attributes::

    attributes { id = "foo", class = "bar baz",
                key1 = "val1", key2 = "val2" }

Adapted from:
https://github.com/jgm/djot/blob/fae7364b86bfce69bc6d5b5eede1f5196d845fd6/djot/attributes.lua#L1

syntax:

attributes <- '{' whitespace* attribute (whitespace attribute)* whitespace* '}'
attribute <- identifier | class | keyval
identifier <- '#' name
class <- '.' name
name <- (nonspace, nonpunctuation other than ':', '_', '-')+
keyval <- key '=' val
key <- (ASCII_ALPHANUM | ':' | '_' | '-')+
val <- bareval | quotedval
bareval <- (ASCII_ALPHANUM | ':' | '_' | '-')+
quotedval <- '"' ([^"] | '\"') '"'
"""
from __future__ import annotations

from enum import Enum
import re
from typing import Callable


class State(Enum):
    START = 0
    SCANNING = 1
    SCANNING_ID = 2
    SCANNING_CLASS = 3
    SCANNING_KEY = 4
    SCANNING_VALUE = 5
    SCANNING_BARE_VALUE = 6
    SCANNING_QUOTED_VALUE = 7
    SCANNING_COMMENT = 8
    SCANNING_ESCAPED = 9
    DONE = 10


REGEX_SPACE = re.compile(r"\s")
REGEX_SPACE_PUNCTUATION = re.compile(r"[\s!\"#$%&'()*+,./;<=>?@[\]^`{|}~]")
REGEX_KEY_CHARACTERS = re.compile(r"[a-zA-Z\d_:-]")


class TokenState:
    def __init__(self):
        self._tokens = []
        self.start: int = 0

    def set_start(self, start: int) -> None:
        self.start = start

    def append(self, start: int, end: int, ttype: str):
        self._tokens.append((start, end, ttype))

    def compile(self, string: str) -> dict[str, str]:
        """compile the tokens into a dictionary"""
        attributes = {}
        classes = []
        idx = 0
        while idx < len(self._tokens):
            start, end, ttype = self._tokens[idx]
            if ttype == "id":
                attributes["id"] = string[start:end]
            elif ttype == "class":
                classes.append(string[start:end])
            elif ttype == "key":
                key = string[start:end]
                if idx + 1 < len(self._tokens):
                    start, end, ttype = self._tokens[idx + 1]
                    if ttype == "value":
                        if key == "class":
                            classes.append(string[start:end])
                        else:
                            attributes[key] = string[start:end]
                        idx += 1
            idx += 1
        if classes:
            attributes["class"] = " ".join(classes)
        return attributes

    def __str__(self) -> str:
        return str(self._tokens)

    def __repr__(self) -> str:
        return repr(self._tokens)


class ParseError(Exception):
    def __init__(self, msg: str, pos: int) -> None:
        self.pos = pos
        super().__init__(msg + f" at position {pos}")


def parse(string: str) -> tuple[int, dict[str, str]]:
    """Parse attributes from start of string.

    :returns: (length of parsed string, dict of attributes)
    """
    pos = 0
    state: State = State.START
    tokens = TokenState()
    while pos < len(string):
        state = HANDLERS[state](string[pos], pos, tokens)
        if state == State.DONE:
            return pos, tokens.compile(string)
        pos = pos + 1

    return pos, tokens.compile(string)


def handle_start(char: str, pos: int, tokens: TokenState) -> State:

    if char == "{":
        return State.SCANNING
    raise ParseError("Attributes must start with '{'", pos)


def handle_scanning(char: str, pos: int, tokens: TokenState) -> State:

    if char == " " or char == "\t" or char == "\n" or char == "\r":
        return State.SCANNING
    if char == "}":
        return State.DONE
    if char == "#":
        tokens.set_start(pos)
        return State.SCANNING_ID
    if char == "%":
        tokens.set_start(pos)
        return State.SCANNING_COMMENT
    if char == ".":
        tokens.set_start(pos)
        return State.SCANNING_CLASS
    if REGEX_KEY_CHARACTERS.fullmatch(char):
        tokens.set_start(pos)
        return State.SCANNING_KEY

    raise ParseError(f"Unexpected character whilst scanning: {char}", pos)


def handle_scanning_comment(char: str, pos: int, tokens: TokenState) -> State:

    if char == "%":
        return State.SCANNING

    return State.SCANNING_COMMENT


def handle_scanning_id(char: str, pos: int, tokens: TokenState) -> State:

    if not REGEX_SPACE_PUNCTUATION.fullmatch(char):
        return State.SCANNING_ID

    if char == "}":
        if (pos - 1) > tokens.start:
            tokens.append(tokens.start + 1, pos, "id")
        return State.DONE

    if REGEX_SPACE.fullmatch(char):
        if (pos - 1) > tokens.start:
            tokens.append(tokens.start + 1, pos, "id")
        return State.SCANNING

    raise ParseError(f"Unexpected character whilst scanning id: {char}", pos)


def handle_scanning_class(char: str, pos: int, tokens: TokenState) -> State:

    if not REGEX_SPACE_PUNCTUATION.fullmatch(char):
        return State.SCANNING_CLASS

    if char == "}":
        if (pos - 1) > tokens.start:
            tokens.append(tokens.start + 1, pos, "class")
        return State.DONE

    if REGEX_SPACE.fullmatch(char):
        if (pos - 1) > tokens.start:
            tokens.append(tokens.start + 1, pos, "class")
        return State.SCANNING

    raise ParseError(f"Unexpected character whilst scanning class: {char}", pos)


def handle_scanning_key(char: str, pos: int, tokens: TokenState) -> State:

    if char == "=":
        tokens.append(tokens.start, pos, "key")
        return State.SCANNING_VALUE

    if REGEX_KEY_CHARACTERS.fullmatch(char):
        return State.SCANNING_KEY

    raise ParseError(f"Unexpected character whilst scanning key: {char}", pos)


def handle_scanning_value(char: str, pos: int, tokens: TokenState) -> State:

    if char == '"':
        tokens.set_start(pos)
        return State.SCANNING_QUOTED_VALUE

    if REGEX_KEY_CHARACTERS.fullmatch(char):
        tokens.set_start(pos)
        return State.SCANNING_BARE_VALUE

    raise ParseError(f"Unexpected character whilst scanning value: {char}", pos)


def handle_scanning_bare_value(char: str, pos: int, tokens: TokenState) -> State:

    if REGEX_KEY_CHARACTERS.fullmatch(char):
        return State.SCANNING_BARE_VALUE

    if char == "}":
        tokens.append(tokens.start, pos, "value")
        return State.DONE

    if REGEX_SPACE.fullmatch(char):
        tokens.append(tokens.start, pos, "value")
        return State.SCANNING

    raise ParseError(f"Unexpected character whilst scanning bare value: {char}", pos)


def handle_scanning_escaped(char: str, pos: int, tokens: TokenState) -> State:
    return State.SCANNING_QUOTED_VALUE


def handle_scanning_quoted_value(char: str, pos: int, tokens: TokenState) -> State:

    if char == '"':
        tokens.append(tokens.start + 1, pos, "value")
        return State.SCANNING

    if char == "\\":
        return State.SCANNING_ESCAPED

    if char == "{" or char == "}":
        raise ParseError(
            f"Unexpected character whilst scanning quoted value: {char}", pos
        )

    if char == "\n":
        tokens.append(tokens.start + 1, pos, "value")
        return State.SCANNING_QUOTED_VALUE

    return State.SCANNING_QUOTED_VALUE


HANDLERS: dict[State, Callable[[str, int, TokenState], State]] = {
    State.START: handle_start,
    State.SCANNING: handle_scanning,
    State.SCANNING_COMMENT: handle_scanning_comment,
    State.SCANNING_ID: handle_scanning_id,
    State.SCANNING_CLASS: handle_scanning_class,
    State.SCANNING_KEY: handle_scanning_key,
    State.SCANNING_VALUE: handle_scanning_value,
    State.SCANNING_BARE_VALUE: handle_scanning_bare_value,
    State.SCANNING_QUOTED_VALUE: handle_scanning_quoted_value,
    State.SCANNING_ESCAPED: handle_scanning_escaped,
}