PureXML
Go to file
Mihaela Mihaljevic 771fa4b457
XSLT/XPath conformance: close 68 Apache Xalan cases (#254)
* fix(xslt): keep namespace nodes on an included literal result element

An xsl:include'd stylesheet's declarations are absorbed after collectUnit
returns, and each literal result element resolves its 7.1.1 namespace nodes
by walking the weak parent chain back to its own included stylesheet element.
Those elements lived only in the write-only retainedRoots array, so an
optimized build released them before the deferred resolution ran and the
in-scope xmlns declarations vanished, dropping the namespace nodes the
included element should carry. The output was correct in debug and wrong in
release. Pin the retained roots across the resolution with
withExtendedLifetime. Imports were unaffected because they compile eagerly
while their tree is still a live local.

Catches Apache Xalan conformance case lre/lre05, surfaced by running the
apache/xalan-test corpus.

* fix(xslt): select a called template by import precedence, not document order

xsl:call-template looked up the target with the first match by position in the
flattened template list. Imports fold in before the including unit's own and
included templates, so when an import and an include both define a template
with the same name, the imported one was wrongly chosen. An included template
carries the including unit's higher import precedence and must win (XSLT 1.0
section 6). The lookup now picks the highest-precedence definition among the
same-name templates.

Closes Apache Xalan conformance cases namedtemplate17, namedtemplate18,
namedtemplate19, and namespace13 (removed from the xalan-test baseline). Full
suite 1608 tests green in debug and release; the Xalan suite passes.

* fix(xpath): string-value of a comment or PI node is its own data

The XPath string-value of a comment node is its character data and of a
processing-instruction node is its data (XPath 1.0 5.6, 5.7), but TreeNode
returned the empty string for both, so value-of select="comment()", and any
matching, sorting, or comparison over comment/PI nodes, produced nothing.

stringValue was conflated with the text-only concatenation that forms an
element's or root's string-value, where comment and PI descendants correctly
contribute nothing (5.1, 5.5). That concatenation now uses a separate
textContent, so an element's string-value still excludes comment and PI text
while a comment or PI node reports its own data.

Closes 24 Apache Xalan conformance cases across node, axes, position, select,
modes, conflictres, and variable (removed from the baseline). Full suite 1609
tests green in debug and release; the Xalan suite passes.

* fix(xslt): resolve top-level variables in any order, not document order

Top-level variables and parameters may reference one another regardless of
declaration order (XSLT 1.0 section 11.4), but they were evaluated once
top-to-bottom, so a forward reference (a global declared before the one it
uses) saw an undefined value and resolved to empty. Globals now evaluate to a
fixpoint: each pass re-evaluates the unsettled ones with the latest bindings,
so a dependency chain in any order settles in at most N passes, a pass that
changes nothing stops early, and a circular reference stops at the cap.

Extracted the evaluation into Transformer.evaluatedGlobals (XSLTGlobals.swift).
Closes Apache Xalan conformance cases variable33, variable34, variable35. Full
suite 1611 tests green in debug and release; the Xalan suite passes.

* fix(xslt): an empty-content variable with no select is the empty string

XSLT 1.0 section 11.2 makes <xsl:variable name="x"/> (empty content, no select)
equivalent to select="", so boolean() of it is false. It was always built as a
result-tree-fragment document node, which boolean() reports as true. A variable
with no select and empty content now yields the empty string; only non-empty
content remains a result tree fragment.

Closes Apache Xalan conformance cases boolean43 and boolean87. Full suite 1612
tests green in debug and release; the Xalan suite passes.

* fix(xpath): allow whitespace around the :: axis separator

XPath 1.0 allows whitespace between tokens, so `child :: sub` is the child axis
with node test `sub`. The parser only matched `::` immediately after the axis
name, so a spaced separator fell back to the child axis with the axis name read
as an element test, selecting nothing. parseAxis now skips whitespace after the
axis name and after `::`; on no match the reset to before the name is unchanged,
so a bare name step is unaffected.

Closes Apache Xalan conformance cases select16, select27, select28. Full suite
1613 tests green in debug and release; the Xalan suite passes.

