From 3440d2ce791e912fb173743565af5d39cde224ef Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 26 Aug 2026 13:22:13 -0400 Subject: [PATCH 01/13] Locate byte regions with expat rather than a hand-rolled byte walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lxml exposes no byte offsets, but the stdlib's expat binding does: CurrentByteIndex reports where the current event's markup begins. Taking region starts and ends from element events retires the tag, comment, CDATA, processing-instruction and nesting walk that found them by hand — the module drops from 189 lines to 137, and scanning sango.lift (4.8 MB) from 660 ms to 185 ms, since the byte loop it replaces ran in Python and expat runs in C. _tag_end survives, and does the one thing offsets alone cannot settle. An empty element's end event reports the offset just past the whole element, where every other element's reports the "<" of its end tag; the two cases are indistinguishable from the offset, so which one applies is read off the start tag's "/" instead. The same quote-aware scan supplies the root's open-tag end. Conservative refusals are unchanged, and now come from one place: expat rejects the malformed and truncated markup the walk used to detect case by case, and a DTD is refused as before, since entity expansion would make these offsets describe bytes that are not in the file. Co-Authored-By: Claude Opus 5 (1M context) --- src/sil_lift/_scan.py | 227 +++++++++++++++++------------------------- 1 file changed, 89 insertions(+), 138 deletions(-) diff --git a/src/sil_lift/_scan.py b/src/sil_lift/_scan.py index f630681..f712d50 100644 --- a/src/sil_lift/_scan.py +++ b/src/sil_lift/_scan.py @@ -2,9 +2,10 @@ The writer emits untouched entries verbatim from their original bytes, which requires knowing each top-level ````'s (and ``
``'s) exact byte -region in the source. lxml exposes no byte offsets, so this module walks the -raw bytes with a small state machine that understands tags, quoted attribute -values, comments, CDATA sections, and processing instructions. +region in the source. lxml exposes no byte offsets, but the stdlib's expat +binding does: ``CurrentByteIndex`` reports where the current event's markup +begins, which is a region's start at the element's start event and — bar the +empty-element wrinkle noted below — its end at the matching end event. "Region" rather than "span" throughout: LIFT has a ```` element for inline markup, modelled as :class:`~sil_lift.Span`, and the two would @@ -13,18 +14,19 @@ What it exists for is byte identity, not diagnostics — ``docs/en/fidelity.md`` states the guarantee it underpins. Problem reporting needs only the line an element starts on and takes that from lxml's ``sourceline`` (see -``_validate._line``); a region needs the end offset too, which no parser API +``_validate._line``); a region needs the end offset too, which no tree API exposes. It is deliberately conservative: anything unexpected (DOCTYPE, malformed -nesting, non-ASCII-compatible encoding — checked by the caller) returns -``None`` and the writer falls back to canonical serialization, which keeps -the semantic guarantee and waives only byte identity. +markup, non-ASCII-compatible encoding — checked by the caller) returns ``None`` +and the writer falls back to canonical serialization, which keeps the semantic +guarantee and waives only byte identity. """ from __future__ import annotations from dataclasses import dataclass +from xml.parsers import expat __all__ = ["ChildRegion", "ScanResult", "scan"] @@ -44,146 +46,95 @@ class ScanResult: children: list[ChildRegion] # document order; empty for a self-closing root -def _skip_comment(data: bytes, i: int) -> int | None: - end = data.find(b"-->", i + 4) - return None if end < 0 else end + 3 +class _Unscannable(Exception): + """Raised inside a handler to abandon the scan; ``scan`` returns None.""" -def _skip_pi(data: bytes, i: int) -> int | None: - end = data.find(b"?>", i + 2) - return None if end < 0 else end + 2 +def _tag_end(data: bytes, start: int) -> int: + """Just past the ``>`` of the start tag beginning at ``start``. - -def _skip_cdata(data: bytes, i: int) -> int | None: - end = data.find(b"]]>", i + 9) - return None if end < 0 else end + 3 - - -def _skip_tag(data: bytes, i: int) -> tuple[int, bool] | None: - """From ``<`` of a start/end tag to just past ``>``; reports self-closing.""" - n = len(data) - j = i + 1 + An attribute value may hold a ``>``, so this tracks quoting rather than + searching for the delimiter. End tags take no attributes and so need no + such care. + """ quote: int | None = None - while j < n: - c = data[j] + for index in range(start + 1, len(data)): + char = data[index] if quote is not None: - if c == quote: + if char == quote: quote = None - elif c in (0x22, 0x27): # " or ' - quote = c - elif c == 0x3E: # > - return j + 1, data[j - 1] == 0x2F # preceded by / - j += 1 - return None - - -def _tag_name(data: bytes, i: int) -> str: - j = i + 1 - n = len(data) - while j < n and data[j] not in b" \t\r\n/>": - j += 1 - return data[i + 1 : j].decode("utf-8", errors="replace") - - -def _skip_element(data: bytes, i: int) -> int | None: - """From ``<`` of a start tag to just past the matching end tag.""" - step = _skip_tag(data, i) - if step is None: - return None - pos, self_closing = step - if self_closing: - return pos - depth = 1 - n = len(data) - while depth > 0: - lt = data.find(b"<", pos) - if lt < 0: - return None - if data.startswith(b"