Skip to content

✨ feat(serialize): lossless byte-preserving to_source#600

Merged
gaborbernat merged 1 commit into
tox-dev:mainfrom
gaborbernat:feat/547-lossless-serialize
Jul 8, 2026
Merged

✨ feat(serialize): lossless byte-preserving to_source#600
gaborbernat merged 1 commit into
tox-dev:mainfrom
gaborbernat:feat/547-lossless-serialize

Conversation

@gaborbernat

Copy link
Copy Markdown
Member

The parse-then-serialize round trip normalizes the whole document: it re-quotes every attribute, lowercases tag names, and rewrites character references to their canonical form. That is the right default for canonical, diff-stable output, but it is the wrong tool for a surgical edit, where you want to change one thing and leave every other byte, including the author's own quoting and formatting, exactly as written. This closes #547.

Node.to_source() fills that gap. It re-emits a parsed tree from its original source bytes, copying the verbatim span of every element and text run the parse left untouched and reserializing only the nodes a mutation changed. Parse with source_locations=True and an unedited round trip reproduces the input byte for byte for markup that parsed without implied elements or content reordering; after an edit only the changed node's markup is rewritten while every untouched sibling and subtree keeps its original span. 🧩 It is the tree-based counterpart to the streaming turbohtml.rewrite, the model Cloudflare's lol-html popularized: reach for the rewriter at the edge and for a page larger than memory, and for to_source when the edit needs a query the stream cannot decide.

The C serializer walks the tree once, composing each node from the per-element spans a source-location parse already records. An element copies its start-tag span, recurses into its children, and copies its end-tag span; a text run copies its zero-copy slice while it is still one. Because contiguous child spans tile the parent's content, concatenating the pieces reproduces the original without a separate whole-subtree copy, and each clean node costs one memcpy. Inserted and removed nodes are reflected by reading the tree's current state; the one edit a span cannot reveal, a changed attribute value, is flagged on the element's start tag by the mutation API so that one tag rebuilds from the current attributes while its end tag, children, and siblings stay verbatim. The walk is iterative, so an arbitrarily deep tree never grows the C stack.

Two normalizations the parser itself performs are not reversible from the tree and so are not preserved: a lowercase <!doctype html> re-emits as <!DOCTYPE html>, and a character reference the tokenizer resolved re-emits in its canonical spelling (&amp;, &lt;, &gt;, and &nbsp; survive because the escaper re-encodes them; a legacy or numeric reference becomes its character). Where every byte of an error-recovering or reordering parse must survive, the streaming rewriter, which never discards the token stream, remains the tool; the serialization explanation states the full round-trip contract.

Alongside the C implementation and its thin to_source method, this adds a lossless-serialize benchmark op wired through the pyperf, CodSpeed, and PGO corpora, the four Diataxis docs, and a new section of the lol-html migration guide covering when a tree beats the stream. The migration guide keeps its existing rationale for carrying no bench-table or package-meta: lol-html is a Rust and WebAssembly project with no in-process Python peer to benchmark and no PyPI package the badge directive can describe.

Node.to_source() re-emits a parsed tree from its original source bytes,
copying the verbatim span of every element and text run the parse left
untouched and reserializing only the nodes a mutation changed. On a tree
parsed with source_locations=True, an unedited round trip reproduces the
input byte for byte -- author quoting, tag-name case, character-reference
spelling, and insignificant whitespace intact -- for markup that parsed
without implied elements or content reordering. After an edit only the
changed node's markup is rewritten while every untouched sibling and
subtree keeps its original span.

The walk composes per node from the spans a source-location parse records:
an element copies its start-tag span, recurses, and copies its end-tag
span; a text run copies its zero-copy slice while it is still one. Inserted
and removed nodes are reflected by reading the current tree; a changed
attribute value, which a span cannot reveal, is flagged on the element's
start tag by the mutation API so that one tag rebuilds from the current
attributes. The walk is iterative, so an arbitrarily deep tree never grows
the C stack.

This is the tree-based counterpart to the streaming turbohtml.rewrite, the
model Cloudflare's lol-html popularized. Adds a lossless-serialize benchmark
op wired through the pyperf, CodSpeed, and PGO corpora, the four Diataxis
docs, and an extension of the lol-html migration guide.

closes tox-dev#547
@gaborbernat gaborbernat added the enhancement New feature or request label Jul 8, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 83 untouched benchmarks
🆕 1 new benchmark
⏩ 18 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 test_feature[lossless-serialize] N/A 2.8 ms N/A

Comparing gaborbernat:feat/547-lossless-serialize (6e77048) with main (e3afe02)

Open in CodSpeed

Footnotes

  1. 18 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@gaborbernat gaborbernat merged commit 8c0c215 into tox-dev:main Jul 8, 2026
49 checks passed
@gaborbernat gaborbernat deleted the feat/547-lossless-serialize branch July 10, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Serializer: lossless byte-preserving rewriter

1 participant