* feat(xslt): document('') returns the stylesheet's own document

XSLT 1.0 section 12.1 makes an empty URI reference the document containing the
expression, so a stylesheet can read its own xsl:* nodes (embedded lookup
tables, self-inspection). The reference went straight to the document loader,
which has no entry for it, so document('') returned an empty node-set. The
transformer now keeps its parsed stylesheet tree and the document function
returns it for an empty reference, cached like any loaded document; a #fragment
still applies.

Closes Apache Xalan conformance cases select67, select68, idkey50, mdocs17,
namespace20. Full suite 1614 tests green in debug and release; Xalan passes.

* fix(xslt): implement unparsed-entity-uri from the source DTD

unparsed-entity-uri was a stub returning the empty string. It now reads the
source document type's unparsed (NDATA) entities, threaded to the function table
alongside the document loader, and returns the named entity's system URI; an
unknown name still returns empty.

Closes Apache Xalan conformance case expression02. Full suite 1615 tests green
in debug and release; the Xalan suite passes.

* fix(xpath): substring-after(s, '') returns s

Per the XPath 1.0 errata, an empty second argument matches at the start of the
string, so substring-after returns the whole string. The function bailed to the
empty string on an empty separator. substring-before(s, '') stays empty, as the
spec requires.

Closes Apache Xalan conformance case string143. Full suite 1615 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): match a template mode by expanded name, not its prefix

XSLT 1.0 section 5.7 compares a mode by expanded name, so two prefixes bound to
the same namespace name the same mode. The prefixed QName string was stored and
compared verbatim, so apply-templates mode="foo:m" missed a template with
mode="moo:m" even when foo and moo share a namespace. Mode names on xsl:template
and xsl:apply-templates now resolve to {uri}local at parse time.

Moved the parser's declaration helpers to XSLTParserDeclarations.swift to keep
the file within the length budget.

Closes Apache Xalan conformance case modes16. Full suite 1616 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): name a variable by expanded QName, not its prefix

XSLT 1.0 section 11.1 names a variable by expanded name, so two prefixes bound
to the same namespace name the same variable. The declared name and the
$prefix:local reference were compared as raw strings, so $new:x did not see a
variable declared txt:x even when new and txt share a namespace. A prefixed
declared name now resolves to {uri}local at parse time, and the XPath evaluator
falls back to the namespace-resolved name when the raw key is unbound, so a
caller-supplied raw binding still works and unprefixed names are unchanged.

Closes Apache Xalan conformance case variable55. Full suite 1617 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): a match pattern predicate sees the global variables

XSLT 1.0 section 5.2 evaluates a pattern predicate with the top-level (global)
variables in scope, but pattern matching ran with no variables bound, so a
template like match="foo[. > $screen]" never matched ($screen was undefined).
The transformer supplies the global bindings to the match cache; they are
constant per transform, so they do not enter the cache key.

Closes Apache Xalan conformance cases match14 and idkey43. Full suite 1618 tests
green in debug and release; the Xalan suite passes.

* fix(emit): make a comment ending in a hyphen or containing -- well-formed

XML forbids `--` inside a comment and a `-` immediately before `-->`, so a
comment whose text ends with `-` was emitted as <!--text---> (malformed). The
serializer now inserts a space after each offending hyphen (the XSLT 1.0 16
recovery), so `text-` becomes `text- `. Applied in both the Serializer and the
streaming Writer via a shared Escaping.comment helper.

Closes Apache Xalan conformance cases output89 and output90. Full suite 1619
tests green in debug and release; the Xalan suite passes.

* fix(xslt): preserve whitespace adjacent to a CDATA section in a template

Text and CDATA are one text node in the data model, so <out> <![CDATA[test]]>
</out> is the node " test ", whose surrounding spaces must survive whitespace
stripping (XSLT 1.0 3.4). The template body dropped each whitespace-only text
node independently, so the spaces around the CDATA vanished. Adjacent text and
CDATA children now coalesce into one run, dropped only when the whole run is
whitespace; a purely whitespace run between elements is still removed.

