- Zig 95.9%
- Shell 2.9%
- Nix 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 2m24s
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkxDixvQUrBMuaMQPvQNMF |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
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.nextreturns oneEventat 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 —
decodeAllocandtextAllocare 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_depthso a hostile document hits an error rather than a stack overflow. -
Empty elements are not a special case.
<foo/>reports astart_elementwithself_closingset and then a syntheticend_element, so a consumer walking the event stream never needs to branch on it. -
Encoding is handled.
toUtf8reads the declaration and transcodes latin-1 documents, decoding the C1 range as Windows-1252 — which is what files labellediso-8859-1overwhelmingly 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..lenientpasses an unknown reference through untouched;.strictreportserror.UnknownEntity..htmladditionally resolves the 248 HTML 4.01 named references ——, ,’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.
.htmlchanges nothing for those feeds. It earns its keep on the feeds that put 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="podcastowner@example.com"
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 | 29–582 | default namespace, no prefixes |
| Whatever (John Scalzi) | Atom and RSS | 150 / 144 | same content, both dialects |
| OPML 2.0 specification examples (all five) | OPML | 22–77 | 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 </> 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
— that an &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
License
MIT.