blockspec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blockspec/__init__.py +10 -0
- blockspec/__main__.py +4 -0
- blockspec/anchor.py +1250 -0
- blockspec/cli.py +133 -0
- blockspec/document.py +1212 -0
- blockspec/edit.py +234 -0
- blockspec-0.1.0.dist-info/METADATA +137 -0
- blockspec-0.1.0.dist-info/RECORD +16 -0
- blockspec-0.1.0.dist-info/WHEEL +4 -0
- blockspec-0.1.0.dist-info/entry_points.txt +2 -0
- blockspec-0.1.0.dist-info/licenses/LICENSE +87 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +73 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +156 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +121 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/MIT.txt +21 -0
- blockspec-0.1.0.dist-info/licenses/LICENSES/PSF-2.0.txt +277 -0
blockspec/anchor.py
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
2
|
+
"""Resolve blockspec draft 0's anchors and transclusions: `SPEC.md` section 11.
|
|
3
|
+
|
|
4
|
+
Decided by `docs/adjudications/0003-anchors.md` and its addendum of
|
|
5
|
+
2026-10-16, as `0004-anchor-refusals.md` amends them. Standard library
|
|
6
|
+
only, like the reader it builds on (`document.py`), which it is handed
|
|
7
|
+
rather than imports, so that a mutated copy of the reader is the one it
|
|
8
|
+
runs.
|
|
9
|
+
|
|
10
|
+
Where SPEC.md does not decide an input, this module does not guess: it raises
|
|
11
|
+
`Undecided` with the question, and `resolve` reports that record or
|
|
12
|
+
transclusion as `UNDECIDED`, which no conformance case can expect. The
|
|
13
|
+
questions are listed where each is raised.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import html
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import unicodedata
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from types import SimpleNamespace
|
|
25
|
+
|
|
26
|
+
__all__ = ["Undecided", "resolve", "duplicate_record_ids"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Undecided(Exception):
|
|
30
|
+
"""SPEC.md does not decide what this input resolves to."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, question: str) -> None:
|
|
33
|
+
super().__init__(question)
|
|
34
|
+
self.question = question
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Refused(Exception):
|
|
38
|
+
"""A resolution reason (SPEC.md section 11.10)."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, reason: str) -> None:
|
|
41
|
+
super().__init__(reason)
|
|
42
|
+
self.reason = reason
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CONTEXT = 32 # code points of prefix and of suffix (section 11.1)
|
|
46
|
+
LEAF_TYPES = ("p", "h1", "h2", "h3", "h4", "h5", "h6", "code", "table")
|
|
47
|
+
STATUSES = ("open", "accepted", "rejected", "resolved")
|
|
48
|
+
TEXT_BEARING = ("paragraph", "heading", "code_block", "table")
|
|
49
|
+
HEX_DIGITS = {"sha1": 40, "sha256": 64}
|
|
50
|
+
_HEX = re.compile(r"[0-9a-f]+")
|
|
51
|
+
# A ULID: 26 characters of Crockford's base 32, the first at most 7.
|
|
52
|
+
_ULID = re.compile(r"[0-7][0-9A-HJKMNP-TV-Z]{25}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- canonical JSON (section 11.1, 0003 addendum detail 1) ------------------
|
|
56
|
+
|
|
57
|
+
_ESCAPE = {0x22: '\\"', 0x5C: "\\\\", 0x0A: "\\n"}
|
|
58
|
+
_U_ESCAPED = frozenset(
|
|
59
|
+
[*range(0x00, 0x20), *range(0x7F, 0xA0), 0x2028, 0x2029, 0xFEFF, 0xFFFE, 0xFFFF]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical_string(value: str) -> str:
|
|
64
|
+
"""A string in the one form section 11.1 allows."""
|
|
65
|
+
out = ['"']
|
|
66
|
+
for ch in value:
|
|
67
|
+
point = ord(ch)
|
|
68
|
+
if point in _ESCAPE:
|
|
69
|
+
out.append(_ESCAPE[point])
|
|
70
|
+
elif point in _U_ESCAPED:
|
|
71
|
+
out.append(f"\\u{point:04x}")
|
|
72
|
+
else:
|
|
73
|
+
out.append(ch)
|
|
74
|
+
out.append('"')
|
|
75
|
+
return "".join(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Object(list):
|
|
79
|
+
"""A JSON object, as its list of (key, value) pairs: told apart from a
|
|
80
|
+
JSON array, which stays a plain list."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _pairs(text: str):
|
|
84
|
+
"""`text` read as JSON, every object as its list of (key, value) pairs,
|
|
85
|
+
so that key order and a repeated key stay visible. None if it is not
|
|
86
|
+
JSON."""
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(text, object_pairs_hook=_Object)
|
|
89
|
+
except (ValueError, RecursionError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_int(value) -> bool:
|
|
94
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _hex_id(value, object_format: str) -> bool:
|
|
98
|
+
return (
|
|
99
|
+
isinstance(value, str)
|
|
100
|
+
and len(value) == HEX_DIGITS[object_format]
|
|
101
|
+
and _HEX.fullmatch(value) is not None
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Anchor:
|
|
107
|
+
base: str
|
|
108
|
+
type: str
|
|
109
|
+
nth: int
|
|
110
|
+
text: str
|
|
111
|
+
span: str
|
|
112
|
+
prefix: str
|
|
113
|
+
suffix: str
|
|
114
|
+
replace: str | None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_anchor(anchor: Anchor) -> str:
|
|
118
|
+
"""The anchor's one canonical line value."""
|
|
119
|
+
s = canonical_string
|
|
120
|
+
out = (
|
|
121
|
+
f'{{"base": {s(anchor.base)}, "leaf": {{"type": {s(anchor.type)}, '
|
|
122
|
+
f'"nth": {anchor.nth}, "text": {s(anchor.text)}}}, "span": {s(anchor.span)}, '
|
|
123
|
+
f'"prefix": {s(anchor.prefix)}, "suffix": {s(anchor.suffix)}'
|
|
124
|
+
)
|
|
125
|
+
if anchor.replace is not None:
|
|
126
|
+
out += f', "replace": {s(anchor.replace)}'
|
|
127
|
+
return out + "}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_anchor(value: str, object_format: str) -> Anchor | None:
|
|
131
|
+
"""The anchor a line value holds, or None when it is not in the canonical
|
|
132
|
+
form, or holds a value section 11.1 does not allow."""
|
|
133
|
+
pairs = _pairs(value)
|
|
134
|
+
if not isinstance(pairs, _Object):
|
|
135
|
+
return None
|
|
136
|
+
keys = [k for k, _ in pairs]
|
|
137
|
+
if keys not in (
|
|
138
|
+
["base", "leaf", "span", "prefix", "suffix"],
|
|
139
|
+
["base", "leaf", "span", "prefix", "suffix", "replace"],
|
|
140
|
+
):
|
|
141
|
+
return None
|
|
142
|
+
got = dict(pairs)
|
|
143
|
+
leaf = got["leaf"]
|
|
144
|
+
if not isinstance(leaf, _Object) or [k for k, _ in leaf] != ["type", "nth", "text"]:
|
|
145
|
+
return None
|
|
146
|
+
leaf = dict(leaf)
|
|
147
|
+
strings = [got["base"], leaf["type"], leaf["text"], got["span"], got["prefix"], got["suffix"]]
|
|
148
|
+
if "replace" in got:
|
|
149
|
+
strings.append(got["replace"])
|
|
150
|
+
if not all(isinstance(v, str) for v in strings) or not _is_int(leaf["nth"]):
|
|
151
|
+
return None
|
|
152
|
+
if leaf["nth"] < 0 or leaf["type"] not in LEAF_TYPES or not _hex_id(got["base"], object_format):
|
|
153
|
+
return None
|
|
154
|
+
anchor = Anchor(
|
|
155
|
+
got["base"],
|
|
156
|
+
leaf["type"],
|
|
157
|
+
leaf["nth"],
|
|
158
|
+
leaf["text"],
|
|
159
|
+
got["span"],
|
|
160
|
+
got["prefix"],
|
|
161
|
+
got["suffix"],
|
|
162
|
+
got.get("replace"),
|
|
163
|
+
)
|
|
164
|
+
return anchor if write_anchor(anchor) == value else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def read_string(value: str) -> str | None:
|
|
168
|
+
"""A canonical JSON string's text, or None."""
|
|
169
|
+
got = _pairs(value)
|
|
170
|
+
if not isinstance(got, str) or canonical_string(got) != value:
|
|
171
|
+
return None
|
|
172
|
+
return got
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- records (section 11.1) --------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class Record:
|
|
180
|
+
"""A record whose frontmatter is in the grammar. `anchor` is None for a
|
|
181
|
+
reply, and `reply_to` None for a comment or a suggestion."""
|
|
182
|
+
|
|
183
|
+
kind: str
|
|
184
|
+
status: str | None
|
|
185
|
+
anchor: Anchor | None
|
|
186
|
+
reply_to: str | None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _frontmatter_lines(doc, lines: list[str]) -> list[str] | None:
|
|
190
|
+
if doc.frontmatter is None:
|
|
191
|
+
return None
|
|
192
|
+
first, last = doc.frontmatter
|
|
193
|
+
return lines[first : last - 1]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def read_record(lines: list[str] | None, object_format: str) -> Record | None:
|
|
197
|
+
"""The record a frontmatter's lines hold, or None when they break the
|
|
198
|
+
grammar (section 11.1, 0003 addendum detail 1)."""
|
|
199
|
+
if lines is None:
|
|
200
|
+
return None
|
|
201
|
+
if len(lines) == 3 and lines[0].startswith("anchor: "):
|
|
202
|
+
anchor = read_anchor(lines[0][len("anchor: ") :], object_format)
|
|
203
|
+
kind = {"kind: comment": "comment", "kind: suggestion": "suggestion"}.get(lines[1])
|
|
204
|
+
status = lines[2][len("status: ") :] if lines[2].startswith("status: ") else None
|
|
205
|
+
if anchor is None or kind is None or status not in STATUSES:
|
|
206
|
+
return None
|
|
207
|
+
if (anchor.replace is not None) != (kind == "suggestion"):
|
|
208
|
+
return None
|
|
209
|
+
return Record(kind, status, anchor, None)
|
|
210
|
+
if len(lines) == 2 and lines[0].startswith("reply-to: ") and lines[1] == "kind: comment":
|
|
211
|
+
parent = read_string(lines[0][len("reply-to: ") :])
|
|
212
|
+
if parent is None or not _ULID.fullmatch(parent):
|
|
213
|
+
return None
|
|
214
|
+
return Record("comment", None, None, parent)
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --- snapshots and their leaves (section 11.2) ------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class Leaf:
|
|
223
|
+
index: int # among the snapshot's text-bearing leaves
|
|
224
|
+
node: object # the reader's node
|
|
225
|
+
type: str # the record's leaf.type: p, h1 to h6, code or table
|
|
226
|
+
text: str # section 11's text: the content, NFC, a table as one string
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def key(self) -> tuple[str, str]:
|
|
230
|
+
return (self.type, self.text)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def written_key(self) -> str:
|
|
234
|
+
return f"{self.type}\x1f{self.text}"
|
|
235
|
+
|
|
236
|
+
def ref(self) -> dict:
|
|
237
|
+
return {"node": list(self.node.path), "type": self.node.type}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def table_text(rows: list[list[str]]) -> str:
|
|
241
|
+
"""A table as one string (section 11.2, 0003 addendum detail 3)."""
|
|
242
|
+
text = "\n".join(" | ".join(cell.replace("|", "\\|") for cell in row) for row in rows)
|
|
243
|
+
return unicodedata.normalize("NFC", text)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _heading_level(raw_line: str) -> int:
|
|
247
|
+
# Container prefixes are `>`, list markers and spaces, so the first `#`
|
|
248
|
+
# opens the heading's run.
|
|
249
|
+
at = raw_line.index("#")
|
|
250
|
+
level = 0
|
|
251
|
+
while at + level < len(raw_line) and raw_line[at + level] == "#":
|
|
252
|
+
level += 1
|
|
253
|
+
return level
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Snapshot:
|
|
257
|
+
"""One text of a document, read, with its text-bearing leaves."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, reader, text: str) -> None:
|
|
260
|
+
self.text = text
|
|
261
|
+
self.doc = reader.parse(text)
|
|
262
|
+
self.lines = reader.split_lines(text)
|
|
263
|
+
self.leaves: list[Leaf] = []
|
|
264
|
+
for node in self.doc.nodes:
|
|
265
|
+
if node.type not in TEXT_BEARING:
|
|
266
|
+
continue
|
|
267
|
+
if node.type == "paragraph":
|
|
268
|
+
kind, text_ = "p", node.content()
|
|
269
|
+
elif node.type == "heading":
|
|
270
|
+
kind, text_ = f"h{_heading_level(self.lines[node.start - 1])}", node.content()
|
|
271
|
+
elif node.type == "code_block":
|
|
272
|
+
kind, text_ = "code", node.content()
|
|
273
|
+
else:
|
|
274
|
+
kind, text_ = "table", None
|
|
275
|
+
if kind == "table":
|
|
276
|
+
text_ = table_text(node.content())
|
|
277
|
+
else:
|
|
278
|
+
text_ = unicodedata.normalize("NFC", text_)
|
|
279
|
+
self.leaves.append(Leaf(len(self.leaves), node, kind, text_))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _read(reader, text: str) -> Snapshot:
|
|
283
|
+
"""`text` as a snapshot, or `not-in-profile`."""
|
|
284
|
+
try:
|
|
285
|
+
return Snapshot(reader, text)
|
|
286
|
+
except reader.Refusal as refusal:
|
|
287
|
+
raise _Refused("not-in-profile") from refusal
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# --- similarity and alignment (section 11.2, 0003 addendum detail 5) -------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def lcs_length(a: str, b: str) -> int:
|
|
294
|
+
if len(a) < len(b):
|
|
295
|
+
a, b = b, a
|
|
296
|
+
previous = [0] * (len(b) + 1)
|
|
297
|
+
for x in a:
|
|
298
|
+
current = [0]
|
|
299
|
+
for j, y in enumerate(b):
|
|
300
|
+
current.append(previous[j] + 1 if x == y else max(previous[j + 1], current[j]))
|
|
301
|
+
previous = current
|
|
302
|
+
return previous[-1]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def lcs_alignment(a, b) -> dict[int, int]:
|
|
306
|
+
"""Section 11.2's longest common subsequence alignment: position in `a`
|
|
307
|
+
to the position in `b` it is paired with."""
|
|
308
|
+
m, n = len(a), len(b)
|
|
309
|
+
suffix = [[0] * (n + 1) for _ in range(m + 1)]
|
|
310
|
+
for i in range(m - 1, -1, -1):
|
|
311
|
+
row, below = suffix[i], suffix[i + 1]
|
|
312
|
+
for j in range(n - 1, -1, -1):
|
|
313
|
+
row[j] = below[j + 1] + 1 if a[i] == b[j] else max(below[j], row[j + 1])
|
|
314
|
+
paired = {}
|
|
315
|
+
i = j = 0
|
|
316
|
+
while i < m and j < n:
|
|
317
|
+
if a[i] == b[j]:
|
|
318
|
+
paired[i] = j
|
|
319
|
+
i += 1
|
|
320
|
+
j += 1
|
|
321
|
+
elif suffix[i + 1][j] == suffix[i][j]:
|
|
322
|
+
i += 1
|
|
323
|
+
else:
|
|
324
|
+
j += 1
|
|
325
|
+
return paired
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def similarity(a: Leaf, b: Leaf) -> tuple[int, int]:
|
|
329
|
+
"""`(L, T)`: the similarity is `2·L/T`, kept as integers."""
|
|
330
|
+
return lcs_length(a.written_key, b.written_key), len(a.written_key) + len(b.written_key)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_SPACE = re.compile(r"[\t\n\r ]+")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def words(leaf: Leaf) -> list[str]:
|
|
337
|
+
"""A leaf's words (0004): its text split at runs of tab, LF, CR and
|
|
338
|
+
space."""
|
|
339
|
+
return [w for w in _SPACE.split(leaf.text) if w]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def word_similarity(a: Leaf, b: Leaf) -> tuple[int, int]:
|
|
343
|
+
"""`(L, T)` over words: the word similarity is `2·L/T` (0004), 0 when
|
|
344
|
+
either leaf has no words."""
|
|
345
|
+
wa, wb = words(a), words(b)
|
|
346
|
+
if not wa or not wb:
|
|
347
|
+
return 0, 2
|
|
348
|
+
return lcs_length(wa, wb), len(wa) + len(wb)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def at_least_half(sim: tuple[int, int]) -> bool:
|
|
352
|
+
return 4 * sim[0] >= sim[1]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def exceeds_by_margin(one: tuple[int, int], other: tuple[int, int]) -> bool:
|
|
356
|
+
"""Whether `one` exceeds `other` by at least 0.15."""
|
|
357
|
+
(l1, t1), (l2, t2) = one, other
|
|
358
|
+
return 40 * l1 * t2 - 40 * l2 * t1 >= 3 * t1 * t2
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --- correspondence (section 11.3) ------------------------------------------
|
|
362
|
+
|
|
363
|
+
EXACT, MOVED, EDITED = "EXACT", "MOVED", "EDITED"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def leftmost_chain(pairs: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
367
|
+
"""Of the longest subsequences of `pairs` (in base order) whose current
|
|
368
|
+
positions increase, the one whose current positions are lexicographically
|
|
369
|
+
smallest (0003 addendum detail 4)."""
|
|
370
|
+
n = len(pairs)
|
|
371
|
+
longest = [1] * n # the longest increasing run that starts at each pair
|
|
372
|
+
for k in range(n - 1, -1, -1):
|
|
373
|
+
for later in range(k + 1, n):
|
|
374
|
+
if pairs[later][1] > pairs[k][1]:
|
|
375
|
+
longest[k] = max(longest[k], longest[later] + 1)
|
|
376
|
+
chain: list[tuple[int, int]] = []
|
|
377
|
+
start, last = 0, -1
|
|
378
|
+
for need in range(max(longest, default=0), 0, -1):
|
|
379
|
+
options = [k for k in range(start, n) if longest[k] == need and pairs[k][1] > last]
|
|
380
|
+
k = min(options, key=lambda k: pairs[k][1])
|
|
381
|
+
chain.append(pairs[k])
|
|
382
|
+
start, last = k + 1, pairs[k][1]
|
|
383
|
+
return chain
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class Correspondence:
|
|
387
|
+
"""Section 11.3 between a base snapshot's leaves and a current one's."""
|
|
388
|
+
|
|
389
|
+
def __init__(self, base: list[Leaf], current: list[Leaf]) -> None:
|
|
390
|
+
self.base, self.current = base, current
|
|
391
|
+
self._similarity: dict[tuple[int, int], tuple[int, int]] = {}
|
|
392
|
+
self._words: dict[tuple[int, int], tuple[int, int]] = {}
|
|
393
|
+
base_count = _counts(base)
|
|
394
|
+
current_count = _counts(current)
|
|
395
|
+
unique = {leaf.key: leaf.index for leaf in current if current_count[leaf.key] == 1}
|
|
396
|
+
pairs = [
|
|
397
|
+
(leaf.index, unique[leaf.key])
|
|
398
|
+
for leaf in base
|
|
399
|
+
if base_count[leaf.key] == 1 and leaf.key in unique
|
|
400
|
+
]
|
|
401
|
+
self.chain = leftmost_chain(pairs)
|
|
402
|
+
self.pair: dict[int, tuple[int, str]] = {b: (c, EXACT) for b, c in self.chain}
|
|
403
|
+
on_chain = set(self.chain)
|
|
404
|
+
for b, c in pairs:
|
|
405
|
+
if (b, c) not in on_chain:
|
|
406
|
+
self.pair[b] = (c, MOVED)
|
|
407
|
+
moved_current = {c for c, kind in self.pair.values() if kind == MOVED}
|
|
408
|
+
bounds = [(-1, -1), *self.chain, (len(base), len(current))]
|
|
409
|
+
for (b0, c0), (b1, c1) in zip(bounds, bounds[1:], strict=False):
|
|
410
|
+
gap_base = [b for b in range(b0 + 1, b1) if b not in self.pair]
|
|
411
|
+
gap_current = [c for c in range(c0 + 1, c1) if c not in moved_current]
|
|
412
|
+
keys_b = [base[b].key for b in gap_base]
|
|
413
|
+
keys_c = [current[c].key for c in gap_current]
|
|
414
|
+
for i, j in lcs_alignment(keys_b, keys_c).items():
|
|
415
|
+
self.pair[gap_base[i]] = (gap_current[j], EXACT)
|
|
416
|
+
paired_current = {c for c, _ in self.pair.values()}
|
|
417
|
+
left_base = [b for b in gap_base if b not in self.pair]
|
|
418
|
+
left_current = [c for c in gap_current if c not in paired_current]
|
|
419
|
+
for b in left_base:
|
|
420
|
+
for c in left_current:
|
|
421
|
+
if self._edited(b, c, left_base, left_current):
|
|
422
|
+
self.pair[b] = (c, EDITED)
|
|
423
|
+
self.matched = {c for c, _ in self.pair.values()}
|
|
424
|
+
|
|
425
|
+
def similarity(self, b: int, c: int) -> tuple[int, int]:
|
|
426
|
+
if (b, c) not in self._similarity:
|
|
427
|
+
self._similarity[(b, c)] = similarity(self.base[b], self.current[c])
|
|
428
|
+
return self._similarity[(b, c)]
|
|
429
|
+
|
|
430
|
+
def word_similarity(self, b: int, c: int) -> tuple[int, int]:
|
|
431
|
+
if (b, c) not in self._words:
|
|
432
|
+
self._words[(b, c)] = word_similarity(self.base[b], self.current[c])
|
|
433
|
+
return self._words[(b, c)]
|
|
434
|
+
|
|
435
|
+
def _edited(self, b: int, c: int, left_base: list[int], left_current: list[int]) -> bool:
|
|
436
|
+
sim = self.similarity(b, c)
|
|
437
|
+
if not at_least_half(sim):
|
|
438
|
+
return False
|
|
439
|
+
if not all(exceeds_by_margin(sim, self.similarity(b, o)) for o in left_current if o != c):
|
|
440
|
+
return False
|
|
441
|
+
return all(exceeds_by_margin(sim, self.similarity(o, c)) for o in left_base if o != b)
|
|
442
|
+
|
|
443
|
+
def unmatched(self) -> list[int]:
|
|
444
|
+
return [c for c in range(len(self.current)) if c not in self.matched]
|
|
445
|
+
|
|
446
|
+
def gap(self, k: int) -> tuple[range, range]:
|
|
447
|
+
"""`k`'s gap (section 11.4): the base and current positions strictly
|
|
448
|
+
between the nearest chain pairs before and after it, in base order."""
|
|
449
|
+
before = max(((b, c) for b, c in self.chain if b < k), default=(-1, -1))
|
|
450
|
+
after = min(((b, c) for b, c in self.chain if b > k), default=None)
|
|
451
|
+
if after is None:
|
|
452
|
+
after = (len(self.base), len(self.current))
|
|
453
|
+
return range(before[0] + 1, after[0]), range(before[1] + 1, after[1])
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _counts(leaves: list[Leaf]) -> dict[tuple[str, str], int]:
|
|
457
|
+
counts: dict[tuple[str, str], int] = {}
|
|
458
|
+
for leaf in leaves:
|
|
459
|
+
counts[leaf.key] = counts.get(leaf.key, 0) + 1
|
|
460
|
+
return counts
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def check_pair(corr: Correspondence, k: int) -> tuple[int, str]:
|
|
464
|
+
"""Section 11.4 for base leaf `k`: its pair, or `_Refused`, or None as the
|
|
465
|
+
pair when the leaf is ORPHANED."""
|
|
466
|
+
leaf = corr.base[k]
|
|
467
|
+
copies = [c.index for c in corr.current if c.key == leaf.key]
|
|
468
|
+
if k not in corr.pair:
|
|
469
|
+
if copies:
|
|
470
|
+
raise _Refused("unaccounted-copy")
|
|
471
|
+
return None, "ORPHANED"
|
|
472
|
+
c, kind = corr.pair[k]
|
|
473
|
+
unmatched = corr.unmatched()
|
|
474
|
+
gap_base, gap_current = corr.gap(k)
|
|
475
|
+
|
|
476
|
+
def near() -> bool:
|
|
477
|
+
return any(at_least_half(corr.similarity(k, u)) for u in unmatched)
|
|
478
|
+
|
|
479
|
+
if kind == EXACT:
|
|
480
|
+
base_copies = [b.index for b in corr.base if b.key == leaf.key]
|
|
481
|
+
if len(base_copies) > 1 or len(copies) > 1:
|
|
482
|
+
in_base = sum(1 for b in base_copies if b in gap_base)
|
|
483
|
+
in_current = sum(1 for d in copies if d in gap_current)
|
|
484
|
+
if in_base != in_current:
|
|
485
|
+
raise _Refused("repeat")
|
|
486
|
+
if any(
|
|
487
|
+
corr.current[u].key != leaf.key and at_least_half(corr.word_similarity(k, u))
|
|
488
|
+
for u in unmatched
|
|
489
|
+
):
|
|
490
|
+
raise _Refused("near-copy")
|
|
491
|
+
elif kind == MOVED:
|
|
492
|
+
if near():
|
|
493
|
+
raise _Refused("near-copy")
|
|
494
|
+
if any(u in gap_current for u in unmatched):
|
|
495
|
+
raise _Refused("refilled-gap")
|
|
496
|
+
else:
|
|
497
|
+
if any(u in copies for u in unmatched):
|
|
498
|
+
raise _Refused("exact-copy")
|
|
499
|
+
if any(u in gap_current for u in unmatched):
|
|
500
|
+
raise _Refused("crowded-gap")
|
|
501
|
+
if near():
|
|
502
|
+
raise _Refused("near-copy")
|
|
503
|
+
return c, kind
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# --- the span (section 11.5) ------------------------------------------------
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def occurrences(text: str, part: str) -> list[int]:
|
|
510
|
+
"""Every start of `part` in `text`, overlapping ones included."""
|
|
511
|
+
found, at = [], text.find(part)
|
|
512
|
+
while at != -1:
|
|
513
|
+
found.append(at)
|
|
514
|
+
at = text.find(part, at + 1)
|
|
515
|
+
return found
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def span_in_base(anchor: Anchor) -> int:
|
|
519
|
+
"""Where the span starts in `leaf.text`, or `record-inconsistent` when the
|
|
520
|
+
record's context breaks section 11.1's consistency rules."""
|
|
521
|
+
text = anchor.text
|
|
522
|
+
if not anchor.span:
|
|
523
|
+
raise _Refused("record-inconsistent")
|
|
524
|
+
found = occurrences(text, anchor.prefix + anchor.span + anchor.suffix)
|
|
525
|
+
if len(found) != 1:
|
|
526
|
+
raise _Refused("record-inconsistent")
|
|
527
|
+
at = found[0]
|
|
528
|
+
end = at + len(anchor.prefix) + len(anchor.span) + len(anchor.suffix)
|
|
529
|
+
if len(anchor.prefix) < CONTEXT and at > 0:
|
|
530
|
+
raise _Refused("record-inconsistent")
|
|
531
|
+
if len(anchor.suffix) < CONTEXT and end < len(text):
|
|
532
|
+
raise _Refused("record-inconsistent")
|
|
533
|
+
if len(anchor.prefix) > CONTEXT or len(anchor.suffix) > CONTEXT:
|
|
534
|
+
raise _Refused("record-inconsistent") # second addendum, item 1
|
|
535
|
+
return at + len(anchor.prefix)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _spaced(text: str) -> str:
|
|
539
|
+
"""`text` with each run of tab, LF, CR and space as one space (0004)."""
|
|
540
|
+
return _SPACE.sub(" ", text)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _neighbour(text: str, at: int) -> str:
|
|
544
|
+
"""The code point at `at` as 0004 compares a span's neighbours: "" at an
|
|
545
|
+
end of the text, " " for tab, LF, CR or space, else the code point."""
|
|
546
|
+
if at < 0 or at >= len(text):
|
|
547
|
+
return ""
|
|
548
|
+
return " " if text[at] in "\t\n\r " else text[at]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def find_span(
|
|
552
|
+
anchor: Anchor, start: int, base: Leaf, current: Leaf, both_sides: bool = False
|
|
553
|
+
) -> tuple[str, list | None]:
|
|
554
|
+
"""Section 11.5 for an EDITED pair: `EDITED` with the span, `DETACHED`,
|
|
555
|
+
or `_Refused("span-ambiguous")`. A suggestion needs `both_sides`, and a
|
|
556
|
+
pair where either leaf is code or a table both sides compared exactly
|
|
557
|
+
(0004)."""
|
|
558
|
+
text = current.text
|
|
559
|
+
exact = base.type in ("code", "table") or current.type in ("code", "table")
|
|
560
|
+
spaced = (lambda t: t) if exact else _spaced
|
|
561
|
+
span, prefix, suffix = anchor.span, anchor.prefix, anchor.suffix
|
|
562
|
+
end = start + len(span)
|
|
563
|
+
before_was, after_was = _neighbour(base.text, start - 1), _neighbour(base.text, end)
|
|
564
|
+
hits = []
|
|
565
|
+
for at in occurrences(text, span):
|
|
566
|
+
if _neighbour(text, at - 1) != before_was or _neighbour(text, at + len(span)) != after_was:
|
|
567
|
+
continue
|
|
568
|
+
before, after = spaced(text[:at]), spaced(text[at + len(span) :])
|
|
569
|
+
if len(prefix) < CONTEXT:
|
|
570
|
+
prefix_holds = before == spaced(prefix)
|
|
571
|
+
else:
|
|
572
|
+
prefix_holds = before.endswith(spaced(prefix))
|
|
573
|
+
if len(suffix) < CONTEXT:
|
|
574
|
+
suffix_holds = after == spaced(suffix)
|
|
575
|
+
else:
|
|
576
|
+
suffix_holds = after.startswith(spaced(suffix))
|
|
577
|
+
if both_sides or exact or not (prefix or suffix):
|
|
578
|
+
holds = prefix_holds and suffix_holds
|
|
579
|
+
else:
|
|
580
|
+
holds = (prefix_holds and prefix != "") or (suffix_holds and suffix != "")
|
|
581
|
+
if holds:
|
|
582
|
+
hits.append(at)
|
|
583
|
+
if not hits:
|
|
584
|
+
return "DETACHED", None
|
|
585
|
+
if len(hits) > 1:
|
|
586
|
+
raise _Refused("span-ambiguous")
|
|
587
|
+
hit = hits[0]
|
|
588
|
+
paired = lcs_alignment(base.text, text)
|
|
589
|
+
if any(paired.get(start + d) != hit + d for d in range(len(span))):
|
|
590
|
+
return "DETACHED", None
|
|
591
|
+
return "EDITED", [hit, hit + len(span)]
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --- write-back (section 11.7) ----------------------------------------------
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class _Unmappable(Exception):
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _raw_lines(text: str) -> list[tuple[int, int, int]]:
|
|
602
|
+
"""Each line as (start, end of its text, end of its line ending), as the
|
|
603
|
+
reader splits lines: a final empty line is no line."""
|
|
604
|
+
out = []
|
|
605
|
+
for match in re.finditer(r"[^\r\n]*(\r\n|\r|\n|$)", text):
|
|
606
|
+
if match.start() == len(text) and (not out or out[-1][2] == len(text)):
|
|
607
|
+
break
|
|
608
|
+
end = match.end(0) - len(match.group(1))
|
|
609
|
+
out.append((match.start(), end, match.end(0)))
|
|
610
|
+
return out
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _content_positions(snapshot: Snapshot, leaf: Leaf) -> tuple[str, list]:
|
|
614
|
+
"""The leaf's content as the reader gives it, before NFC, and for each of
|
|
615
|
+
its code points the raw (start, end) it comes from, or None for a code
|
|
616
|
+
point no single raw character gives."""
|
|
617
|
+
node = leaf.node
|
|
618
|
+
lines = _raw_lines(snapshot.text)
|
|
619
|
+
content = node.content()
|
|
620
|
+
# The reader gives U+FFFD for each U+0000 (SPEC.md section 3): one raw
|
|
621
|
+
# character still, so positions into this text are positions into the raw.
|
|
622
|
+
source = snapshot.text.replace("\x00", "\ufffd")
|
|
623
|
+
positions: list = []
|
|
624
|
+
if node.type == "heading":
|
|
625
|
+
start, end, _ = lines[node.start - 1]
|
|
626
|
+
raw = source[start:end]
|
|
627
|
+
at = raw.index("#")
|
|
628
|
+
while at < len(raw) and raw[at] == "#":
|
|
629
|
+
at += 1
|
|
630
|
+
while at < len(raw) and raw[at] in " \t":
|
|
631
|
+
at += 1
|
|
632
|
+
if raw[at : at + len(content)] != content:
|
|
633
|
+
raise _Unmappable()
|
|
634
|
+
return content, [(start + at + k, start + at + k + 1) for k in range(len(content))]
|
|
635
|
+
for number, text in node.lines:
|
|
636
|
+
start, end, after = lines[number - 1]
|
|
637
|
+
raw = source[start:end]
|
|
638
|
+
synthetic = None
|
|
639
|
+
for k in range(4):
|
|
640
|
+
tail = text[k:]
|
|
641
|
+
if text[:k] != " " * k or not raw.endswith(tail):
|
|
642
|
+
continue
|
|
643
|
+
if k and raw[len(raw) - len(tail) - 1] != "\t":
|
|
644
|
+
continue
|
|
645
|
+
synthetic = k
|
|
646
|
+
break
|
|
647
|
+
if synthetic is None:
|
|
648
|
+
raise _Unmappable()
|
|
649
|
+
offset = start + len(raw) - (len(text) - synthetic)
|
|
650
|
+
positions += [None] * synthetic
|
|
651
|
+
positions += [(offset + k, offset + k + 1) for k in range(len(text) - synthetic)]
|
|
652
|
+
positions.append((end, after) if after > end else None)
|
|
653
|
+
if node.type == "paragraph":
|
|
654
|
+
# Lines are joined, not ended, by \n, and final spaces are dropped.
|
|
655
|
+
positions = positions[: len(content)]
|
|
656
|
+
if len(positions) != len(content):
|
|
657
|
+
raise _Unmappable()
|
|
658
|
+
return content, positions
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _map_span(content: str, text: str, start: int, end: int) -> tuple[int, int]:
|
|
662
|
+
"""Section 11.7 step 1: positions `a` and `b` in the content before NFC."""
|
|
663
|
+
nfc = unicodedata.normalize
|
|
664
|
+
for a in range(len(content) + 1):
|
|
665
|
+
if nfc("NFC", content[:a]) != text[:start]:
|
|
666
|
+
continue
|
|
667
|
+
for b in range(a, len(content) + 1):
|
|
668
|
+
if nfc("NFC", content[a:b]) == text[start:end]:
|
|
669
|
+
return a, b
|
|
670
|
+
break
|
|
671
|
+
raise _Unmappable()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _container_prefix(leaf: Leaf, raw_line: str, number: int) -> str:
|
|
675
|
+
"""The container prefixes the parse gives line `number`, or
|
|
676
|
+
`_Unmappable` where a new line cannot repeat them (section 11.7, *A line
|
|
677
|
+
break that cannot be written back*)."""
|
|
678
|
+
chain = []
|
|
679
|
+
node = leaf.node.parent
|
|
680
|
+
while node is not None and node.type != "document":
|
|
681
|
+
chain.append(node)
|
|
682
|
+
node = node.parent
|
|
683
|
+
at = 0
|
|
684
|
+
for node in reversed(chain):
|
|
685
|
+
if node.type == "block_quote":
|
|
686
|
+
j = at
|
|
687
|
+
while j < at + 3 and j < len(raw_line) and raw_line[j] == " ":
|
|
688
|
+
j += 1
|
|
689
|
+
if j >= len(raw_line) or raw_line[j] != ">":
|
|
690
|
+
raise _Unmappable() # a lazy line, which has no prefix to repeat
|
|
691
|
+
j += 1
|
|
692
|
+
if j < len(raw_line) and raw_line[j] == "\t":
|
|
693
|
+
raise _Unmappable() # a tab in the container prefix
|
|
694
|
+
if j < len(raw_line) and raw_line[j] == " ":
|
|
695
|
+
j += 1
|
|
696
|
+
at = j
|
|
697
|
+
elif node.type == "list_item":
|
|
698
|
+
if node.start == number:
|
|
699
|
+
raise _Unmappable() # the item's first line: its prefix is the marker
|
|
700
|
+
width = node.list_data["offset"] + node.list_data["padding"]
|
|
701
|
+
part = raw_line[at : at + width]
|
|
702
|
+
if "\t" in part:
|
|
703
|
+
raise _Unmappable() # a tab in the container prefix
|
|
704
|
+
if part != " " * width:
|
|
705
|
+
raise _Unmappable() # a lazy line, which has no prefix to repeat
|
|
706
|
+
at += width
|
|
707
|
+
return raw_line[:at]
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def write_back(snapshot: Snapshot, leaf: Leaf, span: list[int], replace: str) -> str:
|
|
711
|
+
"""The document's text with `replace` written over the span (section
|
|
712
|
+
11.7 steps 1 to 3), or `_Unmappable`."""
|
|
713
|
+
if leaf.type == "table":
|
|
714
|
+
raise _Unmappable() # 0005 item 7
|
|
715
|
+
content, positions = _content_positions(snapshot, leaf)
|
|
716
|
+
a, b = _map_span(content, leaf.text, *span)
|
|
717
|
+
if leaf.node.type == "code_block" and b == len(content):
|
|
718
|
+
raise _Unmappable() # the block's final line ending (third addendum, item 4)
|
|
719
|
+
if any(p is None for p in positions[a:b]) or a == b:
|
|
720
|
+
raise _Unmappable()
|
|
721
|
+
if b < len(positions) and positions[b] is None:
|
|
722
|
+
raise _Unmappable()
|
|
723
|
+
first = positions[a][0]
|
|
724
|
+
last = positions[b][0] if b < len(positions) else positions[b - 1][1]
|
|
725
|
+
if "\n" in replace:
|
|
726
|
+
lines = _raw_lines(snapshot.text)
|
|
727
|
+
line_of = {}
|
|
728
|
+
for number, (start, _end, after) in enumerate(lines, 1):
|
|
729
|
+
for p in range(start, after):
|
|
730
|
+
line_of[p] = number
|
|
731
|
+
# the line where the span starts (0005 item 7)
|
|
732
|
+
number = line_of[first]
|
|
733
|
+
start, end, after = lines[number - 1]
|
|
734
|
+
ending = snapshot.text[end:after]
|
|
735
|
+
prefix = _container_prefix(leaf, snapshot.text[start:end], number)
|
|
736
|
+
if not ending:
|
|
737
|
+
raise _Unmappable() # the file's last line, with no line ending to copy
|
|
738
|
+
replace = replace.replace("\n", ending + prefix)
|
|
739
|
+
return snapshot.text[:first] + replace + snapshot.text[last:]
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# --- records: resolution (sections 11.4 to 11.8) -----------------------------
|
|
743
|
+
|
|
744
|
+
PAIRED_STATUS = {EXACT: "RESOLVED", MOVED: "MOVED"}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _raw_frontmatter(lines: list[str]) -> list[str] | None:
|
|
748
|
+
"""The lines between a frontmatter's `---` lines, found by section 5's
|
|
749
|
+
rule on the raw lines, so that a record whose body the profile refuses
|
|
750
|
+
still shows whether it is a suggestion."""
|
|
751
|
+
if len(lines) > 1 and lines[0] == "---" and lines[1].strip(" \t"):
|
|
752
|
+
for at in range(1, len(lines)):
|
|
753
|
+
if lines[at].rstrip(" \t") == "---":
|
|
754
|
+
return lines[1:at]
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _record_location(path: str) -> str:
|
|
759
|
+
"""The document a record path belongs to, or `record-inconsistent` for an
|
|
760
|
+
entry of a notes directory that is not a record (section 11.1)."""
|
|
761
|
+
parts = path.split("/")
|
|
762
|
+
notes = [i for i, part in enumerate(parts[:-1]) if part.endswith(".notes")]
|
|
763
|
+
if not notes:
|
|
764
|
+
raise Undecided("a path that is in no notes directory")
|
|
765
|
+
if notes[-1] != len(parts) - 2:
|
|
766
|
+
raise _Refused("record-inconsistent") # in a subfolder of a notes directory
|
|
767
|
+
name = parts[-1]
|
|
768
|
+
if not name.endswith(".md") or not _ULID.fullmatch(name[: -len(".md")]):
|
|
769
|
+
raise _Refused("record-inconsistent")
|
|
770
|
+
return "/".join(parts[:-1])[: -len(".notes")]
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def resolve_record(reader, repo: Mapping, path: str) -> dict:
|
|
774
|
+
"""One record's outcome. Input problems are reported in section 11.10's
|
|
775
|
+
order, *Which reason is reported*: the first that applies decides."""
|
|
776
|
+
tree = repo["tree"]
|
|
777
|
+
outcome = {"status": None, "leaf": None, "span": None, "suggestion": None}
|
|
778
|
+
if path not in tree:
|
|
779
|
+
raise Undecided("a record that is not in the tree")
|
|
780
|
+
fm = _raw_frontmatter(reader.split_lines(tree[path]))
|
|
781
|
+
if fm is not None and "kind: suggestion" in fm:
|
|
782
|
+
outcome["suggestion"] = {"gate": "stale", "reason": None}
|
|
783
|
+
try:
|
|
784
|
+
document = _record_location(path)
|
|
785
|
+
record = _read_record(reader, tree[path], fm, repo["object_format"])
|
|
786
|
+
if record.anchor is None:
|
|
787
|
+
# A reply is a record: steps 2 and 3 of the order apply to it,
|
|
788
|
+
# and steps 4 to 6 read an anchor (third addendum, item 2).
|
|
789
|
+
if document not in tree:
|
|
790
|
+
raise _Refused("document-missing")
|
|
791
|
+
_read(reader, tree[document])
|
|
792
|
+
directory = path.rpartition("/")[0]
|
|
793
|
+
found = f"{directory}/{record.reply_to}.md" in tree
|
|
794
|
+
outcome["status"] = "REPLY" if found else "#REF!(reply-to)"
|
|
795
|
+
return outcome
|
|
796
|
+
anchor = record.anchor
|
|
797
|
+
start = span_in_base(anchor) # step 1: the checks that read only the record
|
|
798
|
+
current, base, k = _record_inputs(reader, repo, document, anchor)
|
|
799
|
+
except _Refused as refused:
|
|
800
|
+
outcome["status"] = f"REFUSED({refused.reason})"
|
|
801
|
+
return outcome
|
|
802
|
+
corr = Correspondence(base.leaves, current.leaves)
|
|
803
|
+
try:
|
|
804
|
+
c, kind = check_pair(corr, k)
|
|
805
|
+
if c is None:
|
|
806
|
+
outcome["status"] = "ORPHANED"
|
|
807
|
+
return outcome
|
|
808
|
+
paired = current.leaves[c]
|
|
809
|
+
if kind == EDITED:
|
|
810
|
+
status, span = find_span(
|
|
811
|
+
anchor, start, base.leaves[k], paired, both_sides=record.kind == "suggestion"
|
|
812
|
+
)
|
|
813
|
+
else:
|
|
814
|
+
status, span = PAIRED_STATUS[kind], [start, start + len(anchor.span)]
|
|
815
|
+
except _Refused as refused:
|
|
816
|
+
outcome["status"] = f"REFUSED({refused.reason})"
|
|
817
|
+
return outcome
|
|
818
|
+
outcome.update(status=status, leaf=paired.ref(), span=span)
|
|
819
|
+
if record.kind == "suggestion":
|
|
820
|
+
outcome["suggestion"] = _gate(record, status, current, paired, span)
|
|
821
|
+
return outcome
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _read_record(reader, text: str, fm: list[str] | None, object_format: str) -> Record:
|
|
825
|
+
"""The record, or `record-inconsistent` for its grammar or for a body the
|
|
826
|
+
profile refuses (section 11.1)."""
|
|
827
|
+
try:
|
|
828
|
+
reader.parse(text, record=True)
|
|
829
|
+
except reader.Refusal as refusal:
|
|
830
|
+
raise _Refused("record-inconsistent") from refusal
|
|
831
|
+
record = read_record(fm, object_format)
|
|
832
|
+
if record is None:
|
|
833
|
+
raise _Refused("record-inconsistent")
|
|
834
|
+
return record
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _record_inputs(reader, repo: Mapping, document: str, anchor: Anchor):
|
|
838
|
+
"""Steps 2 to 6 of section 11.10's order: the current snapshot, the base
|
|
839
|
+
snapshot and the anchored leaf, or the first reason that applies."""
|
|
840
|
+
if document not in repo["tree"]:
|
|
841
|
+
raise _Refused("document-missing")
|
|
842
|
+
current = _read(reader, repo["tree"][document])
|
|
843
|
+
if anchor.base not in repo["blobs"]:
|
|
844
|
+
raise _Refused("base-unavailable")
|
|
845
|
+
base = _read(reader, repo["blobs"][anchor.base])
|
|
846
|
+
k = _anchored_leaf(base, anchor)
|
|
847
|
+
if k is None:
|
|
848
|
+
raise _Refused("record-inconsistent")
|
|
849
|
+
return current, base, k
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _anchored_leaf(base: Snapshot, anchor: Anchor) -> int | None:
|
|
853
|
+
same = [leaf.index for leaf in base.leaves if leaf.key == (anchor.type, anchor.text)]
|
|
854
|
+
return same[anchor.nth] if anchor.nth < len(same) else None
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _gate(record: Record, status: str, current: Snapshot, leaf: Leaf, span) -> dict:
|
|
858
|
+
"""Section 11.7's gate for a suggestion that resolved to a leaf."""
|
|
859
|
+
if record.status != "open" or status not in ("RESOLVED", "EDITED"):
|
|
860
|
+
return {"gate": "stale", "reason": None}
|
|
861
|
+
try:
|
|
862
|
+
written = write_back(current, leaf, span, record.anchor.replace)
|
|
863
|
+
except _Unmappable:
|
|
864
|
+
return {"gate": "stale", "reason": "unmappable-offset"}
|
|
865
|
+
return {"gate": "apply", "written": written}
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def duplicate_record_ids(tree: Mapping[str, str]) -> dict[str, list[str]]:
|
|
869
|
+
"""Record ids found in more than one notes directory, each with its
|
|
870
|
+
directories: section 11.8's warning, which is not a refusal."""
|
|
871
|
+
seen: dict[str, list[str]] = {}
|
|
872
|
+
for path in sorted(tree):
|
|
873
|
+
directory, _, name = path.rpartition("/")
|
|
874
|
+
if directory.endswith(".notes") and name.endswith(".md"):
|
|
875
|
+
seen.setdefault(name[: -len(".md")], []).append(directory)
|
|
876
|
+
return {rid: dirs for rid, dirs in seen.items() if len(dirs) > 1}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# --- transclusion (section 11.9) --------------------------------------------
|
|
880
|
+
|
|
881
|
+
_TITLE = re.compile(
|
|
882
|
+
r"""[ \t\n]("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^()\\]|\\.)*\))[ \t\n]*\)"""
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
@dataclass(frozen=True)
|
|
887
|
+
class Transclusion:
|
|
888
|
+
leaf: Leaf
|
|
889
|
+
path: str # the destination's part before the `#`, as raw text
|
|
890
|
+
name: str
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
class _NotALink(Exception):
|
|
894
|
+
"""CommonMark reads the paragraph's opening bracket as no link."""
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
_QUOTED_TITLE = re.compile(r""""(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^()\\]|\\.)*\)""")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _skip_space(content: str, at: int) -> int:
|
|
901
|
+
"""Past spaces and tabs, with at most one line ending among them."""
|
|
902
|
+
newline = False
|
|
903
|
+
while at < len(content) and content[at] in " \t\n":
|
|
904
|
+
if content[at] == "\n":
|
|
905
|
+
if newline:
|
|
906
|
+
break
|
|
907
|
+
newline = True
|
|
908
|
+
at += 1
|
|
909
|
+
return at
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _inline_link_tail(content: str, at: int) -> int | None:
|
|
913
|
+
"""Where an inline link's `(destination "title")` that starts at `at`,
|
|
914
|
+
just after `(`, ends, or None when it is no link tail (CommonMark
|
|
915
|
+
0.31.2, *Links*)."""
|
|
916
|
+
at = _skip_space(content, at)
|
|
917
|
+
if content[at : at + 1] == "<":
|
|
918
|
+
end = at + 1
|
|
919
|
+
while end < len(content) and content[end] not in "<>\n":
|
|
920
|
+
end += 2 if content[end] == "\\" else 1
|
|
921
|
+
if end >= len(content) or content[end] != ">":
|
|
922
|
+
return None
|
|
923
|
+
at = end + 1
|
|
924
|
+
else:
|
|
925
|
+
at = _destination_end(content, at)
|
|
926
|
+
gap = _skip_space(content, at)
|
|
927
|
+
if content[gap : gap + 1] == ")":
|
|
928
|
+
return gap + 1
|
|
929
|
+
title = _QUOTED_TITLE.match(content, gap) if gap > at else None
|
|
930
|
+
if title is None:
|
|
931
|
+
return None
|
|
932
|
+
end = _skip_space(content, title.end())
|
|
933
|
+
return end + 1 if content[end : end + 1] == ")" else None
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _link_text_end(content: str) -> int | None:
|
|
937
|
+
"""Where a link's text ends, after `[`, or None. Brackets nest and a
|
|
938
|
+
backslash escapes; text a code span or raw HTML could reshape is left
|
|
939
|
+
undecided. A link inside the text means the outer brackets are no link
|
|
940
|
+
(CommonMark 0.31.2, example 518): `_NotALink`."""
|
|
941
|
+
depth = 0
|
|
942
|
+
at = 1
|
|
943
|
+
while at < len(content):
|
|
944
|
+
ch = content[at]
|
|
945
|
+
if ch == "\\":
|
|
946
|
+
at += 2
|
|
947
|
+
continue
|
|
948
|
+
if ch in "`<":
|
|
949
|
+
raise Undecided("a transclusion link whose text holds a code span or HTML")
|
|
950
|
+
if ch == "[" and content[at - 1] != "!":
|
|
951
|
+
inner = _link_text_end(content[at:])
|
|
952
|
+
if inner is not None and content[at + inner + 1 : at + inner + 2] == "(":
|
|
953
|
+
if _inline_link_tail(content, at + inner + 2) is not None:
|
|
954
|
+
raise _NotALink()
|
|
955
|
+
if ch == "[":
|
|
956
|
+
depth += 1
|
|
957
|
+
elif ch == "]":
|
|
958
|
+
if depth == 0:
|
|
959
|
+
return at
|
|
960
|
+
depth -= 1
|
|
961
|
+
at += 1
|
|
962
|
+
return None
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _destination_end(content: str, at: int) -> int:
|
|
966
|
+
"""Where a destination that is not in angle brackets ends, from `at`."""
|
|
967
|
+
depth = 0
|
|
968
|
+
while at < len(content):
|
|
969
|
+
ch = content[at]
|
|
970
|
+
if ch == "\\" and at + 1 < len(content):
|
|
971
|
+
at += 2
|
|
972
|
+
continue
|
|
973
|
+
if ch == "(":
|
|
974
|
+
depth += 1
|
|
975
|
+
elif ch == ")":
|
|
976
|
+
if depth == 0:
|
|
977
|
+
break
|
|
978
|
+
depth -= 1
|
|
979
|
+
elif ord(ch) <= 0x20 or ch == "\x7f":
|
|
980
|
+
break
|
|
981
|
+
at += 1
|
|
982
|
+
return at
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
_ENTITY = re.compile(r"&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});")
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _reads_transclude(title: str) -> bool:
|
|
989
|
+
"""Whether a link title, with its delimiters, reads as `transclude` once
|
|
990
|
+
its backslash escapes and entity references are decoded. CommonMark
|
|
991
|
+
decodes an entity only with its closing `;`."""
|
|
992
|
+
inner = re.sub(r"\\([!-/:-@\[-`{-~])", r"\1", title[1:-1])
|
|
993
|
+
return _ENTITY.sub(lambda m: html.unescape(m.group()), inner) == "transclude"
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _link_destination(content: str) -> str | None:
|
|
997
|
+
"""The destination of a paragraph that is exactly one inline link titled
|
|
998
|
+
`"transclude"`, or None when it is not that shape."""
|
|
999
|
+
if not content.startswith("["):
|
|
1000
|
+
return None
|
|
1001
|
+
close = _link_text_end(content)
|
|
1002
|
+
if close is None or content[close : close + 2] != "](":
|
|
1003
|
+
return None
|
|
1004
|
+
at = close + 2
|
|
1005
|
+
if content[at : at + 1] == "<":
|
|
1006
|
+
raise Undecided("a transclusion destination in angle brackets")
|
|
1007
|
+
end = _destination_end(content, at)
|
|
1008
|
+
title = re.fullmatch(
|
|
1009
|
+
r'(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)"transclude"[ \t]*(?:\n[ \t]*)?\)', content[end:]
|
|
1010
|
+
)
|
|
1011
|
+
if end == at or title is None:
|
|
1012
|
+
return None
|
|
1013
|
+
return content[at:end]
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def classify(leaf: Leaf) -> Transclusion | None:
|
|
1017
|
+
"""Whether a paragraph is a transclusion (section 11.9): None, or the
|
|
1018
|
+
transclusion, or `Undecided` for a shape section 12 leaves open."""
|
|
1019
|
+
content = leaf.node.content()
|
|
1020
|
+
if "](" not in content:
|
|
1021
|
+
return None # no inline link at all
|
|
1022
|
+
titles = [m.group(1) for m in _TITLE.finditer(content) if _reads_transclude(m.group(1))]
|
|
1023
|
+
if not titles:
|
|
1024
|
+
return None
|
|
1025
|
+
if all(t in ("'transclude'", "(transclude)") for t in titles):
|
|
1026
|
+
return None # 0005 item 6: only the double-quoted title makes one
|
|
1027
|
+
if any(t != '"transclude"' for t in titles):
|
|
1028
|
+
raise Undecided(
|
|
1029
|
+
"a link title that reads as `transclude` but is written another way: "
|
|
1030
|
+
"another quoting form, a backslash escape or an entity reference"
|
|
1031
|
+
)
|
|
1032
|
+
try:
|
|
1033
|
+
if content.startswith("![") and _link_destination(content[1:]) is not None:
|
|
1034
|
+
return None # an image, which is no link
|
|
1035
|
+
destination = _link_destination(content)
|
|
1036
|
+
except _NotALink:
|
|
1037
|
+
return None
|
|
1038
|
+
if destination is None:
|
|
1039
|
+
raise Undecided("a `transclude` link in a paragraph that holds other text")
|
|
1040
|
+
path, hash_, name = destination.partition("#")
|
|
1041
|
+
if not hash_:
|
|
1042
|
+
return None # 0005 item 6
|
|
1043
|
+
return Transclusion(leaf, path, name)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def resolve_path(referrer: str, path: str) -> str:
|
|
1047
|
+
"""The repository path a transclusion's path names (0003 addendum
|
|
1048
|
+
detail 7), or `unresolvable-path`."""
|
|
1049
|
+
if not path:
|
|
1050
|
+
return "" # no file of that name, so document-missing (0005 item 6)
|
|
1051
|
+
segments = path.split("/")
|
|
1052
|
+
if path.startswith("/") or ":" in segments[0]:
|
|
1053
|
+
raise _Refused("unresolvable-path")
|
|
1054
|
+
if "" in segments:
|
|
1055
|
+
raise Undecided("an empty segment in a transclusion path")
|
|
1056
|
+
out = referrer.split("/")[:-1]
|
|
1057
|
+
for segment in segments:
|
|
1058
|
+
if segment == ".":
|
|
1059
|
+
continue
|
|
1060
|
+
if segment == "..":
|
|
1061
|
+
if not out:
|
|
1062
|
+
raise _Refused("unresolvable-path")
|
|
1063
|
+
out.pop()
|
|
1064
|
+
else:
|
|
1065
|
+
out.append(segment)
|
|
1066
|
+
return "/".join(out)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def read_pins(lines: list[str] | None, object_format: str) -> dict[str, str] | None:
|
|
1070
|
+
"""A referrer's pins by path (0003 addendum detail 2): `{}` when it has
|
|
1071
|
+
none, None when they break the grammar."""
|
|
1072
|
+
lines = lines or []
|
|
1073
|
+
heads = [i for i, line in enumerate(lines) if line.startswith("pins:")]
|
|
1074
|
+
if not heads:
|
|
1075
|
+
return {}
|
|
1076
|
+
if len(heads) > 1 or lines[heads[0]] != "pins:":
|
|
1077
|
+
return None
|
|
1078
|
+
pins: dict[str, str] = {}
|
|
1079
|
+
at = heads[0] + 1
|
|
1080
|
+
while at < len(lines) and lines[at].startswith(" - "):
|
|
1081
|
+
value = lines[at][len(" - ") :]
|
|
1082
|
+
pairs = _pairs(value)
|
|
1083
|
+
if not isinstance(pairs, _Object) or [k for k, _ in pairs] != ["path", "blob"]:
|
|
1084
|
+
return None
|
|
1085
|
+
got = dict(pairs)
|
|
1086
|
+
if not isinstance(got["path"], str) or not _hex_id(got["blob"], object_format):
|
|
1087
|
+
return None
|
|
1088
|
+
written = (
|
|
1089
|
+
f'{{"path": {canonical_string(got["path"])}, "blob": {canonical_string(got["blob"])}}}'
|
|
1090
|
+
)
|
|
1091
|
+
if written != value:
|
|
1092
|
+
return None
|
|
1093
|
+
if pins and got["path"] <= list(pins)[-1]:
|
|
1094
|
+
return None # out of order, or a path twice
|
|
1095
|
+
pins[got["path"]] = got["blob"]
|
|
1096
|
+
at += 1
|
|
1097
|
+
for later in range(at, len(lines)):
|
|
1098
|
+
if lines[later].startswith(" - ") and all(
|
|
1099
|
+
not line.strip(" \t") or line.startswith((" ", "#")) for line in lines[at:later]
|
|
1100
|
+
):
|
|
1101
|
+
return None
|
|
1102
|
+
return pins
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _bound_leaves(snapshot: Snapshot, binding: Mapping) -> list[Leaf]:
|
|
1106
|
+
roots = binding["region"] if "region" in binding else [binding]
|
|
1107
|
+
paths = [tuple(r["node"]) for r in roots]
|
|
1108
|
+
return [
|
|
1109
|
+
leaf for leaf in snapshot.leaves if any(tuple(leaf.node.path[: len(p)]) == p for p in paths)
|
|
1110
|
+
]
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _reaches(reader, tree: Mapping, start: str, goal: str, seen: set[str]) -> bool:
|
|
1114
|
+
"""Whether `start`'s transclusions lead back to `goal`, in the current
|
|
1115
|
+
tree. A paragraph whose shape is itself undecided is not followed, and
|
|
1116
|
+
the rest of its document still is."""
|
|
1117
|
+
if start in seen or start not in tree:
|
|
1118
|
+
return False
|
|
1119
|
+
seen.add(start)
|
|
1120
|
+
try:
|
|
1121
|
+
snapshot = Snapshot(reader, tree[start])
|
|
1122
|
+
except reader.Refusal:
|
|
1123
|
+
return False
|
|
1124
|
+
for leaf in snapshot.leaves:
|
|
1125
|
+
if leaf.node.type != "paragraph":
|
|
1126
|
+
continue
|
|
1127
|
+
try:
|
|
1128
|
+
one = classify(leaf)
|
|
1129
|
+
if one is None:
|
|
1130
|
+
continue
|
|
1131
|
+
target = resolve_path(start, one.path)
|
|
1132
|
+
except (_Refused, Undecided):
|
|
1133
|
+
continue
|
|
1134
|
+
if target == goal or _reaches(reader, tree, target, goal, seen):
|
|
1135
|
+
return True
|
|
1136
|
+
return False
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def resolve_transclusions(reader, repo: Mapping, referrer: str) -> list[dict] | str:
|
|
1140
|
+
"""Each transclusion of a referrer, in document order, or the one
|
|
1141
|
+
referrer-level status of a referrer the profile refuses (section 11.9,
|
|
1142
|
+
step 0)."""
|
|
1143
|
+
tree = repo["tree"]
|
|
1144
|
+
if referrer not in tree:
|
|
1145
|
+
raise Undecided("a referrer that is not in the tree")
|
|
1146
|
+
try:
|
|
1147
|
+
snapshot = Snapshot(reader, tree[referrer])
|
|
1148
|
+
except reader.Refusal:
|
|
1149
|
+
return "REFUSED(not-in-profile)"
|
|
1150
|
+
pins = read_pins(_frontmatter_lines(snapshot.doc, snapshot.lines), repo["object_format"])
|
|
1151
|
+
out = []
|
|
1152
|
+
for leaf in snapshot.leaves:
|
|
1153
|
+
if leaf.node.type != "paragraph":
|
|
1154
|
+
continue
|
|
1155
|
+
try:
|
|
1156
|
+
one = classify(leaf)
|
|
1157
|
+
if one is None:
|
|
1158
|
+
continue
|
|
1159
|
+
outcome = _resolve_one(reader, repo, referrer, one, pins)
|
|
1160
|
+
except Undecided as undecided:
|
|
1161
|
+
outcome = {"status": "UNDECIDED", "question": undecided.question, "unit": None}
|
|
1162
|
+
except _Refused as refused:
|
|
1163
|
+
outcome = {"status": f"REFUSED({refused.reason})", "unit": None}
|
|
1164
|
+
out.append({"leaf": leaf.ref(), **outcome})
|
|
1165
|
+
return out
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _slug_only(reader, current: Snapshot, name: str) -> bool:
|
|
1169
|
+
"""Whether only a heading slug matches `name` in the current target."""
|
|
1170
|
+
if name in current.doc.names:
|
|
1171
|
+
return False
|
|
1172
|
+
return any(
|
|
1173
|
+
node.type == "heading" and reader.slug(node.text) == name for node in current.doc.nodes
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _resolve_one(reader, repo: Mapping, referrer: str, one: Transclusion, pins) -> dict:
|
|
1178
|
+
"""Section 11.9, steps 1 to 4, for one transclusion of an accepted
|
|
1179
|
+
referrer. The first step that fails decides."""
|
|
1180
|
+
tree, blobs = repo["tree"], repo["blobs"]
|
|
1181
|
+
target = resolve_path(referrer, one.path) # 1.1
|
|
1182
|
+
if target and (not target.endswith(".md")):
|
|
1183
|
+
raise Undecided("a transclusion path that names a file that is not markdown")
|
|
1184
|
+
if target == referrer or _reaches(reader, tree, target, referrer, set()):
|
|
1185
|
+
raise Undecided("a document that transcludes itself, directly or through another")
|
|
1186
|
+
if pins is None: # 1.2
|
|
1187
|
+
raise _Refused("record-inconsistent")
|
|
1188
|
+
if target not in tree: # 1.3
|
|
1189
|
+
raise _Refused("document-missing")
|
|
1190
|
+
current = _read(reader, tree[target]) # 1.4
|
|
1191
|
+
if _slug_only(reader, current, one.name): # 1.5
|
|
1192
|
+
return {"status": f"#REF!({one.name})", "unit": None}
|
|
1193
|
+
if one.path not in pins:
|
|
1194
|
+
raise _Refused("base-unavailable") # 0005 item 6
|
|
1195
|
+
if pins[one.path] not in blobs: # 1.6
|
|
1196
|
+
raise _Refused("base-unavailable")
|
|
1197
|
+
pinned = _read(reader, blobs[pins[one.path]]) # 1.7
|
|
1198
|
+
if one.name not in pinned.doc.names: # 1.8
|
|
1199
|
+
raise _Refused("record-inconsistent")
|
|
1200
|
+
binding = current.doc.names.get(one.name) # 2
|
|
1201
|
+
if binding is None:
|
|
1202
|
+
return {"status": f"#REF!({one.name})", "unit": None}
|
|
1203
|
+
corr = Correspondence(pinned.leaves, current.leaves) # 3
|
|
1204
|
+
pairs = []
|
|
1205
|
+
for leaf in _bound_leaves(pinned, pinned.doc.names[one.name]):
|
|
1206
|
+
try:
|
|
1207
|
+
c, _kind = check_pair(corr, leaf.index)
|
|
1208
|
+
except _Refused as refused:
|
|
1209
|
+
raise _Refused(f"pinned-{refused.reason}") from refused
|
|
1210
|
+
if c is None:
|
|
1211
|
+
raise _Refused("pinned-orphaned")
|
|
1212
|
+
pairs.append(c)
|
|
1213
|
+
inside = {leaf.index for leaf in _bound_leaves(current, binding)} # 4
|
|
1214
|
+
if any(c not in inside for c in pairs):
|
|
1215
|
+
raise _Refused("name-rebound")
|
|
1216
|
+
return {"status": "RESOLVED", "unit": {"document": target, "target": binding}}
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
# --- the entry point --------------------------------------------------------
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _default_reader():
|
|
1223
|
+
from blockspec import document
|
|
1224
|
+
|
|
1225
|
+
return SimpleNamespace(
|
|
1226
|
+
parse=document.parse,
|
|
1227
|
+
Refusal=document.Refusal,
|
|
1228
|
+
split_lines=document._split_lines,
|
|
1229
|
+
slug=document._slug,
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def resolve(repo: Mapping, queries: Mapping, reader=None) -> dict:
|
|
1234
|
+
"""The `anchor` kind's interface (`conformance/run_cases.py`): each queried
|
|
1235
|
+
record's outcome, and each queried referrer's transclusions."""
|
|
1236
|
+
reader = reader or _default_reader()
|
|
1237
|
+
records = {}
|
|
1238
|
+
for path in queries.get("records", []):
|
|
1239
|
+
records[path] = _undecided_or(lambda p=path: resolve_record(reader, repo, p))
|
|
1240
|
+
transclusions = {}
|
|
1241
|
+
for path in queries.get("transclusions", []):
|
|
1242
|
+
transclusions[path] = _undecided_or(lambda p=path: resolve_transclusions(reader, repo, p))
|
|
1243
|
+
return {"records": records, "transclusions": transclusions}
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _undecided_or(work: Callable[[], object]):
|
|
1247
|
+
try:
|
|
1248
|
+
return work()
|
|
1249
|
+
except Undecided as undecided:
|
|
1250
|
+
return {"status": "UNDECIDED", "question": undecided.question}
|