No description
  • Zig 95.9%
  • Shell 2.9%
  • Nix 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 7eacdb1868
All checks were successful
test / test (push) Successful in 2m24s
Reference the standards in comments and list them in the README
The parser named XML, HTML 4.01 entities, ISO-8859-1 and Windows-1252 without
ever pointing at any of them, and the README listed no standards at all. The
doc comments now link the specification each thing comes from, and the README
carries two tables.

They are two tables rather than one because the distinction matters: XML,
namespaces, the HTML entity set and the two character encodings are what the
parser itself implements, while RSS, Atom, OPML, D-Bus introspection and NZB
are vocabularies it has no opinion about -- they are what the test corpus is
drawn from and what the --feed profile in the command-line tool knows how to
check. Putting them in one list would suggest the library understands feeds,
which is exactly the coupling that was kept out of it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FkxDixvQUrBMuaMQPvQNMF
2026-08-30 22:09:13 -05:00
.forgejo/workflows Add CI, and a script that checks the live corpus 2026-08-30 18:12:36 -05:00
LICENSES Initial commit: a small XML pull parser for Zig 2026-08-30 16:01:48 -05:00
src Reference the standards in comments and list them in the README 2026-08-30 22:09:13 -05:00
tools Add CI, and a script that checks the live corpus 2026-08-30 18:12:36 -05:00
.gitignore Initial commit: a small XML pull parser for Zig 2026-08-30 16:01:48 -05:00
build.zig Add an event-stream dumper 2026-08-30 16:11:18 -05:00
build.zig.zon Initial commit: a small XML pull parser for Zig 2026-08-30 16:01:48 -05:00
flake.lock Initial commit: a small XML pull parser for Zig 2026-08-30 16:01:48 -05:00
flake.nix Provide a CA bundle in the dev shell for zig's fetcher 2026-08-30 20:11:03 -05:00
README.md Reference the standards in comments and list them in the README 2026-08-30 22:09:13 -05:00
REUSE.toml Initial commit: a small XML pull parser for Zig 2026-08-30 16:01:48 -05:00

zxml

A small XML pull parser for Zig 0.16, with no dependencies.

It is deliberately not a general-purpose XML implementation. What it covers is elements, attributes, text, CDATA, comments, processing instructions, and the predefined plus numeric entities — the subset that data formats such as NZB, RSS, and Atom are actually built from. There is no internal DTD subset evaluation and no external entity resolution, which is a feature rather than an omission: a document cannot make the parser open a file or reach the network.

Installation

$ zig fetch --save git+https://git.ocjtech.us/jeff/zxml.git

Standards

What the parser itself implements:

Standard Title Support in zxml
XML 1.0 (5th ed.) Extensible Markup Language Elements, attributes, text, CDATA, comments, processing instructions, the XML declaration, and the DOCTYPE declaration as an opaque token. No DTD subset evaluation and no entity resolution beyond the predefined and numeric references
XML Namespaces 1.0 Namespaces in XML Prefixes surfaced as text — localNameOf, prefixOf, nameIs — rather than resolved against their declarations
HTML 4.01 entities Character entity references The 248 named references XML does not define, under EntityPolicy.html
ISO/IEC 8859-1 Latin alphabet No. 1 Transcoded to UTF-8 by toUtf8 when declared
Windows-1252 Windows Latin-1 The C1 range decoded as CP1252, which is what documents labelled iso-8859-1 overwhelmingly contain

Vocabularies the parser has no opinion about, but which the test corpus and the --feed profile cover:

Standard Title Where it appears
RSS 2.0 Really Simple Syndication --feed checks a channel's title, link, and description, and that an item has a title or a description
RFC 4287 The Atom Syndication Format --feed checks a feed's and an entry's id, title, and updated
RFC 4685 Atom Threading Extensions The thr: prefix in the corpus
OPML 2.0 Outline Processor Markup Language --feed checks for head and body, and an outline's text attribute
RFC 822 Date and time specification --feed reports a pubDate that is not in this form
NZB 1.1 newzBin NZB Test corpus, and the format znzb decodes
D-Bus introspection Object introspection format Test corpus

