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/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}