Commit Graph

539 Commits

Author SHA1 Message Date
6957e96b3a
fix(schema): reject overlapping references to one group as a UPA violation, closing a hang (#366)
Two references to the same <xs:group> are two expanded occurrences for Unique Particle
Attribution, so when they overlap, (g?, g?), (g | g), (g*, g), the model is ambiguous,
exactly as inline (x?, x?) / (x | x) is, and as libxml2/Xerces reject them. The
compositional determinism check summarises each group once and reused the group's node
identity across references, so the distinct-identity overlap test could not see the
conflict: PureXML accepted these (an invalid-schemas-accepted false negative). It also let
the per-instance content matcher run on a non-UPA model, where a multiply-referenced
nullable group is exponential, so a depth-18 optional doubling chain was accepted and then
hung instance validation: a keystroke-time hang on a schema PureXML itself accepted.

The fix dedupes each decision set (first set, sequence gap, all cross-follow) by particle
identity and treats a same-identity collision as the conflict; the repetition self-loop
(a*, (a*)*) keeps a non-flagging dedupe so one particle looping is not misread as two
occurrences. This also bounds the check, the deduped first/follow sets stay O(labels) not
2^K, so the depth-40 chain is now rejected at compile in 11 ms and no instance is matched
against it. A redefine self-extension lists a type's own content twice through
effectiveModelNodes; that artifact is deduped before the walk so it is not a false
conflict (addB094, schZ007).

The repeat-edge fold is kept in a separate method, not inline in summarize: summarize
recurses on nesting depth, so a local it holds multiplies the per-level stack frame and a
deep group chain overflows WASI's small stack (the recursive check's deeper WASI depth-
safety is tracked separately).