Design

  • Pull, not push. Reader.next returns one Event at a time. There are no callbacks to thread state through and no tree to allocate.

  • Borrowed, not copied. The parser works over a complete in-memory document and hands out slices into it, so it never allocates unless you ask it to — decodeAlloc and textAlloc are the only entry points that take an allocator.

  • Well-formedness is checked. Mismatched end tags, duplicate attributes, unquoted attribute values, and unterminated constructs are errors, and nesting is capped at max_depth so a hostile document hits an error rather than a stack overflow.

  • Empty elements are not a special case. <foo/> reports a start_element with self_closing set and then a synthetic end_element, so a consumer walking the event stream never needs to branch on it.

  • Encoding is handled. toUtf8 reads the declaration and transcodes latin-1 documents, decoding the C1 range as Windows-1252 — which is what files labelled iso-8859-1 overwhelmingly really contain. It returns the input unchanged when no conversion is needed.

  • Entities are policy. Documents assembled by string concatenation contain bare & characters often enough that rejecting them would reject documents every other client accepts. .lenient passes an unknown reference through untouched; .strict reports error.UnknownEntity.

    .html additionally resolves the 248 HTML 4.01 named references — &mdash;, &nbsp;, &rsquo; and the rest — which XML does not define but RSS and Atom feeds use anyway.

    Reach for it only where the text really is HTML-adjacent. In the two Podcasting 2.0 feeds in the test corpus every one of the 2,261 named references sits inside a CDATA section, and CDATA is literal by definition — no policy touches it, because those entities belong to the HTML payload and to whatever renders it. .html changes nothing for those feeds. It earns its keep on the feeds that put &nbsp; in a bare <title>.

Usage

const std = @import("std");
const xml = @import("zxml");

var reader: xml.Reader = .init(document);
while (true) {
    switch (try reader.next()) {
        .start_element => |e| {
            // `attributes` borrows storage inside the reader and dies at the
            // next call to `next`, so copy anything you keep.
            if (xml.nameIs(e.name, "segment")) {
                const number = e.attr("number") orelse continue;
                const id = try reader.trimmedTextAlloc(gpa, .lenient);
                defer gpa.free(id);
                std.debug.print("{s}: {s}\n", .{ number, id });
            } else {
                try reader.skipElement();
            }
        },
        .eof => break,
        else => {},
    }
}

reader.depth is the nesting level after the most recent event, which is what makes subtree-scoped loops straightforward: a subtree opened at depth d is closed by the end_element that brings depth back below d. skipElement and textAlloc are built on exactly that.

Namespaces

Prefixes are surfaced as text rather than resolved against their declarations, which is enough for formats that use a fixed, known vocabulary. localNameOf, prefixOf, and nameIs do the splitting, and Element.attrLocal matches an attribute regardless of prefix:

xml.nameIs("nzb:segment", "segment")        // true
xml.prefixOf("newznab:attr").?              // "newznab"

Events

Event Payload
declaration version, encoding, standalone from <?xml … ?>
doctype the declaration verbatim, internal subset included, never evaluated
start_element name, attributes, self_closing
end_element the name being closed, synthesized for <foo/>
text character data, entities still in place
cdata the contents of a CDATA section, literal
comment the contents of a comment
pi target and data of a processing instruction
eof the document is exhausted, with nothing left open

Reaching the end of input with elements still open is error.UnexpectedEndOfDocument, not an eof.

Command line

The package also builds a zxml binary that dumps the event stream, one event per line, indented by nesting depth. The usual question about a misbehaving document is not "what does the tree look like" but "where did the parser stop agreeing with me", which a flat event stream answers directly. It doubles as a worked example of the API.

$ zig build run -- example.xml
declaration version="1.0" encoding="UTF-8"
<rss> xmlns:podcast="https://podcastindex.org/namespace/1.0" version="2.0"
  <channel>
    <title>
      text "Podcasting 2.0 Namespace Example"
    </title>
    <podcast:locked> owner="[email protected]"
      text "yes"
    </podcast:locked>

Whitespace-only text nodes are elided unless -w asks for them, since a pretty-printed document is mostly indentation. -c prints a tally by event kind instead of the stream, -q checks well-formedness and prints nothing, and -r shows text before entity decoding. A malformed document reports the line and column where the parser stopped, and exits non-zero:

$ zxml broken.xml
error: broken.xml:12:5: MismatchedEndTag

A file of - reads standard input, so a feed can be piped straight in.

Validating

--check reports on a document instead of dumping it: whether it is well-formed, what encoding it claims and whether it really is that, whether every text node and attribute decodes to valid UTF-8, and how much headroom it leaves against the parser's limits.

$ zxml --check podnews.xml
podnews.xml: ok
  well-formed        yes
  encoding           utf8
  elements           3796  (depth 5 / 256)
  attributes         max 7 / 64 on <rss>
  text nodes         5064, all valid UTF-8
  cdata              301 sections
  decoding           .lenient and .html agree

--feed adds a vocabulary profile on top, recognising RSS 2.0, Atom, and OPML from the root element and checking only what those specifications actually require — a channel's title, link, and description; an Atom entry's id, title, and updated; an outline's text attribute; an item having at least one of title or description; and that a pubDate is RFC 822 rather than the ISO 8601 that generators sometimes emit:

$ zxml --feed broken.xml
broken.xml: problems
  ...
  document kind      RSS 2.0
  items              1
  item 1: has neither <title> nor <description>  (line 1)
  channel: missing <link>  (line 1)
  2 problems