Closes Apache Xalan conformance case whitespace13. Full suite 1620 tests green
in debug and release; the Xalan suite passes.

* fix(xslt): xsl:processing-instruction/comment keep only text-node content

XSLT 1.0 sections 7.4 and 7.6 make it an error for xsl:comment or
xsl:processing-instruction content to create a non-text node; the recovery is
to ignore the offending node together with its content. PureXML took the
string-value of all instantiated content, so a copied element leaked its text
into the PI or comment. The content now contributes only its text and CDATA
node items.

Closes Apache Xalan conformance case copy60. Full suite 1621 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): xsl:attribute keeps only text-node content (errata E27)

XSLT 1.0 errata E27 makes it an error for xsl:attribute content to create a
non-text node, recovering by ignoring the offending node together with its
content (the same rule as xsl:comment/processing-instruction). PureXML took the
string-value of all instantiated content, so a copied element leaked its text
into the attribute value. The content now contributes only its text and CDATA
node items, reusing textNodesOnly.

Closes Apache Xalan conformance cases copy56 and copy58. Full suite 1622 tests
green in debug and release; the Xalan suite passes.

* fix(xslt): xsl:copy-of carries an element's inherited namespace nodes

XSLT 1.0 11.3 copies a node together with its namespace nodes, so copying an
element must reproduce its full in-scope set, including a declaration inherited
from an ancestor. copy-of reproduced only the element's own xmlns attributes,
dropping inherited ones. The inherited declarations are now added to the copied
element and the serializer's namespace fixup drops any already in the result
scope.

Closes Apache Xalan conformance cases copy09, copy27, copy48, mdocs09. Full
suite 1623 tests green in debug and release; the Xalan suite passes.

* fix(xslt): xsl:element with an unusable name passes its content through

XSLT 1.0 7.1.2 makes a name that is not a QName an error; the recovery (the
pinned element-name-not-QName "pass-through") emits the element's content
without the wrapper. The check rejected only an empty or multi-colon name, so
an undeclared prefix (none:foo) or a name that is not an NCName (this is a
string) still produced a malformed element. The name is now also rejected when
a part is not an NCName or its prefix is unbound.

Closes Apache Xalan conformance cases namespace40, namespace42, namespace43,
namespace60, namespace73, namespace79. Full suite 1624 tests green in debug and
release; the Xalan suite passes.

* fix(xslt): xsl:copy of the root node honors use-attribute-sets

Copying the root node produces no element of its own, so the use-attribute-sets
attributes have nowhere to attach on the copy and instead join the enclosing
result element (XSLT 1.0 7.5). copyInstruction dropped them for the root case;
they are now emitted ahead of the copied content so the enclosing element picks
them up.

Closes Apache Xalan conformance cases attribset27 and attribset29. Full suite
1625 tests green in debug and release; the Xalan suite passes.

* refactor(xslt): address review (canonical name/whitespace checks, html comment safety)

Review follow-ups on the conformance series:

- xsl:element name validation now uses the canonical Parsing.XMLCharacter
  .isValidName instead of a hand-rolled grapheme check, which had narrowed valid
  XML names (it dropped a name using a non-ASCII NameChar such as U+00B7).
- The template-body whitespace check reuses Parsing.XMLCharacter.isWhitespace
  instead of a third local copy of the same predicate.
- The HTML serializer applies the section-16 comment recovery too, so a
  method="html" comment ending in a hyphen (or containing two) is well-formed;
  the C14N canonicalizer keeps comments verbatim as its contract requires.

