All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.3.0 - 2026-10-16
Added
Gumbo.materialize/2takesdetail: :tree: the tree without source bookkeeping, with the tag name on every element — the name as written, so a custom element is never left nameless — and attributes as an ordered list of{name, value}, with any prefix the parser split off composed back on (xlink:href,xmlns:xlink). Text-like, comment and processing-instruction nodes are the onesdetail: :contentreturns.- A processing instruction carries
targetanddata— the two parts the spec gives that payload, and the pair a browser reports — in both the:fulland:contentshapes, throughGumbo.Node.target/1andGumbo.Node.data/1.textstill carries the one string the C library stores. - The README's "Differences from Nokogiri's gumbo" section: what each side vendors, and the shapes measured to differ — processing instructions, template handling, and the two option defaults that differ.
Changed
</p>inside a<select>closes the emptypthe parser inserts there, not theparound theselect, so the text after it stays where it was written. The spec removed the "in select" insertion mode and listsselectamong the types that bound a scope query (whatwg/html#10548); a<source>inside a<select>was already kept, which that change makes correct rather than a divergence.
Fixed
- An end tag inside a
<select>that namesapplet,marqueeorobjectno longer reaches past the select and takes it and everything under it: those end tags ask for the element in scope, and the parser asked in table scope, whose boundaries are onlyhtml,tableandtemplate(#9). - An end tag inside a
<select>that names no open element —</option>is the common one — no longer walks past the select and closes what sits above it, which moved everything written after that end tag out of the select (#10, #11). - Control characters and noncharacters in the input stream are kept in the tree, as the spec and
the reference keep them, instead of coming back as U+FFFD. They are reported for what they are —
:control_character_in_input_streamand:noncharacter_in_input_stream— and theircodepoint/1is the code point the decoder read, not the bytes under the cursor. A byte sequence that is not valid UTF-8 is still replaced. - An unknown end tag that names no open element no longer closes the first element with an unknown
name it meets, which moved the text after it out of that element:
</fidiv>no longer closes an open<f&#igure>. Reported as #3, on the pasted custom-element markup a chat application receives. :max_attributescounts the attribute names read for one element, a repeated name included, so an element at the limit with one repeated name is refused with{:error, :too_many_attributes}, the way the reference refuses it; and it is enforced while a start tag is still being read, so the name the tokenizer has reached counts even when end of input abandons the tag. Either way a document past the bound is refused instead of answered with a tree. Reported as #8.
0.2.0 - 2026-10-15
Changed
detail: :contentcarries:tag_namefor an element whosetagis:unknown— a custom element such as<my-tag>, or an SVG tag the enum does not carry such asclipPath— so the name as written survives in that shape. Elements gumbo can name are unchanged: their tag atom is that same name, and they carry no:tag_name.
0.1.0 - 2026-10-14
First release. gumbo is a faithful binding to the C library of the same name, and the surface is
deliberately the library's own: what gumbo can say about a document is reachable from Elixir, and
nothing is layered on top of it.
Add it with {:gumbo, "~> 0.1.0"}. Precompiled NIFs are published for x86_64-linux-gnu,
aarch64-linux-gnu, x86_64-apple-darwin, aarch64-apple-darwin and x86_64-windows; any other
platform compiles from the sources carried in the package. Each archive is built on the platform it
is for and that platform's suite passes before it is packaged — the README has the table and how to
read it. The vendored parser stays byte-identical to its 0.14.1 pin: the six patches below are
applied to a copy.
Added
Gumbo.parse/2andGumbo.parse!/2— parse HTML into an opaqueGumbo.Outputhandle backed by a NIF resource; the tree stays native and is walked lazily throughGumbo.Nodeaccessors.Gumbo.parse_fragment/3andGumbo.parse_fragment!/3— fragment parsing with a context tag and namespace, with the checked/unchecked conflict contract (parse_fragment/3checks,parse/2does not).Gumbo.materialize/2— convert a handle, node, list of nodes, ornilinto plain nested Elixir data in a single iterative NIF walk, atdetail: :fullordetail: :content.Gumbo.Output— the opaque handle:document/1,root/1,errors/1,input/1,options/1,node_count/1,fragment_nodes/1.Gumbo.Node— accessors for everyGumboNodefield, includingtag_name/1for unknown tags anddescendants/1as an iterative walk.Gumbo.Attribute— accessors for everyGumboAttributefield.Gumbo.Error— gumbo's parse errors as structured data, plusmessage/1andcaret/1rendered by gumbo itself.Gumbo.Position,Gumbo.Options,Gumbo.Tag,Gumbo.Types— the plain data structures, the standalone tag API, and the enum type unions.- Scheduler-aware dispatch: parses of 20 480 bytes or more and
materializations of 20 480 nodes or more run on a dirty CPU scheduler,
with a
dirty: :auto | true | falseoverride. - A 13-shape small-stack regression matrix (
mix test.small_stack) and a teardown-contract probe (make check-teardown). :max_tree_depth— the depth ceiling Nokogiri's gumbo exposes, for callers pinned to that reference. It counts the parser's open elements, so a fragment's implied<html>is one of them and 401 means 400 levels of content; it defaults to-1for no limit, because this library's stack budget is what bounds depth here, and a document past it is refused with{:error, :tree_too_deep}rather than handed back truncated.
Changed
A failed extraction inside the NIF can no longer reach a caller as a value. The extractors answered a term for both outcomes, so an allocation failure was a value a detail map could not tell from a term, and a caller read it back as the integer
42. They answer a status with the term written through an out parameter now, and every entry point turns a failure into the samebadargthe VM surfaces asArgumentError. Outside those paths — which are OOM-only — nothing a caller sees has changed.No C in this library reports a failure with a term any more: a failure is a status the caller turns into a
badarg, so it cannot be stored in a map or a list and read back as a number later.mix cialso runsmake check-failure-returns, which fails if a failure value is ever stored rather than returned, or if a numeric literal is used where a term is expected.cert-err33-cis on for the C that ships: it is what found the disregarded returns above.mix cialso runs the fault-injection sweep,mix test.fault_paths, as a step of its own — it arms an extraction to fail by index and requires every one of them to reach the caller.:max_errorsnow defaults to100instead of gumbo's-1, which disables the cap. gumbo records one error per unclosed element at EOF and each of those carries a copy of the open-element tag stack, so an N-deep unclosed document cost N² pointers: a 100 KB document allocated 3.2 GB and 200 KB would have taken ~12.8 GB, reachable from input 80x smaller than the accepted input limit. A later patch caps what one error keeps at its innermost 32 open-element tags, so a capped run holds at mostcap * 32entries rather thancap * depth.max_errors: -1still returns every error.materialize/2's error maps no longer carrycaret. gumbo renders a caret as the whole source line with column padding, so one caret costs O(line length), and materialize builds one map per error: measured, 40 002 errors on a 420 KB single-line document cost 426 KB of fresh binaries per 1 000 errors, a 12.7 s call, and a ~17 GB term.Gumbo.Error.caret/1still renders it for any live error, one at a time and bounded by a single line, andGumbo.Error.details/1still returns all ten fields for a single error. A materialized error map is that map minus:caret.Together these made
mix test.small_stack6.7x faster (38.1 s -> 5.7 s): its three "does not nest" rows produce 40 002 errors each on a one-line document, and the suite materializes every error.The build now applies
c_src/patches/gumbo-parser/0001-make-destroy_node-iterative.patchto a copy of the vendored sources underpriv/obj, leaving the submodule byte-identical to its 0.14.1 pin. The patch is upstream's own fix for the recursive teardown (google/gumbo-parser#387, which Nokogiri ships):destroy_nodeno longer recurses, so a deeply nested document is freed safely instead of by luck of the stack, and the<frameset>path that used to die inside the parse now survives at any depth.mix cirunsmake check-patch, so a patch that stops applying to a new pin is a red build rather than a build that quietly drops the fix;make check-clean-buildbuilds a throwaway copy of the build inputs from an empty object directory, so the fresh-clone path cannot rot either.The destructor no longer runs a disarming pass of its own:
c_src/gumbo_teardown.cexisted to make gumbo's recursive teardown safe, and the patch above removes the recursion instead. The probe that shared it is retargeted atgumbo_destroy_output, and its crash canary is now a hard gate rather than an informational note.The parse is no longer quadratic in nesting depth, and neither is the error vector. Two more patches in
c_src/patches/gumbo-parseranswer the two per-tag queries over the stack of open elements in O(1) instead of by walking it, and cap what one error keeps of that stack at its innermost 32 entries. Measured:<div>x 40 000 parses in 6.7 ms instead of 1 223 ms and<b>x 40 000 in 6.3 ms instead of 287 ms, so every shape in the matrix is linear; a 100 KB unclosed document allocates 9.3 MB instead of 3.2 GB, and 20 000 unmatched</p>at depth 20 000 allocates 18.7 MB instead of 6.4 GB. Tree construction is unchanged —make check-parityasserts that over a seeded corpus against the pinned sources — but an error deep in a document now reports at most 32 open-element tags.mix cigrew a sanitizer gate: the same differential harness, built against the patched sources with AddressSanitizer and UndefinedBehaviorSanitizer, over the corpus and ten adversarial shapes. It is the only net there is for a memory-safety bug inside gumbo itself, and it found one in the fourth patch as soon as it ran — the new attribute index was read as uninitialised memory on the first tag of every parse, which was invisible to builds that happened to get zeroed pages.The toolchain is declared rather than inherited:
.mise.tomlpins erlang, elixir and clang, and points at the Homebrew LLVM prefixes because Homebrew's llvm is keg-only and shipsclang-formatandclang-tidyoff PATH. The C gates ask PATH first and mise second, so they behave the same in an activated shell and a plain one.mise trustonce, thenmise install.The C now also runs under
clang-tidy, with the checks in.clang-tidyand the vendored sources and ERTS headers passed as system headers so that findings are attributed to this library's own code — the first run's 125 reports were all insidegumbo.h. Twelve findings fixed (signed literals in shifts and bitwise operations, and four size constants multiplied asunsigned int), five resolved on their merits, and the rest excluded with their counts and reasons written down.The C now gets the treatment the Elixir has had all along:
mix cirunsclang-formatagainst a checked-in.clang-format(Google's style, because that is what gumbo is written in, so a patch and the file it patches read the same way) and compiles this library's own C with warnings as errors and fifteen warning classes, including the implicit sign conversions the ERTS API invites. Our files only: the vendored copy is pinned and reformatting it would break every patch. Between them the two gates found five things, all fixed — a function that should have been static, anintwhere an enum bit was meant, a shell glob inside a block comment, a format string forwarded without an attribute saying so, and hand-aligned comments the checker would not accept.mix cialso runsclang --analyzeover this library's own C, which is the newest and least exercised code here and the only place AddressSanitizer cannot look: instrumenting the NIF would mean instrumenting the VM. Its canary file is a deliberate use-after-free and the gate requires the analyzer to report it, because a gate that cannot fail proves nothing — which took two failed attempts to verify, recorded in the ledger, including the finding that the analyzer cannot modelenif_alloc/enif_freeas a pair.A stack budget guard, so a recursion nobody has measured is refused rather than fatal: the parse is told how much stack it has (this library already measures the thread for the sized-thread decision) and stops before it runs out, reporting
{:error, :too_deep}. It is a backstop under the arithmetic, not a limit a caller has to think about, andmake check-stack-guardis what holds it to firing when it should — including the measurement that no document makes the C stack grow with its nesting depth any more.
Notes
Attribute counts, measured and then fixed, 2026-10-13. An element's attributes used
to cost the square of their count, because finish_attribute_name looked for a
duplicate name by scanning the attributes already collected: 80 000 distinct names
(0.7 MB of input) took 13.6 s, and the input cap allows about 2.4 million. They are
indexed now (c_src/patches/gumbo-parser/0004-max-attributes.patch, a per-tag FNV-1a
hash into buckets that double as the attributes outnumber them), so the same input
is linear: 80 000 in 21 ms, 800 000 in 306 ms. The same patch adds
:max_attributes, 1024 by default — the lever Nokogiri's gumbo exposes under the
same name — and exceeding it returns {:error, :too_many_attributes} rather than
dropping attributes, so a caller never receives a tree that differs from what the
pinned parser builds.
gumbo-parser 0.14.1's own gumbo_destroy_output frees the tree with
destroy_node, which recurses once per tree level, and 0.14.1 has no
maximum nesting depth. Two call sites reach that recursion.
The destructor's: a ~7 000-deep document segfaults the VM when the handle is garbage collected — a 320 KB stack holds 320 KiB / 48 = 6 826 levels of the 48-byte frame.
And handle_in_body's, which runs inside the parse: a deeply nested document
followed by a <frameset> start tag makes gumbo destroy the body subtree at
input depth mid-parse. <!doctype html> followed by 8 000 <div>s and a
<frameset> — a 40 KB document — killed the VM inside Gumbo.parse/1, before
the parse returned.
Both are now fixed at the source rather than worked around: the build applies
c_src/patches/gumbo-parser/0001-make-destroy_node-iterative.patch, upstream's own
fix from google/gumbo-parser#387, to a copy of the pinned sources, so
destroy_node no longer recurses and the submodule stays byte-identical to its
0.14.1 pin. The same document that died at 8 000 levels now survives 50 000 on the
same stack.
The parse still runs on a C stack sized from the input's length
(c_src/gumbo_parse_stack.c), because the patch fixes the recursions that are
known and not the class: depth is bounded by input — a nesting level costs at least
a three-byte start tag, and the worst frame in that recursion measures 64 bytes —
so documents small enough for the calling thread's own stack parse as before and
larger ones run on a thread of the size the input needs. Inputs over 16 MiB are
refused with {:error, :input_too_large}, since that is where the sized stack stops
being a reasonable reservation. make check-teardown holds the teardown to both
properties that matter — the allocator balance is exactly zero, and a 20 000-level
tree survives a 320 KB stack — and make check-clean-build builds from an empty
object directory, which is what a fresh clone or a hex consumer does.