It reports nothing for optional elements, since a feed that omits one is not broken and crying wolf about it would make the tool useless on the real web. A document that is not a feed gets no opinion beyond the XML battery. Exit status is 0 when clean and 1 when not, and -q suppresses the report and leaves only that status, which is what to use in a script.

The feed vocabulary lives in the command-line tool, in src/feeds.zig. The library is a general XML parser and has no opinion about RSS.

Compatibility

The test suite covers the NZB specification's example document and a Podcasting 2.0 feed condensed from the podcast-namespace specification's own example — namespace prefixes, self-nesting itunes:category, nested podcast:value/podcast:valueRecipient, <content:encoded> CDATA, an xml-stylesheet processing instruction, and numeric character references.

Beyond the suite, the parser has been run over production feeds:

Feed Size Elements Prefixes Parse
podcast-namespace example.xml 17 KB 172 2 clean
Daily Tech News Show 113 KB 152 4 clean
Twenty Thousand Hertz 975 KB 272 3 49 ms
Security Now (audio and video) 121 KB 407 / 417 9 clean
Podnews Daily 2.3 MB 3,796 4 61 ms
Accidental Tech Podcast 3.0 MB 8,500 2 77 ms
Linux Unplugged 10 MB 15,622 5 47 ms

Podcast RSS is only one dialect, so the corpus also covers two unrelated vocabularies:

Document Kind Elements Notes
Daring Fireball, Rust Blog, xkcd, GitHub releases Atom 29582 default namespace, no prefixes
Whatever (John Scalzi) Atom and RSS 150 / 144 same content, both dialects
OPML 2.0 specification examples (all five) OPML 2277 attribute-only elements, depth 6
ISBN International RangeMessage data 6,796 internal DTD subset, dotted names

Atom is a useful contrast because it puts its whole vocabulary in a default namespace, so nothing carries a prefix except extensions such as thr:; the podcast feeds are the opposite, prefixing almost everything. Scalzi's blog publishes the same posts as both Atom and RSS, which isolates the dialect from the content. OPML inverts the usual shape again: an <outline> has no text at all, carrying its data entirely in attributes, and nests deeper than any feed here — the specification's states.opml reaches depth 6.

The ISBN RangeMessage is the one document in the corpus that is not a feed at all, and it exercises three things none of the others do: a declaration quoted with apostrophes, element names containing dots (EAN.UCC), and a real internal DTD subset — 604 bytes of <!ELEMENT> declarations, consumed whole, returned verbatim, and never evaluated. Parsing it yields 287 registration groups and 1,878 range rules in 23 ms, and reading 978 back as International ISBN Agency and 978-0 as English language confirms the content survives the trip.

Between them they cover eleven distinct namespace prefixes — podcast:, itunes:, media:, rawvoice:, googleplay:, creativeCommons:, sy:, content:, atom:, dc:, and a publisher's own jb: — along with 331 CDATA sections, 146,000 escaped &lt;/&gt; pairs, and 2,402 astral-plane code points arriving as numeric character references. Every text node and attribute value decodes to valid UTF-8. As the parser counts them — which excludes the markup inside CDATA sections, where a grep would find far more — they peak at 13 attributes on an element and 5 levels of nesting, against caps of max_attributes (64) and max_depth (256).

They also disagree with each other in useful ways. Most are pretty-printed, so most of their text nodes are indentation; ATP is written compact, with 2 whitespace-only nodes out of 6,375, which is why the dumper elides such nodes by default rather than assuming they exist. ATP is likewise 85% CDATA by volume, where Linux Unplugged escapes the same kind of HTML into text instead. DTNS, generated by WordPress, is 91% CDATA and writes its <rss> start tag across four lines with tabs between the attributes and a newline before the closing bracket — the only feed here that does. Twenty Thousand Hertz, from Squarespace, is the only one to put a comment in the prolog, between the declaration and the root element, and it is 98% CDATA.

The two large feeds take opposite approaches to embedded HTML — one wraps it in CDATA, the other escapes it into text — and between them they establish that neither is mangled. In particular, decoding all 29,627 text nodes of the escaping feed produces byte-identical output under .lenient and .html, because decoding is a single left-to-right pass and cannot re-resolve the &mdash; that an &amp;mdash; just produced.

Testing

$ zig build test

That includes a std.testing.fuzz target, whose corpus replays on every run. Continuous fuzzing with zig build test --fuzz does not currently work on Zig 0.16.0: fuzz mode fails to build the compiler's own test runner, and a trivial one-test file reproduces it. The target is there for the toolchain that fixes it.

Used by

  • znzb — NZB file decoder
  • znewznab — newznab API response decoder

License

MIT.