Adds regression tests for a non-ASCII NameChar element name and for html
comment hyphen-safety. Full suite 1626 tests green in debug and release; the
Xalan suite passes.
2026-06-24 00:13:14 +02:00
.claude/commands feat(parsing): streaming honors the declared encoding; streaming ISO-2022-JP 2026-06-09 17:33:52 +02:00
.githooks ci: split workflows for truthful badges and attribution gates 2026-06-08 04:37:32 +02:00
.github feat(xpath,xslt,wasm): evaluation budget, tailored collation, WASI runtime proof 2026-06-11 06:26:13 +02:00
Benchmarks feat(xpath,parsing): public streaming surface, linear document-order at scale 2026-06-11 06:06:31 +02:00
docs docs(design): derive the exact Particle Valid (Restriction) procedure (#252) 2026-06-23 10:50:38 +02:00
PublicAPITests feat(xpath,xslt,wasm): evaluation budget, tailored collation, WASI runtime proof 2026-06-11 06:26:13 +02:00
scripts test(ci): add an opt-in coverage gate; close the 0% value types it surfaces 2026-06-22 16:38:30 +02:00
Sources XSLT/XPath conformance: close 68 Apache Xalan cases (#254) 2026-06-24 00:13:14 +02:00
Tests XSLT/XPath conformance: close 68 Apache Xalan cases (#254) 2026-06-24 00:13:14 +02:00
.gitattributes chore: scaffold PureXML pure-Swift XML package 2026-06-07 23:20:20 +02:00
.gitignore chore: extend .gitignore with editor, venv, node, and local-settings entries 2026-06-07 23:35:22 +02:00
.swiftformat style(format): adopt --importgrouping testable-bottom from canonical rules-swift 2026-06-18 20:33:07 +02:00
.swiftlint.yml chore(rules): align lint config and pointer with canonical rules-swift 2026-06-18 20:30:46 +02:00
AGENTS.md Mirror mandatory Swift rules 2026-06-18 22:00:41 +02:00
ATTRIBUTION.md chore: scaffold PureXML pure-Swift XML package 2026-06-07 23:20:20 +02:00
CHANGELOG.md XSLT/XPath conformance: close 68 Apache Xalan cases (#254) 2026-06-24 00:13:14 +02:00
CLAUDE.md docs(rules): research-first rule, consult research repo and literature before guessing 2026-06-15 00:30:01 +02:00
CODE_OF_CONDUCT.md chore: scaffold PureXML pure-Swift XML package 2026-06-07 23:20:20 +02:00
CONTRIBUTING.md chore: scaffold PureXML pure-Swift XML package 2026-06-07 23:20:20 +02:00
GEMINI.md docs: align stale figures with code and the enforced XSTS gate 2026-06-19 23:32:04 +02:00
LICENSE chore: scaffold PureXML pure-Swift XML package 2026-06-07 23:20:20 +02:00
Package.swift feat(xpath,parsing): public streaming surface, linear document-order at scale 2026-06-11 06:06:31 +02:00
README.md docs(roadmap): add epic #195 and sync XSTS counts 2026-06-20 13:50:55 +02:00
SECURITY.md docs: align stale figures with code and the enforced XSTS gate 2026-06-19 23:32:04 +02:00
SUPPORT.md chore: scaffold PureXML pure-Swift XML package 2026-06-07 23:20:20 +02:00

PureXML

Style and namespacing Swift macOS Swift Linux Swift Windows Swift WASM License: MIT

PureXML is a dependency-free XML package written entirely in Swift.

The goal is a Linux-, Windows-, and WebAssembly-compatible XML reader/writer that does not pull in libxml2, expat, or Foundation's XMLParser. The package is intentionally strict about portability:

  • no external SwiftPM dependencies
  • no bundled C sources
  • no Foundation requirement in the library target
  • root Swift package layout
  • macOS, Linux, Windows, and WASI build gates

It is a sibling project to PureYAML and follows the same structure, rules, and verification gates.

Roadmap

The roadmap uses the TileDown Mermaid palette: green for shipped work, yellow for review, purple for epic grouping, and gray for open work.

flowchart TB
  classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
  classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
  classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
  classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
  LDone["In main now"]:::done
  LReview["PR in review"]:::review
  LEpic["Epic grouping"]:::epic
  LTodo["Open issue, no PR"]:::todo
  Core["Core shipped: #1 push, #19 SAX, #28 encoding, #29 entities, #31 tree, #32 writer, #33 reader, #34 save, #35 chars"]:::done
  Query["Query + validation shipped: #21 #22 #23 query, #30 regex, #2 XSD/RELAX NG, #25 Schematron"]:::done
  Transform["Transform + HTML shipped: #3 XSLT, #20 HTML, #24 XInclude, #26 C14N, #27 Catalog"]:::done
  Parity["Parity completion shipped: #61 (XSLT keys/output, XSD union/import/identity, RELAX NG compact, HTML5 doc structure, C14N 2.0 trim)"]:::done
  Validator["Validation framework + recovering reader shipped: #92 (one located severity-aware ValidationError, never-crash read)"]:::done
  Editor["Editor integration shipped: #94 (source spans, unified lint, schema completions, structured quick-fixes)"]:::done
  Conformance["Conformance push shipped: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82"]:::done
  LDone --> LReview
  LReview --> LEpic
  LEpic --> LTodo
  LTodo --> Core
  Core --> Query
  Query --> Transform
  Transform --> Parity
  Parity --> Validator
  Validator --> Editor
  Editor --> Conformance

The goal is full libxml2 feature parity in pure Swift with zero dependencies, by clean-room reimplementation (behavior reproduced from the private PureXML-research analysis, no upstream source copied). The streaming core, the schema languages (DTD, XSD, RELAX NG, Schematron), XPath 1.0, and the transform/canonicalization stack are shipped; the remaining depth gaps are tracked under epic #71. Deliberate non-goals: network fetching (nanohttp/nanoftp) and the threading/memory infrastructure stay out, and external resolution is opt-in through an injected resolver so XXE stays closed.

The original epics, the documented-subset parity work (#61), the validation framework and recovering reader (#92), and the editor-integration layer (#94) are all shipped (summarized above). The beyond-parity libxml2 conformance audit (#71), its parser-breadth sub-epic (#74), and the parity-frontier epic (#105: output encodings, streaming validation, the last encodings, the analysis findings, and the first W3C-suite pass) are all complete. Feature parity is done; the remaining distance is production readiness for IDE use, tracked under epic #167 (schema authority, differential oracle, located diagnostics). Conformance suite maintenance (#122, closed) leaves #130 (XSLT xalan burn-down) as the only active runner work; see docs/roadmap.md.

flowchart TB
  classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
  classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
  classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
  classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
  Epic71["#71 beyond-parity libxml2 audit: complete"]:::epic
  Epic74["#74 parser-breadth sub-epic: complete"]:::epic
  Done["All audit children closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90, serialization #91, C14N #85, XPath/XPointer #88, XInclude #87, validators #101, HTML5 #83 #84, encodings #97 #99 #102 #103"]:::done
  Epic71 --> Done
  Epic74 --> Done
  Epic105["#105 parity frontier: complete (#106 #107 #108 output encodings, streaming validation, last encodings; findings #109-#119; first W3C pass #120)"]:::epic
  Epic121["#121 xmlconf full archive: complete"]:::epic
  Oasis["#123 OASIS/NIST: zero deviations"]:::done
  Sun["#124 Sun section: complete, zero deviations"]:::done
  IBM["#125 IBM section: clean to two documented classes"]:::done
  Japanese["#126 japanese section: complete, zero deviations"]:::done
  Eduni["#127 eduni: runner + exact baselines, four classified deviations"]:::done
  StrictSubset["#128 strict internal-subset profile: complete, xmltest 185/186"]:::done
  ScalarLex["#135 scalar-level lexing: complete"]:::done
  NSConstraints["#136 namespace constraints: complete"]:::done
  EncodingMismatch["#137 encoding-declaration mismatches: complete"]:::done
  BaseURI["#138 per-entity base URIs: complete"]:::done
  Epic122["#122 conformance suite maintenance: complete (#130 survivor)"]:::epic
  XSTS["#129 XSTS runner shipped; gates #145-#148 at 0/39/0/26"]:::review
  XSLTTS["#130 XSLT 1.0 suite (xalan-test): ~126 baseline failures"]:::review
  Parity100["Behavioral parity closed: #141 top-level params, #142 public streaming, #143 XPath budget, #144 WASI runtime proof, pl/ru sort collation"]:::done
  Epic139["#139 faster than libxml2, measured + streaming (benchmarks in docs/benchmarks.md; quadratics killed; unsafe authorized)"]:::epic
  Audit140["#140 XSLT validation-framework audit"]:::todo
  Epic139 --> Parity100
  Epic139 --> Audit140
  RNGTS["#131 RELAX NG spec suite: complete, one documented class"]:::done
  C14NTS["#132 C14N spec vectors: complete"]:::done
  Epic167["#167 production readiness for IDE use (v0.2.0)"]:::epic
  Oracle171["#171 schema differential harness"]:::todo
  CrossDoc161["#161 cross-document composition"]:::todo
  Gates145["#145-#148 XSTS gates: burn-down to 0"]:::review
  Loc169["#169 located schema diagnostics"]:::todo
  Epic195["#195 xsi:type and substitution-group derivation correctness"]:::epic
  Epic105 --> Epic121
  Epic105 --> Epic122
  Epic121 --> Oasis
  Epic121 --> Sun
  Epic121 --> IBM
  Epic121 --> Japanese
  Epic121 --> Eduni
  Epic121 --> StrictSubset
  Epic121 --> ScalarLex
  Epic121 --> NSConstraints
  Epic121 --> EncodingMismatch
  Epic121 --> BaseURI
  Epic122 --> XSTS
  Epic122 --> XSLTTS
  Epic122 --> RNGTS
  Epic122 --> C14NTS
  Epic122 --> Epic167
  Epic167 --> Oracle171
  Epic167 --> CrossDoc161
  Epic167 --> Gates145
  Epic167 --> Loc169
  Epic167 --> Epic195

Status

Current focus: production readiness for IDE use (#167). XSTS settled baselines (valid schemas rejected / invalid schemas accepted / valid instances rejected / invalid instances accepted), enforced in Tests/XSTSSuiteTests.swift: 0 / 39 / 0 / 26. See docs/roadmap.md for milestones and open tasks.

PureXML is a working, dependency-free XML library today: parse, emit, validate, query, and stream documents on macOS, Linux, Windows, and WASM. The test suite currently runs 1448 tests in 258 suites (swift test).

Shipped (libxml2-aligned surface)

  • Model (PureXML.Model): Node, Element, Attribute, QualifiedName, and a mutable TreeNode for in-place editing (insert, remove, replace, copy, parent navigation).
  • Parsing (PureXML.Parsing): iterative streaming parser over strings, bytes, or incremental character/byte sources. Pull events via EventReader, SAX2-style callbacks via SAXHandler, and a pull cursor via TextReader (libxml2 xmlTextReader). Handles elements, attributes, text, predefined and numeric entities, comments, CDATA, and processing instructions. Namespace bindings (xmlns) are resolved to URIs on parse.
  • Encoding: UTF-8/16/32 (with BOM sniffing) from raw bytes or streaming byte input, plus the ISO-8859 single-byte family, the Windows code pages (1250-1258), KOI8-R/U, and the CJK multi-byte encodings (Shift-JIS, EUC-JP/KR/TW, GBK, GB18030, Big5, ISO-2022-JP).
  • DTD (opt-in): <!DOCTYPE> is off by default (Limits.allowDoctype: false) to keep XXE and entity-expansion closed. When enabled, the internal subset is parsed; internal general entities expand with a bounded cap; external entities and external subsets are refused unless an injected EntityResolver supplies replacement text.
  • Validation (PureXML.Validation): one composable framework (the OpenAPIKit idiom, parameterized over subject and document) backing structural checks, DTD validation (content models, attribute lists/defaulting, tokenized types, ID/IDREF), XSD (datatypes, complex types, derivation control, wildcards, namespace qualification), RELAX NG (XML and compact syntax), and Schematron (rules, phases, dynamic messages). Errors are located by coding path for editor use.
  • Emitting (PureXML.Emitting): Serializer (save options, quote style, xml:space, line endings) and an incremental, namespace-aware Writer (libxml2 xmlTextWriter) with compact and pretty modes.
  • XPath / XPointer (PureXML.XPath): XPath 1.0 (all axes, the core function library, eval-time namespace bindings) and XPointer (element(), xpointer(), xpath1(), xmlns()).

Consumption modes

You choose how the document is consumed; only the tree mode materializes the whole document. The input itself never has to be a whole string: every mode below also accepts an incremental character or byte source (pulling: / pullingBytes:), so the engine advances step by step with bounded memory and the bytes are pulled on demand. The String overloads are a convenience for when you already hold the text.

  • Pull cursor (step by step): events(_:) / events(pulling:) returns an EventReader; call next() to get one Event at a time. TextReader is the node cursor (the libxml2 xmlTextReader model): read() advances to the next node, you inspect it, read() again. Bounded state, no tree.
  • Push / feed: PushParser.feed(_:)/finish() accepts the document in arbitrary chunks (a socket, a generator); events(feeding:) exposes the same over an AsyncSequence. It retains only the current incomplete token and the open-element stack.
  • SAX: parse(_:sax:) delivers SAX2-style callbacks, no tree.
  • Tree: parse/parseTree build a Model.Node / TreeNode tree for random access, mutation, validation, XPath, and XSLT. This mode (like libxml2's DOM) holds the whole document, because those operations need the full structure.

Safe defaults

By default the parser rejects <!DOCTYPE> and refuses every external reference (EntityResolver.refusing). Enable DTD processing only when you need internal subset validation or internal entities, and wire a resolver only for trusted, in-memory replacements. Network fetching (nanohttp/nanoftp) and libxml2's threading/memory infrastructure are deliberate non-goals.

Remaining work

Feature parity with libxml2's documented surface is complete: the core I/O stack, the query and transform layers, all four schema languages, the validation framework, editor integration, and the encoding set (the ISO-8859 family, the Windows code pages, and the CJK multi-byte encodings) are shipped. The beyond-parity audit epics (#71, its parser-breadth sub-epic #74) and the parity-frontier epic (#105) are closed.

The remaining distance is production readiness for IDE use, tracked under epic #167: driving the XSTS conformance buckets toward zero (currently 0 / 39 / 0 / 26), located schema diagnostics (#169), the schema differential oracle (#171), and cross-document composition (#161). The one active runner burn-down is #130 (the XSLT 1.0 xalan suite). See docs/roadmap.md.

Usage

import PureXML

// Build a tree and emit it (works today).
let element = PureXML.Model.Element(
    "book",
    attributes: [.init("id", "bk101")],
    children: [.element(.init("title", children: [.text("XML Developer's Guide")]))],
)

let xml = PureXML.serialize(.element(element))
try PureXML.validate(.element(element))

// Parse it back into a tree.
let node = try PureXML.parse(xml)

// Or stream events without building a tree (drives chunked input too).
var reader = PureXML.events(xml)
while let event = try reader.next() {
    // handle .startElement / .characters / .endElement / ...
}

// DTD validation (opt-in: allowDoctype + optional resolver for trusted externals).
let issues = try PureXML.validateAgainstInternalDTD(
    xmlWithDoctype,
    limits: .init(allowDoctype: true),
)

// XPath over a parsed tree.
let titles = try PureXML.xpath("//title", over: node)
    .compactMap(\.element)

Attribution

PureXML is informed by the behavior of established XML parsers (libxml2, expat, Foundation's XMLParser) and by the W3C XML 1.0 specification, but it does not copy their implementation into Sources/. See ATTRIBUTION.md.

Development Contract

PureXML must stay dependency-free and portable. Before merging changes:

  • Swift tools version: 6.1
  • Package products: PureXML
  • SwiftPM dependencies: none
  • Hosted CI matrix: macOS, Linux, Windows, and WASM
bash scripts/check-all.sh

That command expands to:

bash scripts/check-style.sh
bash scripts/check-namespacing.sh
bash scripts/check-forbidden-patterns.sh
bash scripts/check-validation-coverage.sh
bash scripts/check-validation-fields.sh
bash scripts/check-changelog-touched.sh
swiftformat . --config .swiftformat --lint
swiftlint --config .swiftlint.yml --strict
swift build
swift test

License

MIT.