The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.
Also includes the mandated repo-wide swiftformat/swiftlint pass.
Computing a node's string-value walked its subtree recursively, so it
overflowed the stack on a deeply-nested node (around 2000 deep on a Swift
Task stack): `XPath.Query("/a/a").strings(over:)` over a 4000-deep chain
crashed. `TreeNode.textContent` and the value-Node string-value behind
`Selection.stringValue` now walk an explicit stack, pushing children reversed
to concatenate in document order.
The value-Node path also wrongly folded descendant comment and
processing-instruction data into an element's string-value, disagreeing with
the tree-node path and XPath 1.0 (5.1/5.5: only text and CDATA contribute to
an element's string-value; a comment or PI node's own string-value, 5.6/5.7,
is still its data). It now matches. No test depended on the old behaviour:
1789 pass, including the XPath and XSLT value corpora.
Closes the string-value sites of #350; the transform walks (canonicalize,
DTD defaults, XSLT result tree) remain tracked there.
* perf(model): rebuild DTD-default application iteratively, not recursively
applyingDefaults rebuilt the tree with a per-node recursive walk, so applying
DTD attribute defaults to a deeply-nested document overflowed the stack (around
2000 deep on a Swift Task stack). It now rebuilds bottom-up through a new shared
iterative spine, Node.rebuildingBottomUp, which transforms each node once its
children are rebuilt via an explicit work stack.
The same primitive will carry the other tree-to-tree transforms (canonical
prefix rewrite, XSLT namespace fixup) in follow-ups. Defaults are applied
identically: 1788 tests pass. Part of #350.
* perf(canonical): serialize C14N iteratively, not recursively
Canonicalizer.emit recursed on element children, so canonicalizing a
deeply-nested document overflowed the stack (crashed around 5000 deep).
The element serializer now drives a deferred-close work stack (the same
shape as the main serializer): each step carries the in-scope and
already-rendered namespace context, children push reversed to emit in
document order, and a close step emits the end tag. The shallow document
case and all namespace-rendering logic are unchanged.
The canonical bytes are byte-identical: the C14N 1.0, 1.1, and exclusive
conformance corpora pass, full suite 1789. A 50000-deep document now
canonicalizes without crashing. The node-subset helpers moved to their
own file to keep within the file-length limit. Part of #350.
Mapping an ordered node-set to selections rebuilt each result as an
independent value Node through TreeNode.node, projecting that node's whole
subtree. For a result set whose nodes nest within one another (`//a` over a
deep chain, where every `a` contains all deeper ones) this materialized the
sum of their subtrees: quadratic. `//a` over an 8000-deep chain took ~19s.
The projection now threads a memo keyed by tree-node identity across one
result set. A tree node is projected once; a result that nests a
deeper one reuses its already-built value Node by reference, which the
copy-on-write Element storage (from the value-Node depth-safety work) makes
a shared, not copied, subtree. The set is built in one pass over the
distinct nodes. The same query now returns in ~0.04s and scales linearly.
The non-memoized `node` projection (serialization, value comparison) is
unchanged. Results are identical: 1787 tests pass, including the XPath and
XSLT value corpora. Closes#348.
Element stored its children inline and Node was `indirect`, so a Node's
recursive layout made building, comparing, hashing, and releasing a deeply
nested tree recurse one native frame per level and overflow the stack
(signal 10) -- on a constrained stack such as a Swift Task, as shallow as a
few thousand deep.
Element now holds its children behind a copy-on-write reference
(ElementStorage), so Node is a fixed-size value (no longer indirect) and:
- `==` and `hashValue` walk the tree iteratively through explicit work
stacks (the synthesized conformances recursed); equal trees still hash
equally.
- ElementStorage's `deinit` flattens the subtree into a heap worklist
before releasing, draining only storage it solely owns so a copy-on-write
sibling keeps its subtree.
- `children` is a computed copy-on-write view with a `_modify` accessor, so
in-place mutation (the parser's append loop) stays amortized O(1).
Value semantics are unchanged; Element/Node remain Equatable, Hashable,
Sendable. A 50000-deep tree builds, compares, hashes, and releases in a
fraction of a second; 1782 tests pass. Completes the value-Node half of #341.
Building a TreeNode from a value Node, projecting it back via `node`, and
releasing it each recursed one native frame per tree level, so a deeply
nested document overflowed the call stack (signal 10 around 30-60k deep,
and far shallower on a constrained stack such as a Swift Task). None of
these is fixed by the serializer, which was already iterative.
- `TreeNode(_:)` and `node`/`projectedChildren` now drive explicit
post-order work stacks (entity-reference splicing, doctype/namespace
dropping, and the root special-cases are unchanged).
- `deinit` flattens the subtree into a heap worklist before releasing, so
each node frees with empty children and the teardown stays flat. Only a
node the teardown solely owns (isKnownUniquelyReferenced) is flattened,
so a child still referenced elsewhere keeps its subtree.
- The validator walk and the XPath descendant axis were made iterative for
the same reason.
A 60000-deep tree now releases in well under 0.1s; 1778 tests pass and no
accepted output changes. Part of #341; the value Node's own recursive
release and XPath result ordering/dedup remain depth-bounded and tracked
there.
Building the mutable TreeNode tree from a parsed value Node ran append for every
child. append -> insert detaches the child from any previous parent, rejects a
self/ancestor cycle, and inserts into a children array that grows element by
element. The children the conversion passes come from element.children.map and
are freshly built and parentless, so all three guards are no-ops and the
incremental growth rebuilds an array that was already prepared.
TreeNode.init(adopting:) adopts the prepared array directly and wires each
child's parent. The tree is identical: the same children in the same order with
the same parent links. The adopting initializer is used only by the value-Node
conversion; every public factory keeps the detaching, cycle-checking append path
for externally supplied children that may already live in a tree.
Measured parse ~12% faster on the 20k-item generated corpus via a same-state
A/B (7.7x to ~6.5x slower than libxml2). Verified by TreeNodeAdoptingInitTests
(parent-wiring and post-adoption mutability), the W3C XML conformance corpora,
the Xalan XSLT gold-output corpus, the full suite, the WASM build, and an
adversarial equivalence review.
QualifiedName(_:) ran firstIndex(of: ":") over the whole name, grapheme by
grapheme, on every element and attribute. Most names have no prefix, so the
common case scanned the entire name to find no colon at all.
The byte scanner already visits every byte of an all-ASCII name, so have
takeASCIIName record the offset of the first colon (one comparison per byte),
and split at that known offset in QualifiedName(ascii:colonOffset:). The
unprefixed name (no colon) is then built with no scan; a prefixed name splits
at the recorded offset. The names are identical to the firstIndex path for
every colon placement: none, leading (:local), trailing (prefix:), middle,
doubled (::), and multiple (first colon wins). The hint is confined to the
all-ASCII fast path; non-ASCII names and every other construction site keep
QualifiedName(_:).
Measured parse ~12% faster on the 20k-item generated corpus via a same-state
A/B (8.5x to ~7.7x slower than libxml2). Verified by QualifiedNameColonHintTests
(colon-placement differential), the W3C XML conformance corpora, the Xalan XSLT
gold-output corpus, the full suite, the WASM build, and an adversarial
equivalence review.
* 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.
Schema compile findings now carry ValidationError coding paths that resolve to source spans on a ranged tree, enabling IDE underlines for id, structure, include, and facet violations.
Document the weak-parent ownership trap at the public entry points: parseTree
and readTree now state that a tree (or ownerDocument) must be held while using
any node inside it, that TreeNode has no internal synchronization (confine to
one thread or actor), and that the immutable Sendable Node projection is the
way across concurrency boundaries; the TreeNode class doc carries the same
contract. QualifiedName's malformed-colon fallback (':local', 'prefix:', '::'
stay whole and unprefixed), already implemented, is now documented on init
and locked by a test.
Closes#114
Add the DOM/tree editing primitives from the audit that the editor use
case needs:
- Attribute helpers: attributeValue(_:), setAttribute(_:_:) (update in
place or append), removeAttribute(_:).
- shallowCopy() copies a node alone, complementing the deep copy().
- precedes(_:) compares two nodes in document order (ancestor before
descendant, earlier sibling before later; unrelated nodes compare false).
- normalize() coalesces adjacent text children, drops empty text, and
recurses into elements; CDATA is left intact.
First-class namespace/DOCTYPE/entity-reference nodes (an invasive
TreeNodeKind change) and cross-document adoption/ownerDocument (no
document-identity model) are left for a follow-up.
Closes#90
Slice 4 of #94 (completes the epic). Quick-fixes are derived from the structured
schema completions, never by parsing a diagnostic message:
- TreeNode gains contentRange (the span between an element's start and end tags),
captured by the ranged reader, so an inserted attribute or child is placed
exactly.
- QuickFix and TextEdit model an editor edit (a replacement over a source range;
an insertion is a zero-width range).
- QuickFixEngine turns a Completions into fixes: a required-but-absent attribute
becomes an insertion before the start tag's >, and a still-expected required
child becomes an insertion before the end tag. Schema.Document.quickFixes(at:in:)
is the entry over a ranged tree.
Tests apply a fix and re-validate: adding the required id, or inserting the
required qty, yields XML the schema then accepts; a complete element offers none.
#94 (editor integration) is complete: ranged recovering read, unified lint,
schema completions, and quick-fixes.
Slice 1 of #94 (editor integration). The keystone: a located validation finding
can now be mapped back to a source range.
- TreeNode gains a sourceRange (start/end Mark); SourceRange is a half-open span.
- PureXML.readTree builds a mutable, parent-aware, best-effort tree whose every
node carries its span (an element from the < of its start tag to past its end
tag; leaves over the text they consumed). Never throws, recovers in place,
returns located diagnostics.
- TreeNode.node(at:) navigates a validation coding path to its node using the
same canonical convention the validators build paths with (a sibling index
only when a name repeats), and sourceRange(at:) returns the span. So
Document.validate's ValidationError.codingPath resolves to characters in the
document.
Tests prove the spans, the navigation, the end-to-end bridge (an XSD finding to
a source range), and that ranged reading of invalid input never crashes.
Add PureXML.Model.TreeNode, a reference-type document tree that knows its
parent and siblings. Navigation goes up and sideways (parent, nextSibling,
previousSibling, ancestors, root, elementChildren, stringValue) and editing
is in place (append, insert before/after, removeFromParent, replace, copy).
Children are held strongly and the parent weakly; attaching a node detaches
it from any prior parent and a node can never become its own ancestor.
parseTree builds one from XML; TreeNode.node converts back for serialization.
Closes#31
Add namespace resolution following the XML Namespaces rules, reimplemented in
Swift from libxml2's SAX2 model; no upstream code copied.
- QualifiedName gains an optional resolved namespaceURI (nil for names built
directly or in no namespace), with a resolved(namespaceURI:) helper.
- NamespaceContext maintains a scope stack of xmlns bindings as elements open
and close, resolving element and attribute names against it.
- A default namespace applies to unprefixed element names but never to
attributes; the xml prefix is built in; xmlns declarations are preserved as
attributes so documents round-trip; an unbound prefix raises
ParseError.undefinedNamespacePrefix. End-tag matching stays lexical.
- Namespace-free documents are unchanged (URIs remain nil).
- 7 namespace tests; 39 total.
Root Swift package mirroring PureYAML's structure, rules, and verification
gates. Includes the XML node model (Node, Element, Attribute, QualifiedName),
a working emitter (Serializer) with escaping and pretty/compact options, and
structural validation. The parser surface is stable; parse() raises
notImplemented until the scanner lands, pinned by a test.
- dependency-free, namespaced under PureXML
- portability and style gates carried over (style, namespacing, forbidden
patterns, swiftformat, swiftlint, linux, wasm, windows CI)
- 11 tests passing