"""Shared block parser for the diff/patch revision mode toolchain (#89 Item 7 Slice A). Normative source: `docs/design/2026-06-10-390-diff-patch-revision-mode-spec.md` §3.1 (block segmentation, marker grammar, malformed-state rules, unsupported-construct rejection). Both `ars_anchorize_draft.py` and `ars_apply_revision_patch.py` import this module so segmentation can never drift between the stamping side and the splicing side. Design constraints the implementation must not violate: - **Fail-closed, never guess.** Anything the line-based scan cannot classify into the §3.1 supported block classes raises ``BlockParseError`` naming the construct. Mis-anchoring would silently misroute patches; a loud stop is the contract. - **Byte-span fidelity.** Blocks carry character offsets into the original text (UTF-8 decoded, no newline translation) so the apply script can splice the original byte stream. The parser never re-serializes content. - **Hash normalization is read-side only** (§3.2): CRLF→LF, marker line excluded, block-level leading/trailing blank lines stripped, intra-line whitespace untouched. Normalized text exists for hash computation and is never written back. Supported block classes (§3.1, closed list): fenced code, ATX heading, table run, list run, blockquote run, plain text run, plus skipped YAML frontmatter. Setext-underline shapes, line-initial raw-HTML openers (detector subset: ``^$") MARKER_PREFIX = " markers (§3.2)", ) if pending_marker is not None: raise BlockParseError( "marker_stack", pending_marker[0] + 1, f"marker {pending_marker[1]} is followed by another marker line", "each marker labels exactly one block", ) pending_marker = (i, marker_match.group(1)) i += 1 if i >= n: raise BlockParseError( "orphan_marker", pending_marker[0] + 1, f"marker {pending_marker[1]} is attached to nothing (end of file)", "remove the marker line or re-run anchorize", ) continue kind = _classify_start(stripped) start = i if kind == "fence": fence_match = _FENCE_OPEN_RE.match(stripped) fence_str = fence_match.group(1) fence_char = fence_str[0] fence_len = len(fence_str) close_re = re.compile(r"^ {0,3}(" + re.escape(fence_char) + r"{" + str(fence_len) + r",})\s*$") i += 1 while i < n and not close_re.match(_strip_eol(lines[i])): i += 1 if i >= n: raise BlockParseError( "unterminated_fence", start + 1, "fenced code block has no matching closing fence", "close the fence; the parser does not guess at EOF-terminated fences", ) i += 1 # include the closing fence line elif kind == "heading": i += 1 elif kind == "table": while i < n and _TABLE_LINE_RE.match(_strip_eol(lines[i])): i += 1 elif kind == "list": i += 1 while i < n: cur = _strip_eol(lines[i]) if _is_blank(lines[i]): # A blank stays inside the run only when the next # non-blank line continues the list (loose lists). k = i while k < n and _is_blank(lines[k]): k += 1 if k < n: nxt = _strip_eol(lines[k]) if not MARKER_RE.match(nxt) and ( _LIST_START_RE.match(nxt) or (nxt[:1] in (" ", "\t") and nxt.strip() != "") ): i = k continue break if _LIST_START_RE.match(cur) or (cur[:1] in (" ", "\t")): i += 1 continue break elif kind == "blockquote": while i < n and _BLOCKQUOTE_RE.match(_strip_eol(lines[i])): i += 1 else: # text run _reject_unsupported_text_line(stripped, i + 1, first_of_run=True) i += 1 while i < n: cur_raw = lines[i] cur = _strip_eol(cur_raw) if _is_blank(cur_raw) or MARKER_RE.match(cur): break if _classify_start(cur) != "text": break _reject_unsupported_text_line(cur, i + 1, first_of_run=False) i += 1 _finish_block(kind, start, i) # Unreachable invariant, not a third orphan-marker code path: a marker # before a blank line is rejected in the blank branch, and a marker at # EOF is rejected right after it is consumed. assert pending_marker is None seen: dict[str, int] = {} for b in blocks: if b.block_id is None: continue if b.block_id in seen: raise BlockParseError( "duplicate_block_id", 0, f"block ID {b.block_id} appears more than once", "duplicates can only arise from hand-editing; re-anchorize from a clean draft", ) seen[b.block_id] = 1 return ParsedDocument(text=text, frontmatter_span=frontmatter_span, blocks=blocks) def segment_fragment(new_text: str) -> list[Block]: """Segment a patch op's ``new_text`` into blocks (§3.2). Returns the parsed ``Block`` objects (spans are into ``new_text``), using the same normative segmentation as the document parser. Raises ``BlockParseError`` on anything the parser refuses (unsupported constructs, embedded markers) and on an empty/blank fragment. """ parsed = parse_document(new_text, fragment=True) if not parsed.blocks: raise BlockParseError("empty_fragment", 1, "new_text contains no block content") return parsed.blocks