Verified over the full W3C XSTS corpus in release: valid-schemas-rejected and
valid-instances-rejected stay 0, invalid-*-accepted unchanged at 8 and 14; an adversarial
over-rejection critic found no valid schema newly rejected; the full suite passes on
macOS, Linux, and WASI (run locally under wasmtime).
2026-06-28 17:56:46 +02:00
5d38f0c82f
perf(schema): graph-structured-stack content matcher, killing the last 2^K content-model hang (#364)
* perf(schema): graph-structured-stack content matcher, sharing each xs:group (alongside)

The per-instance content-model build inlines every xs:group reference, so a
multiply-referenced nested group (g0=(g1,g1), g1=(g2,g2), ...) explodes to 2^K
states and reset actions: a depth-16 doubling chain already takes seconds, depth
20+ hangs. This compiles the same particle to a counted Recursive Transition
Network instead, where each definitionID group is compiled once and reached by a
call, and simulates it with a graph-structured stack.

ContentRTNBuilder mirrors ContentNFABuilder exactly except that a group with a
definitionID becomes a call into its shared, compiled-once fragment (memoized by
id; the fragment's accept is a return). The callee's counters are deliberately not
propagated to the caller: every counter is reset on its own particle's entry edge,
which is on the path from the callee's start, so a shared fragment resets itself on
every re-entry without the caller re-listing it. Re-listing was the residual
blow-up: it nested the reset-action lists to 2^K even when the states stayed O(K).

ContentRTNMatcher runs configurations (state, counters, gssNode): a call saves the
caller's counters on a GSS node interned by (returnState, savedCounters, position)
and a return restores them, so the shared callee never mixes counter state across
its callers; parents are a set and a popped node replays its pop to a parent added
later (the GLL completeness detail). The build drops to O(groups): a depth-24
doubling chain (2^24 inlined states) compiles to under 480 states in under a
millisecond.

It is gated by a differential (ContentRTNDifferentialTests) against the trusted
inlining matcher ContentNFA as the oracle: the two agree on the accept verdict and
the matched particle over the hand model cases, the named-over-wildcard tie-break,
the shared-group doubling chains that exercise call and return, and a 4000-case
fuzz of nested models with occurrences, wildcards, and shared groups, plus a bound
assertion. Built alongside the inlining matcher and not yet wired into validation,
so no behaviour change and the XSTS buckets are unchanged; the switch-over is gated
on this differential.

* perf(schema): add the streaming/located surface to the GSS matcher, lock it differentially

The content-model validator reads two things off the inlining matcher: matchedParticles
for per-child attribution, and the streaming startStates/step/admissible/isComplete for
located diagnostics (which child breaks the model, and the "expected a, b" hint). The GSS
matcher only had matchesWhole/matchedParticles, so it could not yet replace the inlining
matcher. This adds the streaming surface: admissible works because the closure already
expands call states into the callee's label states, so the follow set has the same shape
as ContentNFA's.

The differential gate now locks the whole surface, not just the accept verdict: driving
both matchers child by child, the first rejected child (step is nil) agrees, the
admissible expectation set agrees at the start and after every accepted child, and
isComplete agrees once the sequence is consumed. The fuzz now stamps group references the
way the parser does, a unique definitionID per distinct group (so a singly-referenced
named group also takes the call path, not only doubled groups), a shared id only for
genuinely shared structure, and some anonymous inline groups, over occurrences and
wildcards. Still alongside, not wired; no behaviour change, XSTS buckets unchanged.

* perf(schema): complete the GSS matcher surface with follow(after:), verified

follow(after:) is the last ContentNFA method the GSS matcher lacked: the completion
path (XSDCompletions) reads it for the acceptable-next labels and whether the content
may end. It composes the streaming surface, and the differential now locks its allowed
set and complete flag to ContentNFA too. The RTN matcher is now a complete drop-in for
the ContentNFA surface the validator and completions read, so the switch-over is a
mechanical builder swap gated on XSTS neutrality. Still alongside; no behaviour change.

* docs(design): record GSS Steps 1-3 done, the switch as a corpus-gated mechanical swap

Updates the counted-content-automaton Step 4 to the current state: the GSS matcher and
its full differential gate are built and green in-repo, and the production switch is now
a three-site builder swap blocked only on the XSTS neutrality measurement, which must run
on a machine that has the corpus. Records the exact swap sites and the gate so the corpus
machine can execute it mechanically.

* perf(schema): validate and complete on the GSS matcher, killing the last 2^K content-model hang

The three call sites that built the inlining content automaton once per validated
element, ComplexValidatorContentModel.sequenceStructureErrors (located structure
errors), ComplexValidator.validateParticle (per-child particle attribution), and
XSDCompletions (acceptable-next labels), now build ContentRTNBuilder and run
ContentRTNMatcher. A schema whose nested <xs:group> references inline to 2^K positions
(the g{i}=(g{i+1},g{i+1}) doubling shape) no longer makes each keystroke-time validation
of an element exponential in the nesting depth: the automaton is built in O(groups).

Validating a short instance against a depth-40 doubling schema (2^40 inlined positions),
the shape an editor sees mid-typing, now completes in milliseconds with a located error
instead of hanging; a new end-to-end test pins it.

Verified conformance-neutral on the full W3C XSTS corpus in release: valid-schemas-rejected
and valid-instances-rejected stay 0, invalid-schemas-accepted and invalid-instances-accepted
unchanged at 8 and 14. The located diagnostics, per-child attribution, and completions are
behaviour-identical by the surface differential (ContentRTNDifferentialTests), which locks
the verdict, matched particle, rejection index, admissible set, isComplete, and follow to
the inlining ContentNFA. ContentNFABuilder/ContentNFA are retained as that oracle.

* docs(design): mark GSS content-matcher Step 4 done, XSTS-neutral in production

Records that the three production sites are switched to the graph-structured-stack
matcher and the switch was measured conformance-neutral on the full W3C XSTS corpus in
release (all four buckets held). Notes the local corpus path and that ContentNFABuilder
is kept as the differential oracle rather than deleted.

* test(schema): compare full matched-particle attribution; bound a cyclic group id

From the adversarial review of the GSS matcher:

- The differential compared only the matched-particle kind (element/wildcard), so it
  could not observe the type/value metadata matchedParticles carries. Every fuzz and
  hand element now carries a per-name fixed value constraint and the comparison projects
  it, so the gate verifies the metadata is carried identically through call/return, not
  only that the kind agrees. A name maps to one constraint, so the comparison does not
  depend on the lowest-state-index tie-break (which only distinguishes same-name
  particles, a UPA violation the compiler rejects before instance validation); the
  matchedParticles doc now records that caveat.

- ContentRTNBuilder.groupFragment memoizes after compiling, so a (malformed) cyclic
  definitionID would recur without bound, the interactive-safety hazard this matcher
  removes. definitionID groups are acyclic by construction (the parser breaks cycles at
  element boundaries, the inlining builder relies on the same), but bound it anyway:
  a cycle degrades to a terminating empty fragment instead of recurring.

No production behaviour change; the differential, full suite, and the full XSTS corpus
in release (16/16, all four buckets held) stay green.
2026-06-28 05:07:37 +02:00
e81e4b4df6
feat(schema): carry a stable group-definition identity on shared expansions (#363)
Step 1 of the GSS content-matcher build (docs/design/counted-content-automaton.md).
A multiply-referenced `<xs:group>` expands to one copy-on-write-shared `Particle`
(GroupParticleMemo), but `Group`/`Term` are value types with no exposed identity,
so a content-model compiler cannot tell two references apart and inlines the group
per reference (the 2^K build the GSS matcher will close).

Group now carries a `definitionID: Int?`, stamped from a per-compile counter on
GroupParticleMemo when an expansion is first compiled, so every reference sharing
the memo key reuses the same stamped term and distinct definitions differ. It is
metadata only: Group is not Equatable/Hashable, so it perturbs no determinism or
restriction comparison, and it is nil for an inline group.

FP-neutral by construction (no consumer yet). Full in-repo suite green; the XSTS
buckets are unchanged (valid-schemas-rejected 0, invalid-schemas-accepted 8,
valid-instances-rejected 0, invalid-instances-accepted 14); swiftformat/swiftlint
clean. Step 2 (a call/return content-model build keyed on this id, alongside the
inlining oracle) consumes it.
2026-06-28 01:53:58 +02:00
0af92bcf8d
fix(schema): apply cos-st-derived-ok to NameAndTypeOK over a union base (#359)
A particle restriction whose restricting element has a union-typed base element
was checked by structural member subsetting: PureXML accepted a separately
declared union whenever its members were a subset of the base union's. That is
not the spec's derivation relation. The NameAndTypeOK type clause (XSD 1.0
section 3.9.6) requires the restricting element's type to be validly derived
from the base element's type per Type Derivation OK (Simple), cos-st-derived-ok.

The restricting type is now valid over a union base only if it is the base
type, derives from it through a declared restriction chain, or (clause 2.2.4)
validly derives from one of the union's member types. Scoped to the
element-restriction path, so no attribute or simple-type derivation check moved.

Catches particlesIk011 (a fresh union(integer) is not a restriction of
union(integer, string)): XSTS invalid-schemas-accepted 9 to 8, valid-schemas-
rejected held at 0 over the full corpus (the cardinal no-over-rejection check).
Disclosed: particlesIk025/Ik027, where the restricting type derives from a
union member, are accepted per cos-st-derived-ok 2.2.4, a contested reading
their XSTS invalid expectation does not share. An in-repo test that asserted
the old structural-subset behavior is corrected.
2026-06-27 23:45:40 +02:00
dc6727538d
perf(xslt): make the template evaluator iterative (#358)
The XSLT engine was mutually recursive on template-application and result-tree
nesting (instantiate, evaluate, applyTemplates), so a recursive named template
whose depth comes from source data could overflow the native stack (SIGBUS) on
attacker-controllable input, and an identity transform of a deep source
overflowed on a small (Swift-concurrency Task) stack.

The engine is now an explicit work-stack driver (XSLTDriver). Each instruction
appends its produced items to a sink or pushes sub-work, with the rest of the
body pushed first so result-tree document order is preserved; template
application, the built-in rule's source descent, and result-element nesting all
run on the heap. Only stylesheet-bounded helper evaluation (a variable's or
parameter's result-tree fragment, an attribute set, an xsl:message body)
re-enters through a nested driver call, whose native depth is bounded by the
stylesheet's own nesting.

The result-tree serialization walks the engine now exposes at depth were made
iterative too: XSLTNamespaceFixup (an explicit pre-order visit that keeps the
ns0/ns1 counter in document order), the text and string-value extractions
(iterative accumulators), and the content-type-meta and CDATA-section rewrites
(the shared Node.rebuildingBottomUp spine).

Deep recursion now succeeds on any stack: a 5000-level recursive template builds
and serializes on the test Task stack. maxTemplateDepth is retained as runaway
protection (an unbounded result tree would exhaust memory), defaulting to 40000
and throwing XSLTError.recursionLimitExceeded only past that.

Behavior is otherwise unchanged: the full Apache Xalan XSLT 1.0 conformance
corpus (1086 cases) and the in-repo XSLT/XPath suites pass identically; one
previously-failing Xalan case (mdocs07) now passes and was de-baselined. Full
suite green. Closes #356.
2026-06-27 19:29:51 +02:00
7b7adef016
fix(xslt): bound template recursion so it fails gracefully, not with a crash (#357)
* fix(xslt): bound template recursion so it fails gracefully, not with a crash

A recursive named template whose depth is driven by source data (e.g.
`<xsl:with-param name="n" select="count(//x)"/>`) could recurse without bound
and overflow the native stack (SIGBUS) on attacker-controllable input. Every
XSLT processor guards this (libxslt's xsltMaxDepth); PureXML did not.

Measurement first: source *nesting* depth is not the exposure. `transform`
parses the source at the default maxDepth (256) and there is no XSLT API that
accepts a pre-built tree, so the engine never sees a source deeper than ~255;
an identity transform of a 254-deep source completes normally on a normal
stack. The reachable crash is data-driven template recursion, which overflows
an 8 MB stack between ~400 and 600 levels.

Template-instantiation nesting (instantiateTemplate and builtInRule) is now
counted and capped at maxTemplateDepth; past it the guard latches, the
instantiation loops unwind, and `transform` throws
XSLTError.recursionLimitExceeded instead of crashing. The default (300, exposed
as PureXML.XSLT.defaultMaxTemplateDepth, overridable via a new maxTemplateDepth
parameter on transform) clears the parser's max source depth so a deep-source
identity transform still runs, while staying within an 8 MB main-thread stack
with margin. A stylesheet with deeper legitimate recursion raises the limit on
a correspondingly larger stack.

copyOf moved to XSLTSorting.swift to keep XSLTTransformer.swift within the file
length limit; it is unchanged (a whole-subtree copy that does not recurse on
depth). Guarded by XSLTRecursionLimitTests (run on a large-stack thread, since
the limit is above what swift-testing's Task stack holds). Full suite 1802 pass.
Part of #356; making the engine fully iterative (deep recursion succeeds, and
is safe on a small Task stack) remains tracked there.

* test(xslt): exclude the large-stack recursion tests on WASI

WASI is single-threaded and has no Foundation Thread/DispatchSemaphore, which
the recursion-limit tests need to exercise the guard on a stack large enough for
the limit (300) to fire before the stack overflows. Guard the file with
#if !os(WASI); the recursion guard itself is platform-independent pure Swift,
covered by the macOS and Linux runs.
2026-06-27 18:30:19 +02:00
33160ef775
perf(canonical): make the prefix rewrite and node-subset emit depth-safe (#355)
Two Canonical XML transforms still recursed one native frame per element level,
so a deeply-nested document overflowed the stack.

The C14N 2.0 sequential prefix rewrite walks the tree twice. Its assign pass
(which records one canonical prefix per namespace in document order) is now an
iterative pre-order walk; its rebuild pass is now a child-gathering accumulator
driven by an explicit work stack, threading the in-scope declarations and the
root flag down per element. The document-order prefix assignment and the
root-hoisted declarations are unchanged.

The node-subset serializer (`canonicalize(_:including:)`) now drives the same
deferred-close work stack as the main serializer, threading each element's
in-scope and rendered namespace context; an omitted element still passes its
declarations to descendants without rendering or a close tag.

The canonical bytes are unchanged: the C14N exclusive, 2.0, and node-subset
corpora pass (1794 tests). A 50000-deep document now rewrites and
subset-canonicalizes without crashing, guarded by `CanonicalDepthSafetyTests`.
Part of #350.
2026-06-27 17:08:37 +02:00
f4982219a6
perf(xinclude): make XInclude processing depth-safe (#354)
`PureXML.XInclude.process` rebuilt the tree recursively (`process →
processElement → process`), so a deeply-nested document overflowed the stack
one native frame per element level, even with no includes present. It now
drives an explicit work stack: each branch gathers its processed children into
a reference accumulator, then a deferred build step assembles the node. The
`xml:base`, inclusion-chain depth (bounded by `maxDepth`), and cycle-detection
visited set thread down per context exactly as before; an include splices its
(or its fallback's) content in place by pushing the loaded nodes back onto the
stack.

Behavior is unchanged: the XInclude/XPointer conformance corpus and the
fallback, xpointer, cycle, and xml:base cases all pass. A 50000-deep document,
with or without an include at its base, now processes without crashing, guarded
by `XIncludeDepthSafetyTests`. Part of #350.
2026-06-27 16:55:03 +02:00
37bf30818f
perf(html): make HTML serialization depth-safe (#353)
`PureXML.HTML.serialize` walked an element's children recursively, so a
deeply-nested tree overflowed the stack (as shallow as a couple of thousand
deep on a Swift Task stack). The serializer now drives a deferred-close work
stack, the same shape as the XML serializer and `Canonicalizer.emit`: an
element pushes a close step then its children reversed, and a raw-text element
(`script`, `style`) flags its direct text children to emit verbatim, so nested
elements still serialize by their own name.

The output is byte-identical. Void-element, raw-text, comment, and escaping
semantics are unchanged (a new micro-test pins them) and the HTML conformance
corpus passes. A 50000-deep tree now serializes without crashing, guarded by
`HTMLSerializeDepthSafetyTests`. Part of #350.
2026-06-27 16:22:54 +02:00
48c2c5c7db
fix(xpath): make string-value extraction iterative and comment-correct (#351)
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.
2026-06-26 20:44:49 +02:00
00c55e642f
perf: make tree-to-tree transforms depth-safe (DTD defaults, canonicalization) (#352)
* 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.
2026-06-26 19:27:37 +02:00
b672c49a84
perf(xpath): share value-Node projections across a result set (#349)
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.
2026-06-26 18:43:27 +02:00
3e4f596b7c
perf(xpath): order results by a per-root index, linear not quadratic in depth (#347)
Sorting a node-set into document order keyed each node by its root path, an
array as long as the node's depth, computed with a per-node ancestor walk.
Ordering the descendants of a deeply-nested document was therefore
O(n log n x depth): `//a` over an 8000-deep document took ~19s through the
reference query path.

The key is now a single O(1) pre-order index from a per-root numbering,
built once per document and shared through the evaluation's navigation
cache, with a band ordering an element before its namespace nodes, its
attribute nodes, and its children. A node-set spanning several documents
(an XSLT document() result) is grouped by root, ranked by first appearance,
so only a foreign node walks its ancestors. `appendSubtree` (the numbering
walk) is now iterative, so it no longer recurses on depth either.

Document order is unchanged: 1786 tests pass, including the XPath, XSLT,
XPointer, and Schematron conformance corpora. `Query.nodes` over a
50000-deep document now orders in a fraction of a second.
2026-06-26 18:22:20 +02:00
3405c9709d
perf(parsing): resolve namespaces in O(1), making parse linear in depth (#346)
Each open element resolved its name and attributes by looking up namespace
bindings through NamespaceContext.lookup, which walked a stack of scopes as
deep as the document. With one lookup per element over an O(depth) stack,
parsing a deeply-nested document was O(depth^2): a 40000-deep document took
~39s.

The context now maintains the current in-scope bindings incrementally: a
single prefix-to-URI map, plus a per-element undo list recording the
bindings each element overwrote so closing it restores the enclosing state.
A lookup is O(1) and entering/leaving an element is O(its xmlns
declarations), so parsing is O(n). The same document now parses in ~0.2s.

Namespace semantics are unchanged: the nearest in-scope binding still wins,
an empty default binding still undeclares, and 81 namespace tests plus the
full suite (1784) pass. Closes #345.
2026-06-26 17:44:27 +02:00
d27e108b4d
perf(model): make the value Node tree type depth-safe (#344)
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.
2026-06-26 17:05:42 +02:00
a77ffbd952
perf(model): make the TreeNode tree type depth-safe (#343)
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.
2026-06-26 16:39:08 +02:00
b05b428e30
perf(parsing): linearize entity-graph checks for billion-laughs (#342)
* perf(parsing): memoize the markup-producing entity check (billion-laughs)

splitAtMarkupEntity / producesMarkup recursed into every reference of
every replacement with no memoization, so a nested entity chain was
traversed O(references^depth) times: a classic eight-level billion-laughs
took ~18s of CPU to reject. The check is now memoized per entity name (the
property depends only on the fixed entity graph), so it is O(entities). The
same document now rejects in ~0.6s, bounded by the expansion budget. No
change to which documents are accepted; cyclic entities still rejected.
Found by adversarial probing; closes the no-markup case of #339.

* perf(parsing): three-colour DFS for the No-Recursion check

checkRecursion validated the entity reference graph with only an
in-progress (grey) set and no record of fully-verified entities, so a
billion-laughs graph was re-walked O(references^depth) times: a
markup-producing eight-level document took ~31s to reject. It is now a
three-colour DFS: an entity is added to a black set only after its whole
reachable subgraph returns acyclic, so it can never reach a chain node
that reaches it, and skipping it on revisit is sound. The walk becomes one
pass over the distinct edges (~31s -> ~11s for that document). Cyclic
entities are still rejected; no accepted document changes. Part of #339.
2026-06-26 15:30:51 +02:00
46a5c27e8c
perf(parsing): make entity/content expansion linear, not quadratic (#338)
The lookahead buffer consumed from the front with Array.removeFirst() and
re-injected entity replacement text with insert(at: 0), both O(buffer), so
a single large entity (or a long content run pushed back by reference
inclusion) was consumed in O(n^2). The buffer now advances a head index
(front-consume O(1)) and re-injection reuses the freed prefix space, making
a single large expansion linear. An 800k-character internal text entity
drops from quadratic time to about 0.6 s. Same characters, same limits;
1775 tests unchanged. Found by adversarial robustness probing.
2026-06-26 13:55:29 +02:00
472b0ac16e
refactor: remove three more dead functions (#331)
Repo-wide reference sweep: XSLTTransform.transformToNode (a transform-to-
tree variant nothing calls; the live string-returning transform is
independent), and ComplexValidatorHelpers.matchesAll plus its orphaned
helper label(of:). Zero references; build and 1775 tests confirm no
behavior change.
2026-06-26 12:41:34 +02:00
af9c220272
refactor: remove three dead file-scoped functions (#330)
A private-scope sweep (a private/fileprivate function with zero in-file
callers is provably dead, with no name-conflation risk) found three:
selectNodes in XSLTTransformer (selectXPathNodes is the live sibling),
processingInstructionMatches in EvaluatorSteps, and parseExternalID in
DoctypeScanner. A doc comment referencing selectNodes was reworded.
Build and 1775 tests confirm no behavior change.
2026-06-26 12:04:07 +02:00
ad44fe77b9
fix(schema): reject a simpleContent restriction that relaxes a required attribute (#326)
derivation-ok-restriction.2 (a base required attribute may not become
optional in a restriction) ran only over complexContent restrictions, so
a simpleContent restriction that relaxed a required attribute was accepted.
The attribute-use restriction check now runs over both derivation roots.
Closes XSTS particlesZ030_d, which libxml2 also rejects, so this aligns
with the reference rather than exceeding it. XSTS invalid-schemas-accepted
10 to 9 with valid-schemas-rejected held at 0 (full archive). Guarded by
SimpleContentAttrRestrictionTests; the XSTSSuiteTests baseline is updated.
2026-06-26 11:35:33 +02:00
cadca9621c
refactor(xslt): remove dead namespace-fixup helpers (#318)
XSLTNamespaceFixup.fixAttributeName and resolvedPrefix had zero callers.
The result-tree attribute fixup was inlined into fix(_ element:) and
evolved past them (the inlined path also shadows a carried prefix via
localPrefixes, which the helpers lacked), so the helpers were dead,
diverged duplicates. No behavior change; build green, 1759 tests pass.
2026-06-26 08:17:39 +02:00
0e21b86781
refactor(schema): remove unused [String] consistency wrappers (#317)
* refactor(schema): remove unused [String] consistency wrappers

Drop four internal schema-consistency wrappers that had no callers
(idAttributeErrors, postNamedTypeErrors, structureErrors,
includeCompositionErrors). The located-diagnostic *Findings API
superseded them and the live call sites project reasons with
map(reason) directly, so these were dead code. No behavior change;
the *Findings functions are unchanged. Also correct a stale
doc-comment reference to the renamed userTypeValueConstraintFindings.

* critic-fix(schema): drop the dead consistencyErrors chain the first pass missed

The name-only dead-code sweep read XSDParser.consistencyErrors as used,
but its callers were the distinct Validation.XSDSchema.consistencyErrors
(a different signature returning ValidationError). The XSDParser overload
had zero callers. Removing it, plus the postCompileErrors(namedTypes:)
overload and the SchemaCompileNamedTypes struct that only the already-
removed postNamedTypeErrors reached, completes the dead chain. Build
green, 1759 tests pass, no behavior change.
2026-06-26 08:02:09 +02:00
e5b5120ac2
perf(xslt): direct ID lookup in id() instead of full-index scan (#315)
id() filtered the entire document ID index by tokens.contains on every
call, O(index) per call, so a stylesheet calling id() across a wide
document was quadratic. Look each requested token up directly in the
index dictionary instead, O(tokens). The result is unchanged: the same
elements, in document order (the ID values are the keys, each mapping to
one element). key() already used direct lookups.

Resolving 4000 id() references against a 4000-entry index drops from
about 0.34 s to about 0.03 s (~13x), now linear.
2026-06-26 04:25:58 +02:00
904881b81c
perf(xpointer): linear-time string-range() search (#314)
xpointer(string-range(...)) matched the search string at every position
of each node's value and re-allocated the whole needle window per
position, an O(text * needle) search and a denial-of-service vector on a
long node value with a near-matching needle. Replace it with a
linear-time Knuth-Morris-Pratt search (failure function built once, since
the needle is fixed across nodes). Matches are unchanged: same
non-overlapping, left-to-right occurrences, same offset/length.

A near-matching needle over a 16000-character node drops from about
0.18 s to about 0.002 s (~100x), now linear.
2026-06-26 04:01:41 +02:00
d1a371a6a9
perf(schema): make xsd identity-constraint validation linear (#312)
xs:unique/xs:key/xs:keyref were quadratic over a wide list of targets.
The duplicate check compared each value tuple against every tuple already
seen (and a keyref against every referenced key), and the error-location
helper rescanned the parent's whole child list to position each target.

Key tuples in a Set by their raw string when field equality provably
reduces to a raw string comparison (whitespace-preserving lexical types,
no colon, which is most ids); value-space types (numeric, boolean, date),
whitespace-collapsing types, and QName-bearing values keep the exact
pairwise comparison, so which documents pass is unchanged. Position each
target through a per-parent, per-name index built once.

A 16000-item xs:unique document drops from about 4.0 s to about 0.12 s
(~34x), and the cost is now linear. The full local suite and the W3C
XSTS datatype/identity conformance corpus are unchanged.
2026-06-26 02:45:17 +02:00
8f992a8897
perf(xslt): cache xsl:number level single/multiple sibling ranks (#310)
siblingPosition ranked a numbered node among its parent's matching children by
scanning the child list up to the node, counting matches. Run for every node of
a wide list, numbering was O(n^2): the default xsl:number over 8000 list items
took ~3.5s.

The count test is the same for every candidate of one xsl:number call, so cache
per parent and count pattern the one-based rank of each matching child, built
once. Each node's rank is then an O(1) lookup. The transformer holds the cache
across the whole transform; the count test itself is already O(1) through the
match cache, so building a parent's ranks is linear in its children. The numbers
are identical (a node's rank among matching siblings is 1 + the matching
siblings before it).

The 8000-item numbering drops ~3.5s to ~0.04s (~90x), quadratic to linear. The
full suite, the W3C XSLT and Apache Xalan corpora, and the WASM build stay
green; XSLTNumberSingleTests pins single, partial-match, and multiple-level.
2026-06-26 01:13:07 +02:00
971a7e9f56
perf(xslt): make xsl:number level="any" quadratic, not cubic (#309)
walkPreceding visits a node and everything before it in document order. It found
each preceding sibling with precedingSibling(of:), which rescans the parent's
whole child list, so visiting k preceding siblings was O(k^2) and numbering n
nodes under a wide parent was O(n^3): 1000 items took ~4.7s and 4000 did not
finish in minutes.

Find the candidate's index in the child list once, then walk the children before
it directly (parent.children[..<index].reversed()), visiting each preceding
subtree in reverse. The walk is now linear in the number of preceding nodes, so
the numbering is quadratic (the residual is inherent to level="any", which
counts the matching preceding nodes per numbered node). The numbers are
identical.

Numbering 1000 items drops ~4.7s to ~0.7s (~6x); cubic becomes quadratic. The
full suite, the W3C XSLT and Apache Xalan corpora, and the WASM build stay
green; XSLTNumberAnyTests pins document-order numbering across nesting.
2026-06-26 00:40:16 +02:00
9d0d92bda3
perf(xpath): make translate() and the substring search functions linear (#308)
translate(string, from, to) ran from.firstIndex(of: character) for every
character of the source, so a large from/to map made it O(string * from). And
contains/substring-before/substring-after matched the needle at every position
of the haystack (Array.starts(with:) per index), O(n*m) and quadratic on a
repetitive string, which is a denial-of-service vector on untrusted input.

translate() now builds a from-character to replacement map (and a delete set)
once, first occurrence winning, and translates each source character with an
O(1) lookup. The substring search is Knuth-Morris-Pratt, O(n+m), which never
rescans. The results are unchanged.

A 4000-character large-alphabet translate drops ~0.13s to ~0.002s; a 16000-
character repetitive contains drops ~0.054s to ~0.0002s. The full suite, the
W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay green;
TranslateFunctionTests and SubstringSearchTests pin the semantics.
2026-06-26 00:11:49 +02:00
b104723fed
perf(xpath): cache and fuse the following-sibling/preceding-sibling axes (#306)
siblingPosition(of:) located the context node with
parent.children.firstIndex { $0 === tree } on every call: a linear scan of the
parent's child list, independent of the result. Over a wide parent evaluated
from many context siblings (//x/following-sibling::y) that scan was quadratic,
and the axes then materialized every sibling before the node test filtered it.

Look the index up through the shared per-evaluation SiblingIndexCache (O(1)
after the per-parent table is built), and fuse the step's node test into the
walk so a rejected sibling is never wrapped (the same fusion the descendant and
following/preceding axes use). The selected siblings and their order are
unchanged.

The gratuitous per-context scan and the non-match wrapping are gone (the
remaining per-context iteration over the siblings is inherent to the axis); a
wide-parent following-sibling query is ~1.2-1.6x faster. The full suite, the
W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay green;
SiblingAxisTests pins the fused node test and document order.
2026-06-25 22:41:32 +02:00
d86e4effbe
fix(xpath): cache the attribute index for document-order keys (#305)
An attribute node's document-order key bands by the attribute's index among its
owner's attributes, found with owner.attributes.firstIndex { $0.name == name }.
That linear scan ran for every attribute node, so sorting an element's K
attributes into document order was O(K^2): an 8000-attribute element took ~0.18s
and grew quadratically.

Cache a per-owner attribute-name-to-index map in SiblingIndexCache, built once
on the second lookup (a single lookup still scans), exactly as the sibling-index
cache already does for child positions. The index is the same first-occurrence
position; only its cost changes.

The 8000-attribute sort drops to ~0.01s (~16x) and is now linear. The full
suite, the W3C XPath and Apache Xalan corpora, the C14N suite (which orders
attributes byte-exactly), and the WASM build stay green; AttributeDocumentOrderTests
pins declaration order through the cached sort.
2026-06-25 22:01:13 +02:00
0405850d6b
perf(xpath): fuse the node test into the following/preceding walk (#303)
After #302 cached the document node list, the following and preceding axes still
materialized the entire following/preceding span (Array(slice), one Node copy
with a slow side-table retain per node) before the step's node test filtered it.
For a rare-target query that copy is almost entirely waste.

Fuse the node test into the walk: apply the test to the cached document-order
slice directly, so a node the test rejects is never copied out of the shared
list, only the matches are materialized. The selected nodes and their order are
unchanged (the test is the same compiled node test the general axis path used).

The 200-context following:: query over a 20k-element document drops from ~9s
(post-#302) to ~6s, ~2.6x below the original ~16s. The full suite, the W3C
XPath/XSLT and Apache Xalan corpora, and the WASM build stay green;
FollowingPrecedingAxisTests gains a fused-name-test case.
2026-06-25 20:56:14 +02:00
ad289b0df1
perf(xpath): cache the document node list for following/preceding axes (#302)
following(of:) and preceding(of:) rebuilt the entire document node list and ran
a linear firstIndex search for the context node on every call. Evaluated over
many context nodes (//x/following::y), that rebuild-and-search was quadratic: a
200-context following:: query over a 20k-element document took ~16s.

Share a per-evaluation DocumentNavigationCache: each document's ordered node
list and a node-to-index map are built once (the document is immutable during
evaluation) and reused, so the position lookup is O(1). following:: also skips
the context's contiguous subtree by index arithmetic (start = index + 1 +
descendant count) instead of scanning the tail and filtering every descendant
out. The selected nodes and their document order are unchanged.

The 200-context query drops ~16s to ~9s; the residual is the produced
node-set's own size (materialization), which is inherent to the axis. The full
suite, the W3C XPath/XSLT and Apache Xalan corpora, and the WASM build stay
green; FollowingPrecedingAxisTests pins the subtree skip, ancestor exclusion,
and the shared-cache multi-context path.
2026-06-25 20:03:45 +02:00
4552d24067
fix(xslt): make key(), id(), and EXSLT set functions linear over wide fan-outs (#300)
key(), id(), and EXSLT set:leading/set:trailing sorted their result with
sorted(by: Node.precedes). precedes recomputes both nodes' document-order keys
on every comparison, and each key walks the node to the root taking its index
among each parent's children, a linear scan of a wide parent. Over a wide flat
fan-out the sort was therefore quadratic. key() also deduplicated its matches
with matched.contains(where: ===), a linear membership scan, O(m^2) in the
match count. The EXSLT relative() used the cache-less documentOrder, rescanning
the parent per node.

Sort through sortedByDocumentOrder() (each key computed once behind a shared
sibling-index cache), deduplicate key()'s matches through a Set, and give
relative() a shared cache. The selected nodes, their document order, and the
deduplication are unchanged.

A key()-driven transform over a 20k-element document drops from ~1.15s to
~0.43s (~2.7x). The full suite, the Apache Xalan XSLT gold-output corpus, the
W3C XPath/XSLT/EXSLT tests, and the WASM build stay green; XSLTKeyIdOrderTests
pins key() dedup-and-order and id() document order.
2026-06-25 18:45:08 +02:00
9032d61950
fix(xpath): skip the document-order key for single-node string extraction (#299)
firstInDocumentOrder() computed a document-order key even for a zero- or
one-node set. The key walks a node up to the root, taking its index among each
parent's children, which is a linear scan of a wide parent. string(@x) and
value-of take the string-value of a (usually single) node-set through this
path, so an XSLT transform over a wide flat fan-out rescanned the wide parent
once per extraction, turning the transform quadratic.

For a set of zero or one node the document order is irrelevant: the node is
trivially first. Return it directly without computing the key, the same
count <= 1 early-out sortedByDocumentOrder() already has.

A transform reading a couple of attributes and child values per element over a
20k-element document drops from ~2.3s to ~0.65s (~3.5x). The output is
unchanged: the full suite, the Apache Xalan XSLT gold-output corpus, the W3C
XPath/XSLT/EXSLT tests, and the WASM build stay green, and FirstInDocumentOrderTests
pins the single-node fast path and the multi-node document-first selection.
2026-06-25 18:12:45 +02:00
0bda990f80
perf(serialize): scan bytes, not graphemes, to find characters to escape (#297)
The bulk-copy escape loop still iterated Characters and compared each to '&',
'<', '>' (a grapheme comparison per character), the dominant remaining serialize
cost for content that needs escaping. Every escaping trigger is an ASCII marker,
or in ASCII-only mode a non-ASCII lead byte, so find the next one with
value.utf8.firstIndex(where:) and decode no grapheme for the run between markers.

The marker byte always lands on a grapheme boundary: an ASCII byte is its own
scalar and forces a cluster break (so '&', '<', '>', the quote, and whitespace
are never mid-cluster), and after a grapheme-aligned runStart the first byte at
or above 0x80 is a lead byte. So value[marker] and value[runStart ..< marker]
stay valid, and a multi-scalar cluster escapes as a unit (Swift rounds a
mid-grapheme index to its boundary, so the base letter is neither dropped nor
duplicated). The output is byte-identical.

Measured serialize ~23% faster on the 20k-item generated corpus via a same-state
A/B (~2.8x to ~2.2x slower than libxml2). Verified by EscapingBulkCopyTests
(the oracle differential extended with combining-mark, ZWJ, and flag clusters),
the Apache Xalan gold-output and C14N suites, the full suite, the WASM build,
and an adversarial grapheme-alignment review.
2026-06-25 15:39:20 +02:00
1d4ec91922
perf(serialize): bulk-copy verbatim runs when escaping output (#296)
Escaping.text and Escaping.attribute rebuilt a value that needed escaping one
character at a time: result += String(character) for every unescaped character,
plus the escaped form for each marker. Re-escaping a text node that holds a
single '&' rebuilt the whole value grapheme by grapheme, and the per-character
String construction and append dominated the serialize path for entity-bearing
content.

Copy each run between escapable characters in a single append and rewrite only
the markers. The no-escaping-needed fast path (return the value unchanged) is
untouched. The output is byte-identical: matching is per-Character so multibyte
graphemes copy whole, and the escaped forms are the same.

Measured serialize ~7% faster on the 20k-item generated corpus via a same-state
A/B (~3.0x to ~2.8x slower than libxml2). Verified by EscapingBulkCopyTests (a
per-character-oracle differential with multibyte cases), the Apache Xalan
gold-output and C14N canonicalization suites, the full suite, the WASM build,
and an adversarial review that included a 400k-input fuzz.
2026-06-25 15:02:03 +02:00
7d22194614
perf(parse): build the TreeNode tree directly from the event stream (#295)
parseTree ran TreeNode(parse(...)), which allocates two full trees: the parser
builds an immutable value Node tree, then TreeNode.init(_:) walks it to build
the mutable reference tree. The parsed tree is the deliverable, so building the
value tree first is pure overhead.

Parser.buildTree is the direct counterpart to build: the same iterative event
loop with the same element stack and the same well-formedness guards (an
unmatched end tag throws unexpectedEndOfInput; a document with no element throws
emptyDocument), but it accumulates TreeNode children per frame and adopts them
when an element closes, so the tree is allocated once. The produced tree is
identical: same kinds, names, attributes, values, child order, and parent links.

Measured parse ~8% faster on the 20k-item generated corpus via a same-state A/B
(6.1x to ~5.5x slower than libxml2). Verified by ParseTreeDirectBuildTests (a
structural differential against the convert-from-Node path, plus throw-parity
and a serialized round-trip), the full suite and the W3C XML conformance,
RELAX NG, and Xalan corpora (which all build trees through parseTree), the WASM
build, and an adversarial equivalence review.
2026-06-25 14:15:57 +02:00
6be96e51ce
perf(parse): scan for entity-decoder markers at the byte level (#294)
EntityExpander.expand located the next '&', '<', and ';' with String.firstIndex
over graphemes, so every literal byte of a run was decoded into a grapheme just
to look for a one-byte marker. All three markers are ASCII, so a UTF-8 index is
a valid string index: scan over raw.utf8 instead, and the found index still
subscripts the string directly (the marker byte is never a continuation byte,
so its index lands on a scalar and grapheme boundary).

The decoded text is unchanged, including runs where the marker follows
multibyte UTF-8: a precomposed letter, a combining sequence, or an astral
character all leave the marker on a grapheme boundary.

Measured parse ~5.5% faster on the 20k-item generated corpus via a same-state
A/B (6.5x to ~6.1x slower than libxml2). Verified by EntityDecodeBulkTests
(extended with four multibyte-boundary cases), the W3C XML conformance corpora,
the Xalan XSLT gold-output corpus, the full suite, and the WASM build.
2026-06-25 13:37:27 +02:00
cc2e1aaf7c
perf(parse): adopt prepared children when materializing the tree (#293)
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.
2026-06-25 13:02:20 +02:00
7a5761b64d
perf(parse): split the name prefix from a scanned colon offset (#292)
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.
2026-06-25 12:23:53 +02:00
2cca4912ff
perf(parse): bulk-copy literal runs in the entity decoder (#291)
EntityExpander.expand rebuilt every reference-bearing text run one character at
a time: result.append(character) per character, with a per-character charge
against the amplification budget. A text node holding a single &amp; was copied
grapheme by grapheme, which is the bulk of the per-text-node cost in a document
whose content uses entities.

Scan to the next '&' or '<' and append the literal run in one operation,
charging the budget by the run length. The decoded text, the CDATA-verbatim
shielding, and the billion-laughs budget enforcement are unchanged: the same
overrun throws at the same input, since charging a run of length L against
budget B fails exactly when L > B, the same condition the per-character guard
enforced.

Also return early from recordReferenceContentFindings when there are no DTD
content models (the common no-DTD case): it otherwise scanned each text run for
'&' and rendered the enclosing element's qualified name before the model lookup
that would fail anyway.

Measured parse ~5% faster on the 20k-item generated corpus via a same-state A/B
(9.06x to ~8.5x slower than libxml2). Verified by the W3C XML conformance
corpora, the new EntityDecodeBulkTests six-case decode differential, the full
suite, the Xalan XSLT gold-output corpus, and the WASM build, with an
adversarial equivalence review of the rewrite.
2026-06-25 11:45:02 +02:00
f6a093fe4b
perf(xpath): compile the step node test once per query (#289)
The descendant and attribute walks tested every candidate node through a
closure that called matchesTree / matchesAttribute with the step's NodeTest and
the evaluation namespaces dictionary:

    descendants(of: tree) { matchesTree($0, step.test, on: step.axis, context.namespaces) }

That passes the namespaces dictionary (and the NodeTest's associated string) by
value on every call, so a //name-style query over a wide document carried a
dictionary retain/release on each of tens of thousands of nodes, on the
traversal that dominates evaluation.

compiledTreeTest, compiledAttributeTest, and compiledNodeTest resolve the parts
that do not change as the walk moves (the axis principal kind, whether eval-time
bindings are in effect, the prefix-wildcard shape of the name) exactly once and
return a tight per-node closure capturing only the local name. The selected
nodes are identical to the unfused matchesTree / matchesAttribute / matches.

Measured xpath ~35% faster on the 20k-item generated corpus via a same-state
A/B, from ~2.0x to ~1.3x slower than libxml2. The full suite, the Apache Xalan
XSLT gold-output corpus, and the WASM build stay green. XPathCompiledNodeTestTests
is an exhaustive differential asserting each compiled closure equals the
function it specializes across every node, test, axis, and binding.
2026-06-25 09:44:04 +02:00
68f6680a66
perf(xpath): share focus-independent state through an Environment reference (#288)
An EvaluationContext carried the variable bindings, the function library, the
namespace bindings, and the budget by value. Re-focusing the context onto each
node of a node-set (focused(on:position:size:), called once per node when a
predicate is applied) copied all three dictionaries every time, even though
none of them changes as the focus moves.

Bundle the focus-independent state in an Environment reference type. The
context now holds the node, position, size, and an Environment reference;
re-focusing copies the three integers and shares the environment by reference
instead of copying and reference-counting three dictionaries. The values and
behavior are identical.

Measured xpath ~16% faster on the 20k-item generated corpus via a same-state
A/B (count unchanged at 10000). The full XPath suite, the Apache Xalan XSLT
gold-output corpus, and the WASM build stay green.
2026-06-25 08:52:28 +02:00
720263ee58
perf(xpath): fuse the node test into the attribute traversal (#287)
The attribute axis built an XPath node for every attribute of the element
and then filtered by the step's node test. A predicate like [@name='value']
runs that per matched element, so for a wide document it wrapped many
attributes only to discard all but one, and each wrap retains the owner
element and copies the attribute's qualified name and value.

Apply the same fusion the descendant axes use: test the raw attribute during
the walk and wrap only the ones that pass. matches delegates, for an
attribute node, to a new matchesAttribute (the attribute axis always carries
the attribute principal kind, so the reduction is exact), and the attribute
walk applies it as a keep filter. The node-set, declaration order, and
uniqueness are identical to build-then-filter.

Measured xpath ~20% faster on the 20k-item generated corpus via a same-state
A/B (count unchanged at 10000); xpath is now ~2x libxml2, down from ~13x at
the start of this work. XPathFusedAttributeTests covers every node-test kind
through the fused path, and an adversarial equivalence review found no query
whose result changes. Full suite, Apache Xalan XSLT gold output, and the WASM
build stay green.
2026-06-25 07:51:29 +02:00
52eee325bd
perf(xpath): fuse the node test into the descendant traversal (#286)
The descendant and descendant-or-self axes built the full subtree node list
and then filtered it by the step's node test. For a //name query over a wide
document most of the wrapped nodes were discarded immediately, and each wrap
retains a tree node whose weak parent forces the slow reference-counting path.

Run the node test against the raw tree node during the walk, so a rejected
node is never wrapped. matches(_:_:on:namespaces:) now delegates, for a tree
node, to a new tree-node matchesTree (one source of truth, exercised by the
full XPath suite), and the descendant walk applies it as a keep filter. The
node-set, document order, and uniqueness are identical to build-then-filter;
only non-matching nodes are no longer materialized.

Measured xpath ~11% faster on the 20k-item generated corpus via a same-state
A/B (count unchanged at 10000). XPathFusedDescendantTests covers every
node-test kind through the fused path, and an adversarial equivalence review
found no query whose result changes. Full suite, Apache Xalan XSLT gold
output, and the WASM build stay green.
2026-06-25 07:05:03 +02:00
cf8246a1cb
perf(xpath): build the descendant axis through a single accumulator (#285)
`descendants(of:)` recursed on wrapped XPath nodes and, at every node, built
a fresh array for that node's subtree and concatenated it
(`result.append(contentsOf: descendants(of: child))`). That is an array
allocation and copy per node, roughly O(n x depth) work over the subtree, and
the parse profile showed it dominating XPath evaluation for a `//`-style query
over a wide document.

Recurse on the raw tree node instead, appending into one shared inout
accumulator. The result is the identical node-set in the identical document
order; only the way the array is built changes, from O(n x depth) to O(n).

Measured xpath ~46% faster on the 20k-item generated corpus via a same-state
A/B (count unchanged at 10000). Full suite, the Apache Xalan XSLT gold-output
corpus, and the WASM build stay green.
2026-06-25 06:30:23 +02:00
5ad3cf10fc
perf(xpath): skip the document-order sort for an already-ordered single step (#283)
A path that is a single forward-axis step from one context node returns its
nodes already in document order with no duplicates, so the document-order
sort is redundant. AxisNavigation produces the structural forward axes
(child, descendant, descendant-or-self, self, following, following-sibling)
in document order, deriving sibling order from the same children array the
order key compares, so the two can never disagree. This is the common shape
after //x compiles and fuses to a single descendant::x step.

The attribute axis (whose order interleaves with namespace declarations), the
namespace axis (produced in dictionary order), and every reverse axis
(nearest-first) are excluded and still sorted. Measured xpath ~7% faster on
the 20k-item generated corpus via a same-state A/B, identical results.
XPathSingleStepOrderTests pins document order for each included axis, that the
reverse axes still normalize, and that multi-step paths still sort. An
adversarial order-correctness review found no query whose order or uniqueness
the change breaks.
2026-06-25 05:04:00 +02:00
4bd547d4b1
perf(xpath): drop redundant node-set de-duplication when sorting a path (#282)
A path expression sorted its result into document order through orderUnique,
which first ran a Set-based de-duplication. But evaluateSteps already returns
a duplicate-free node-set: each step de-duplicates across its context nodes,
or uses an axis whose per-context results cannot overlap. So that Set copied
every node for nothing, and each copy retains the wrapped tree node, whose
weak parent reference forces a side table and the slow reference-counting
path.

Path and trailing-path evaluation now sort the steps' result directly.
Unions and filter primaries, which genuinely can carry duplicates, keep the
de-duplicating pass. Measured xpath ~9% faster on the 20k-item generated
corpus via a same-state A/B, identical results. The existing node-set
de-duplication and union tests cover that a path still yields each node once.
2026-06-25 04:18:50 +02:00
b35f4a17ef
perf(xpath): fuse // into a descendant step for non-positional predicates (#281)
`//X` compiles to `descendant-or-self::node()/child::X`, which materializes
every node of the subtree as an intermediate context before selecting the X
children of each. Fuse that pair into a single `descendant::X` step at compile
time: the two select the same node-set, so evaluation skips the intermediate.

The fusion is transparent except for a positional predicate: `//x[1]` is the
first x child of EACH parent, not the first x in document order, so it must
not become `descendant::x[1]`. Apply the rewrite only when every predicate is
provably non-positional: its value is a boolean, node-set, or string (never a
number, which XPath reads as position() = value) and it nowhere references
position() or last(). Anything not provably both is left unfused, so a result
can never change.

Measured xpath ~10x to ~7x slower than libxml2 on the 20k-item generated
corpus via a same-state A/B, identical results. XPathDescendantFusionTests
pins the positional forms keeping their per-parent meaning, the non-positional
forms selecting across the subtree, and composition with trailing and nested
steps. An adversarial differential review (fused versus a spec-literal unfused
reference over nine attack suites) found no query whose result changes. The
boolean-function allowlist carries a documented invariant: every entry must be
position- and size-independent, since the position scan is syntactic.
2026-06-25 03:21:03 +02:00