Parsing: from bytes to a navigable tree¶
A parse runs as a pipeline. Encoding detection turns input bytes into a str, the tokenizer turns that string into a
WHATWG token stream, and the tree builder runs the tree-construction algorithm over those tokens to produce the DOM a
browser would build. The blue stages are input handling, the orange stages are the two spec state machines, and the
green stage is the navigable tree you query.
flowchart LR
bytes([input bytes]) --> enc[encoding detection]
enc --> dec[decoded str]
dec --> tok[tokenizer]
tok --> tree[tree builder]
tree --> dom([navigable DOM tree])
classDef input fill:#e3f2fd,stroke:#1565c0,color:#0d47a1
classDef engine fill:#fff3e0,stroke:#e65100,color:#bf360c
classDef output fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
class bytes,enc,dec input
class tok,tree engine
class dom output
How the encoding is decided¶
For bytes input the first stage resolves an encoding in the WHATWG order: a byte-order mark wins
outright, then the caller’s encoding argument, then a prescan of the first 1024 bytes for a <meta> charset
declaration, and windows-1252 stands in when nothing matched. Passing detect_encoding=True inserts one step before
that fallback: a content-based detector that validates UTF-8 structurally and otherwise lets the CJK and single-byte
candidates compete on character-pair frequencies, where a single decode error or C1 control disqualifies a candidate.
The step is opt-in and strictly subordinate: a declared encoding always wins, so spec conformance is untouched, and pure
ASCII stays windows-1252 (the two decode ASCII identically).
The same pipeline, minus the decode and the parse, is the standalone turbohtml.detect.detect() – the
chardet and charset-normalizer replacement – which is why a standalone detection and
parse(data, detect_encoding=True) can never disagree about the same bytes.
A spec-exact tokenizer¶
turbohtml.tokenize() implements the WHATWG HTML tokenization algorithm (the same state machine inside every browser)
rather than a regex approximation like html.parser.HTMLParser. The C implementation mirrors the spec
state by state so the two read side by side, and the shared html5lib-tests conformance suite that browsers and parser libraries validate against
checks it at all three input storage widths, once per width, because the token stream must be invariant to how CPython
stores the string.
Two decisions bound the tokenizer’s scope:
The tokenizer is not a parser. It hands you the token stream; it does not build a tree, balance tags, or apply the tree-construction rules. The one tree-construction duty it takes on is content-model switching: after a start tag for
script,style,titleand the other raw-text elements, the element’s contents tokenize as the spec requires (a<b>inside a script body is text, not a tag).The machine recovers from parse errors rather than stopping on them. The spec defines a recovery transition for every error and the machine takes it, so malformed input produces the same tokens a browser would see. A duplicate attribute name is one such recovery: the spec keeps the first occurrence and drops the rest at tokenization, so
<a href=x href=y>carries a singlehrefofxeverywhere it is observed: in the token, the parsed tree, and the serialized output alike. The recovery is silent in the token stream itself; the errors it papered over surface onDocument.errorswhen youparse()(see below).
Where behavior could drift, more than the suite pins it: a fuzz comparison runs the token stream against html5lib’s
tokenizer, and source positions use the same 1-based-line, 0-based-column convention as html.parser, so
diagnostics line up with what the standard library reports. For code that prefers callbacks to a token stream,
turbohtml.migration.stdlib.HTMLParser re-exposes the stream through html.parser’s subclass-and-override
surface, so an existing HTMLParser subclass migrates by swapping its base class.
Tokenizing at native width¶
CPython stores a string at one of three widths (PEP 393): one byte per character for Latin-1, two for the basic
multilingual plane, four beyond. The tokenizer keeps that representation end to end instead of widening everything to
UCS-4: the input buffer, accumulated text runs, tag names and attribute values all store code points at the narrowest
width their content needs, promoting only when a wider character arrives. turbohtml compiles the state-machine core once
per width (the same trick CPython’s stringlib uses), so every read is direct indexing, and the plain-text states
bulk-scan to the next special character the way html5ever does rather than
dispatching the state machine per character. For the ASCII documents that dominate real traffic, a text run travels from
input to the final str as one-byte copies.
The Performance page measures the tokenizer against html.parser.HTMLParser and
html5lib’s pure-Python tokenizer. The closest case is a document dominated by a single text node, where the standard
library’s regex performs one C scan and never tokenizes; everywhere markup appears, the state machine is roughly ten
times faster.
A navigable tree without copies¶
turbohtml.parse() runs the full WHATWG tree-construction algorithm on top of the tokenizer (the insertion modes,
the adoption agency, foreign content, and the error recovery a browser performs); the same html5lib-tests tree-construction suite browsers use validates it. The result is the tree
a browser builds for the same bytes.
turbohtml builds the tree in C as a pointer-linked node graph in a single bump-allocated arena, the layout lexbor, Go’s x/net/html, and html5ever all converge on. It holds no Python objects. Element
identity is an interned integer atom, so every scope and category test in the algorithm is an integer compare rather
than a string comparison, and freeing the whole tree is one release of the arena rather than a per-node teardown.
turbohtml creates the Python Node objects only for the nodes you touch. Walking into a child or
sibling allocates one small wrapper that points at the existing arena node; it never copies the subtree. Text payloads
go further: a text node borrows a slice of the original input string and materializes into its own buffer only when you
first read its text or data. A private handle keeps the arena and the
input string alive for as long as any node reachable from them, so a node extracted from a document keeps working after
the document goes out of scope. Building the navigable Document costs about the same as building the
raw C tree, and the wrappers add cost only for the nodes you touch.
turbohtml parses faster than the C parsers lxml and selectolax, and 30 to 80 times faster than the pure-Python BeautifulSoup and html5lib, while building the WHATWG tree that lxml’s libxml2 does not; the Performance page has the per-document figures.
The same tokenizer and tree builder also drive turbohtml.IncrementalParser, the push form of
turbohtml.parse(). The tokenizer is resumable (it suspends mid-token when its input runs out) and reclaims the
prefix it has consumed on every chunk, so the only state the parser carries across a feed is the few insertion-mode
variables of the tree-construction loop, lifted out of the one-shot call into the parser. The input side is therefore
bounded no matter how long the stream: you never hold the whole source at once, the concrete win over parse for a
document that arrives over a socket or a file larger than the buffer you would otherwise join. bytes chunks decode
through a stateful incremental codec, so a multi-byte character split across a chunk boundary still decodes correctly.
The recovery a browser performs hides the spec’s parse errors, but a linter or a strict pipeline still wants them.
parse() records each error it recovers from on Document.errors, a
list of ParseError carrying the spec error code and the source position (1-based line, 0-based
column, the same convention Token uses). Collection costs nothing on well-formed input (there are no
errors to record and the per-character paths are untouched), so it stays on by default rather than behind a flag; the
standalone tokenize() and Tokenizer, which expose the raw stream, do not collect.
Pass strict=True to raise the first error as HTMLParseError (with the ParseError on its
error attribute) instead of returning a recovered tree.