Compare commits

...

314 Commits

Author SHA1 Message Date
9410d9ab9b ci: run the workflows on git.aleahim.com, and point the CI links to it
Some checks failed
Style and namespacing / Style and namespacing (push) Failing after 7s
Swift (Linux) / Swift (Linux) (push) Failing after 2s
Swift (macOS) / Swift (macOS) (push) Failing after 6s
Swift (WASM) / Swift (WASM) (push) Failing after 1m4s
XSTS conformance / XSTS conformance (push) Failing after 2s
2026-11-04 21:16:42 +01:00
df469fd77c docs(changelog): note the new vendor-url of the XSLT system property
Some checks are pending
Style and namespacing / Style and namespacing (push) Waiting to run
Swift (Linux) / Swift (Linux) (push) Waiting to run
Swift (macOS) / Swift (macOS) (push) Waiting to run
Swift (WASM) / Swift (WASM) (push) Waiting to run
XSTS conformance / XSTS conformance (push) Waiting to run
2026-11-04 20:53:29 +01:00
08782ce768 docs: point the links of this repository to git.aleahim.com 2026-11-04 20:52:05 +01:00
a078788ec9 chore(rules): refresh the vendored rules-swift to v2.14.0 2026-11-02 22:11:14 +01:00
ca65a334f7 refactor: split oversized types across companion files for lint budgets 2026-08-31 05:00:44 +02:00
b0c64d86b2 docs(benchmarks): remove em dashes from interning writeup 2026-08-31 03:53:28 +02:00
fb9dab2028 perf(serialize): scan a raw contiguous buffer for the escaping fast-paths
Fifth serialize-path pass (#139, docs/benchmarks.md pass 31). textNeedsEscaping,
attributeNeedsEscaping, and the comment/processingInstruction existence checks
run unconditionally on every text node, attribute value, comment, and PI in a
document, scanning through UTF8View's default Collection iteration. Profiling
a new deeply-nested, attribute-dense corpus (the standard flat catalog corpus
has no density to indict this) put the two needsEscaping functions as the
largest PureXML-specific frames in the sample. A standalone micro-benchmark
confirmed withContiguousStorageIfAvailable's raw buffer scan measures ~2-2.75x
faster than the Collection-based scan, with a Collection fallback kept for a
non-contiguous (foreign/bridged) string.

Two abstraction attempts were tried and rejected before landing this shape: a
shared generic helper taking a predicate closure recovered most but not all of
the win, and calling a separate small function from inside the buffer loop (to
dodge a cyclomatic-complexity violation) erased nearly the entire win even
though it isn't a closure value. The violation is instead resolved by splitting
each function into a contiguous/collection pair, moving the call boundary
outside the loop rather than inside it.

Output is byte-identical by construction; full local suite (1925 tests) and
full lint unchanged. Measured with interleaved same-state A/B under real
concurrent machine load: clean with zero overlap across 5 pairs at the nested
corpus (~9%), noisier at the standard corpus but the means still separate
clearly (~4%).
2026-07-15 04:25:59 +02:00
d359208ff6 perf(serialize): precompute the attribute quote, bulk-copy comment/PI escaping
Two profile-guided serialize passes (#139, docs/benchmarks.md passes 29-30).
The attribute writer now precomputes the quote character as a String once
per element instead of interpolating a Character into every attribute,
avoiding the grapheme-breadcrumb machinery that repeats twice per attribute
for a value that never changes within an element. Escaping.comment and
processingInstruction's escaping fallback (taken whenever a comment has a
hyphen or PI data has a '?', a case pass 8 could skip past but not speed up)
is rewritten in the same byte-scan bulk-copy shape as passes 23-24, so a
value with one rare trigger no longer pays a full character-by-character
rebuild.

Both are same-observable-behavior changes. The quote change is byte-
identical by construction. The comment/PI rewrite is verified against a new
differential suite that reimplements the original character-loop as an
independent oracle and checks adjacency/trailing/combining-mark cases plus
two 20000-case seeded fuzzes, since a byte match inside a combining-mark
cluster is not, on its own, the bare trigger character. Full local suite
(1925 tests) unchanged.

Measured with interleaved same-state A/B: pass 29 ~1.5% at a 100k-item
corpus; pass 30 ~2x on a dedicated comment/PI micro-benchmark (the standard
corpus has no comment/PI content, so real documents with copyright banners
or hyphenated comment prose see this, not the headline ratio). A same-
session multi-append restructuring attempt measured a clear ~6% regression
and was reverted; a suspected hasInlineContent double-scan was profiled at
~0.2% of samples and not worth a pass. Both outcomes are recorded in
docs/benchmarks.md rather than dropped.
2026-07-15 04:05:29 +02:00
380160cc2b refactor(validation): decompose XSD instance validation into composable rules
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.
2026-07-12 14:12:17 +02:00
36f1485f2e docs(benchmarks): record the pass-29 dead end and the #8 weak-parent decision
The byte-path close-quote consume candidate measured NEUTRAL in an
interleaved A/B despite the sampler attributing ~4% of parse to the
advance() chain it replaced: decoding one ASCII scalar into a
small-string-backed Character costs about the same as consume's
literal-matching path. Recorded under Known structure of the gap so no
future round redoes it, with the meta-lesson that sample attribution
overstates single-character round trips and only the interleaved A/B
verdict counts.

Also records the #8 decision: the TreeNode weak-parent side-table cost
(~9% of parse) is accepted for now. unowned is rejected outright since a
trap after release contradicts the never-crash posture and the documented
nil-after-release behavior; the arena/index-based tree remains the
designed future path, sequenced after the scanning-architecture work,
which carries ~47% of parse to this finding's ~9%.
2026-07-12 14:09:37 +02:00
2b6da0d0a0 perf(parsing): reuse one scratch set for the attribute duplicate-name check
Fourth profile-guided pass (#139, docs/benchmarks.md pass 28). scanAttributes
allocated a fresh Set<String>'s native storage on every element with
attributes, just to check a handful of names for duplicates; the set is now a
stored EventReader scratch property cleared with removeAll(keepingCapacity:)
per element, so each element's check reuses the previous element's storage.
Safe because scanAttributes cannot re-enter itself (nothing inside an
attribute scan starts another start-tag scan), so the shared storage never
sees two interleaved scans; duplicate detection, recovering-mode behavior,
and thrown errors are unchanged.

Verified in an isolated worktree at the previous commit: full local suite
(1858 tests) and full W3C XSTS suite (14383 groups) pass unchanged. Measured
with an interleaved same-state A/B, baseline/patched/baseline/patched each
best-of-5, because the machine carried concurrent load this session: both
patched runs (0.1073s, 0.1067s) beat both baseline runs (0.1091s, 0.1117s),
~2-4% on parse.

Recorded alongside, per the benchmarks log's discipline: a sibling candidate
(reserving each tree frame's children capacity on first append) measured
neutral under the same protocol and was reverted rather than landed, and the
TreeNode weak-parent side-table cost (~9% of parse, swift_weakAssign per
child plus a runtime side table per parent) is documented under Known
structure of the gap as needing a design decision, since parent's
nil-after-release semantics are documented public API. Touched lines carry
repo-standard swiftformat rewraps applied by a concurrent formatter run.
2026-07-12 12:56:12 +02:00
fe0400c3df perf(parsing): byte-path quote handling, lazy expanded-name check, byte-scan entity guard
Third profile-guided pass (#139, docs/benchmarks.md pass 27), targeting
scanContentMarkup(), which inlines the start-tag/attribute/namespace scanners
and carries ~46% of parse time.

scanAttributeValue's opening and closing quote checks ran on the Character
path (peek + advance, buffering the quote as a Character only to compare and
discard it) on every attribute value in the document; both now check the raw
byte first, reaching the byte fast path with no Character buffering, with the
Character path kept as the off-fast-path fallback for streaming sources. The
quote bytes are ASCII and non-CR/LF, so the raw byte and the normalized
stream agree, the same reasoning peekByte's other callers already rely on.

NamespaceContext.checkExpandedNames allocated a Set's native storage and
built a concatenated URI+local key string per element even when at most one
attribute carries a namespace URI, where no duplicate is possible (the
default namespace never applies to attributes, and xmlns declarations carry
no URI). Counting URI-bearing attributes first, with no allocation, and
returning early below two skips all of it on the overwhelming majority of
elements; for xml:* attributes specifically the key exceeded the small-string
limit, so this also removes a real per-element heap allocation.

EntityDecoder.decode/decodeLenient guarded with raw.contains("&"), a
grapheme-by-grapheme walk to find one ASCII byte, once per ampersand-bearing
text node or attribute value; raw.utf8.contains(0x26) finds the same byte
without decoding graphemes, the same shape as checkNameShape's colon check.

The quote-handling extraction into two helpers also resolves the cyclomatic
complexity the inline version tripped. Six new regression tests pin the
quote edge cases (empty value, single-quoted, a quote kind nested inside the
other, unterminated at end of input) plus byte/Character-path agreement for
each in the parser path differential.

Verified: full local suite (1858 tests) and full W3C XSTS suite (14383
groups) pass unchanged. Parse ~6.00x -> ~4.75x versus libxml2 at the
20000-item corpus, best of 5.
2026-07-12 12:27:00 +02:00
7ffd1dd545 perf(parsing): remove redundant work in attribute-value and namespace scanning
Two profile-guided passes (#139), found by sample-profiling a 42MB generated
corpus after re-measuring the parse/libxml2 ratio fresh:

scanAttributeValue's closing-quote check re-derived an already-known answer
through Reader.consume's general String-iteration path, which allocates a
fresh String(quote) and iterates it via Character machinery twice, on every
single attribute value in the document. The content loop's own break
condition already guarantees the buffered peek is either the quote or end of
input, so checking it directly and advancing once is the same behavior for
less work.

NamespaceContext.enterElement, called once per element: checkNameShape's
name.localName.contains(":") resolved to Sequence.contains(_:), walking
extended grapheme clusters to find one ASCII byte; utf8.contains(0x3A) finds
the same colon via the UTF-8 byte view. Separately, attributes.map{
resolveAttribute } allocated a fresh backing array on every element even
though resolveAttribute returns its argument unchanged whenever the attribute
has no prefix or the xmlns prefix (the common case); checking first with no
allocation and reusing the input array when nothing would change gives the
identical result without it.

Both are same-observable-behavior changes, verified against the full local
suite (1852 tests) and the full W3C XSTS conformance suite (14383 groups),
both unchanged. Measured parse ~6.59x -> ~6.00x versus libxml2 at the
20000-item/4.2MB corpus, best of 5; see docs/benchmarks.md passes 25-26.
2026-07-12 11:40:33 +02:00
433ccfaec5 chore(codeberg): update links for NodetteHQ 2026-07-05 12:59:57 +02:00
6a7bf78903 fix(schema): literal RecurseAsIfGroup for a non-repeating base choice
An element restricting a base choice was always checked by the
effective-total-range approximation, which exists for one reason: the
literal section 3.9.6 pairing has no allowance for occurrence counts
accumulated across the choice's own repetitions and over-rejects
W3C-read-valid schemas (particlesZ001, whose manifest calls the rule
ambiguous). That ambiguity is impossible when the choice cannot repeat:
at {max occurs} = 1 every accepted child comes from one traversal of one
branch, so the literal rule governs. elementRestrictsChoice now applies
the literal pairing (the derived element, with its own occurrence range,
must validly restrict some non-pointless branch) when base maxOccurs is
1, and keeps the effective-range reading only for repeating choices.

Closes XSTS particlesHa161 (a derived a{0,1} is not a pointwise
NameAndTypeOK restriction of a required branch a{1,1}) and particlesM034
(a derived d1+ cannot map into the branch (d1*, d2+): the required d2 is
unmapped and not emptiable). The settled-valid neighbors that pin the
boundary from the other side all still pass: Ha160, M002, M003, M035,
and the Z001 shape, each now a regression test in
XSDParticleChoiceRestrictionTests. invalid-schemas-accepted 6 -> 4.

particlesIg004 is reclassified as spec-divergent (4 -> 3): section 3.3.2
maps block="#all" and the explicit substitution/extension/restriction
list to the SAME {disallowed substitutions} set, so NameAndTypeOK clause
5's superset requirement holds and the invalid expectation has no spec
path; it is jointly unsatisfiable with the settled Ig001/002/003 (equal
sets, valid) and Ig005 (#all covers the triple, valid).

The remaining three schema deviations are documented divergences, not
open bugs: Ik025/Ik027 (cos-st-derived-ok 2.2.4 reading) and schN12,
newly adjudicated in docs/xsts-deviations.md: its chameleon redefine
fails src-redefine 6.2.2 on NameAndTypeOK clause 1 (no-namespace locals
vs qualified locals), but 6.2.2 cannot be enforced uniformly because
mgO013's settled-VALID schema also violates it; any uniform enforcement
trades a false negative for a valid-schema-rejected false positive.

Also corrects the stale deviations-doc row that listed the already-closed
particlesIk011 and omitted schN12; the failures file is the authority.

Gates: full XSTS release suite green at 0/3/0/4 (valid buckets unchanged
at zero), libxml2 differential green at 244 pinned entries (Ha161 and
M034 newly pinned, both compile under libxml2), full local gate green.
2026-07-05 08:58:35 +02:00
2e3d2e25f8 fix(schema): reject substitution-member swaps in particle restriction
NameAndTypeOK clause 1 (XSD 1.0 section 3.9.6, Elt:Elt) pairs the declared
element declarations and requires the same {name} and {target namespace};
no clause grants substitution an allowance. The substitution-group
expansion rewrites the compiled term (an abstract head is dropped, members
join a synthetic choice), so the checker previously compared the expanded
names and a head-for-member swap looked name-identical.

Particle.declaredReference now preserves the pre-expansion identity of an
element-ref particle, and the restriction check pairs the declared
references before the structural pairing table (extracted as
structuralValid). Closes XSTS elemZ026 and particlesV020:
invalid-schemas-accepted 8 -> 6, valid buckets unchanged at zero.
Stricter than libxml2, which does not implement this edge; elemZ026 is
pinned in the differential baseline accordingly.

particlesZ008, byte-for-byte the same construct as elemZ026 but carrying
a settled valid expectation contradicting elemZ026's settled invalid, is
reclassified as a spec-divergent schema test with the adjudication
recorded in the suite (exclusion tables split into
Tests/XSTSSuiteExclusions.swift for file length).

Separately attributed: the differential baseline gains ten further pinned
entries (addB070a, addB108, addB177, attO025, elemZ027_c, errC008,
groupH021v, notatB005, notatE002, particlesEb040) that reproduce with
this change stashed. They are pre-existing drift from earlier main-line
fixes, not effects of this commit. Baseline 231 -> 242, differential
green.
2026-07-05 08:27:55 +02:00
f7d8fa16d1 docs(roadmap): rebuild tracker references after the GitHub loss
The roadmap and README cited issue numbers from the deleted GitHub
tracker and carried stale XSTS figures (0/39/0/26 and 0/43/0/31 against
the enforced 0/8/0/4). Recreate the load-bearing issues on Codeberg
(#3 production-readiness epic, #4 and #5 gate trackers, #6 performance,
#7 Xalan burn-down), cite them, correct every baseline figure to the
test ratchet, mark shipped work (located diagnostics, the differential
harness) as done, and note that pre-migration numbers in the historical
narrative refer to the lost tracker.

Closes #2
2026-07-05 05:49:57 +02:00
6c2810d71a Merge release/0.4.8: superseded by the 0.4.8 stamp on main
Every unique commit on the branch (the changelog stamp, the Codeberg
repoint, and the rules-baseline stamps) was re-applied on main before
tagging v0.4.8, so the merge records the supersession without content
changes.
2026-07-05 05:38:16 +02:00
d63218256c chore(release): stamp 0.4.8 from main and repoint to codeberg.org/Mihaela
The earlier release/0.4.8 branch stamped the changelog but the tag was
never cut, and main moved 60 commits past it. Fold the accumulated
unreleased entries into the still-unpublished 0.4.8 section, redate it,
repoint every remaining github.com/mihaelamj reference (badges, support
links, roadmap, the XSLT vendor-url system property), and track the
shared-rules baseline v1.1.2.
2026-07-05 05:31:16 +02:00
ff0c6d4c3f chore(rules): track shared-rules baseline v1.1.2
The v1.1.1 and v1.1.2 canon changes touch only the canon's leak-gate
script, which this repo does not vendor, so the stamp is the whole fold.
2026-07-05 00:27:45 +02:00
d57457caf4 chore(rules): track shared-rules baseline v1.1.0 2026-07-02 21:35:32 +02:00
Mihaela Mihaljevic
5aee644bf4 chore: repoint remaining github.com/mihaelamj references to codeberg.org/Mihaela 2026-07-02 14:08:21 +02:00
c7ef8abfd9 chore(rules): pin rules-swift corpus, add gates and CI; drop redundant docs/rules mirror
Adopt the rules-swift pin-and-codify strategy in snapshot mode (third_party/rules-swift
@97dc70f + sha256), add codified gates, a conformance audit ledger, and
.github/workflows/rules-swift.yml (corpus-pin integrity required, full gate report mode).
Remove the docs/rules mirror it supersedes (repo-specific files kept under docs/). Style
and leak gates exclude the vendored corpus.
2026-06-30 14:02:40 +02:00
36b2a73637
chore(concurrency): justify every @unchecked Sendable / nonisolated(unsafe) (#387)
The rules-swift concurrency rule's acceptance check requires every `@unchecked
Sendable` and `nonisolated(unsafe)` to carry an adjacent comment explaining why
the race is acceptable; an unjustified one is a violation. An audit found six
escape hatches whose comment explained the type but not the race:

- The four XPath `FunctionTable` statics (`CoreFunctions`, `StringFunctions`,
  `NodeFunctions`, `Evaluator.library`): each is an immutable, build-once lookup
  table never mutated after initialization, so concurrent reads race on nothing;
  `FunctionTable` is un-`Sendable` only because it stores closures.
- `Validation.Validator`: immutable after `init` (every stored validation list is
  a `let`); the erased `AnyValidation` closures are simply not provably `Sendable`.
- The test `ResultBox`: written once by the worker thread before its semaphore
  signals and read only after the wait, so the write happens-before the read.

Comments only; no behaviour change. `LoaderBox` and `ElementStorage` were already
justified. swiftformat/swiftlint --strict and the full suite stay green.
2026-06-29 15:12:02 +02:00
41338c9e3f
test(schema): adjudicate the instance differential with an independent oracle, not libxml2 alone (#386)
The instance-validation differential treated "PureXML rejects, libxml2 accepts"
as a PureXML false positive. That is unsound: libxml2's own content validation is
lenient and accepts some INVALID instances. A DIFF_SEED sweep surfaced exactly
such a case (seed 13371337): the model ( (a*|c*){1,3}, b? ){1,3} and the instance
<doc><b/><b/><a/><b/><c/><a/></doc>. libxml2 accepts it, but it is genuinely
INVALID (the three b's anchor three (choice,b?) segments, leaving the trailing
'ca' with no fourth segment), so PureXML's rejection is correct, not a false
positive. Verified against an external regex engine and exhaustive enumeration.

Add ContentModelOracle, an independent brute-force membership matcher over element
names, implemented separately from PureXML's matcher. The generated instances are
empty xs:string elements, so an instance is valid iff its element-name sequence is
in the model's language, which this oracle decides exactly. The generator now
emits the structured ContentModel alongside the schema string (group references
inlined), and the differential classifies a false positive / false negative
against the ORACLE as ground truth; libxml2 is kept as a secondary cross-check
whose disagreements with the oracle are counted (its leniency) but never charged
to PureXML.

The oracle is unit-tested (ContentModelOracleTests), including the libxml2-leniency
case it exists to catch. Over a sweep of seeds the differential now reports zero
oracle-adjudicated false positives and zero false negatives, so PureXML's content
matcher agrees with the independent oracle on every generated case; the default
seed is unchanged. Test-only; no library behaviour change.
2026-06-29 12:32:08 +02:00
77884f99b8
test(schema): make the libxml2 differential seed configurable via DIFF_SEED (#385)
The three generated-vs-libxml2 differentials (instance, facet, determinism) each
ran a single hardcoded seed, so they characterised one fixed slice of the open
input space rather than sampling it. Each now reads an optional DIFF_SEED
environment override (defaulting to its existing seed, so the default run is
unchanged), turning the fixed smoke test into an open-space fuzzer: a sweep over
many seeds explores far more of the unbounded schema/instance space than the
finite XSTS corpus, which is the production-readiness "uncharacterized
correctness" stopper.

A 12-seed sweep of the instance differential immediately surfaced a genuine
content-matcher false positive (cardinal-rule violation) at DIFF_SEED=13371337:
the model `( (choice(a*|(c*)+)){1,3}, b? ){1,3}` rejects `<doc><b/><b/><a/><b/>
<c/><a/></doc>` ("element 'c' is not allowed here") though libxml2 accepts it. A
nested nullable-repetition edge in the graph-structured-stack matcher, captured
for a focused fix; the default seed and every other swept seed hold false
positives at 0.
2026-06-29 10:44:43 +02:00
c55522669e
test(schema): reclassify particlesZ001.i as a contested XSTS test, not a deviation (#384)
particlesZ001 declares Derived as a complexContent restriction of Base and an
instance whose <elem> (type Derived) content is annotation + four elements. The
suite pairs a valid schemaTest with an invalid instanceTest, but the two are
mutually exclusive for a consistent conformant processor, and the manifest's own
documentation labels the governing rule, Particle Derivation OK (RecurseAsIfGroup),
ambiguous.

cvc-complex-type (§3.4.4) validates an instance against the element's own
{content type}: for <elem> that is Derived = sequence(annotation?, element*),
which the instance satisfies, so the instance is valid. Particle Valid
(Restriction) / RecurseAsIfGroup (§3.9.6) is a schema-assembly constraint that is
never re-run during instance validation, so there is no path from an accepted
schema to an invalid instance. The only way to reach the suite's invalid is to
reject the schema as an invalid restriction, which is a valid-schema-rejected
false positive and would still fail the suite's own valid schemaTest. libxml2
accepts both schema and instance, agreeing with PureXML.

Exclude particlesZ001.i via specDivergentInstances with the spec-grounded
justification in code and docs/xsts-deviations.md. invalid-instances-accepted
5 -> 4; full XSTS in release now 0/8/0/4, both false-positive buckets at 0. No
library behaviour change. Confirmed by an adversarial analysis that found no
spec-coherent reading yielding valid-schema + invalid-instance.
2026-06-29 10:27:46 +02:00
20da41995a
fix(schema): resolve an unprefixed xsi:type in the instance namespace context (#383)
An xsi:type value is a QName: a prefix binds through that prefix, an unprefixed
name through the in-scope default namespace, and with no default namespace an
unprefixed name is in no namespace. PureXML stores each named type under both its
namespaced key and a bare local-name alias (for lenient lookup), so an unprefixed
xsi:type="Test" matched a {targetNS}Test through the alias even when the instance
had no default namespace, wrongly typing an otherwise-undeclared root and
accepting the document (W3C XSTS targetNS00101m; libxml2 rejects it identically).

xsiTypeReference now keys an unprefixed xsi:type to the in-scope default namespace
(none here) rather than returning the bare local name, so xsi:type="Test" names
no-namespace Test, which does not exist; the root then has neither a declaration
nor a usable type and the instance is invalid. A prefixed xsi:type and an
unprefixed one under a default namespace equal to the target namespace are
unaffected, and the child-element builtin-substitution path keeps its bare-name
fallback (xsiTypeName), so an unprefixed builtin still substitutes where valid.

Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
6 -> 5, valid-instances-rejected held at 0 across all 14383 groups, full suite
(1840 tests) and wasm green. An adversarial critic traced every resolveNamedType
branch, including the unprefixed-builtin-via-key case, and confirmed the only
path that no longer resolves corresponds to a spec-invalid reference, so no valid
instance is newly rejected.
2026-06-29 10:10:04 +02:00
0e782737a3
test(schema): reclassify mgO013.i as a contested XSTS test, not a deviation (#382)
mgO013 redefines a model group `group` to `all{e1(1,1)}`, redefining a base
`all{e1(0,0)}` (e1 forbidden) without referencing itself. The suite pairs a
`valid` schemaTest with an `invalid` instanceTest, but the two are mutually
exclusive for a consistent conformant processor.

src-redefine clause 6.2.2 makes a no-self-reference group redefine a restriction
obligation: the redefined group must be a valid restriction of the original.
`all{e1(1,1)}` is not a valid restriction of `all{e1(0,0)}` (Particle Valid
(Restriction), Occurrence Range OK: derived max 1 > base max 0), so enforcing
6.2.2 rejects the schema, a valid-schema-rejected false positive against the
schemaTest's own `valid` verdict. Yet §4.2.2 makes the referencing type's group
reference resolve to the redefined `all{e1(1,1)}`, against which the single-e1
instance is valid. There is no third path: reaching instance-invalid requires
rejecting the schema. libxml2 accepts both, agreeing with PureXML and the
schemaTest. An adversarial analysis confirmed no reading yields valid-schema +
invalid-instance.

Exclude mgO013.i via specDivergentInstances with the spec-grounded justification
in code and docs/xsts-deviations.md. invalid-instances-accepted 7 -> 6; full XSTS
in release now 0/8/0/6, both false-positive buckets at 0. No library behaviour
change: matching the suite would introduce the false positive the production bar
forbids.
2026-06-29 09:36:02 +02:00
8f2725b62a
fix(schema): reject an identity-constraint field that selects a non-simple node (#381)
XSD 1.0 §3.11.4 (cvc-identity-constraint.3) requires each key/keyref/unique
field to identify a node of simple type. A field that reaches an element
declared with no type (the ur-type anyType) or an inline complex type without
simple content does not, so the instance is invalid; the reference (libxml2)
rejects it identically. PureXML accepted such instances because its identity
validator could resolve a field target's type only for a global element reached
by a direct-child name, not for a local element reached through a path (the W3C
XSTS case idZ010 nests anyType fields several levels deep, e.g. a key field
./b/c where c has no type).

A compile-time pass (XSDParser.identityNonSimpleFieldKeys) resolves each field's
target element through its selector and field paths, by element-name steps, and
records the fields whose declared target is provably non-simple. The validator
rejects those at runtime unless the instance gives the node a simple type
through xsi:type, since cvc reads the node's governing type (a text-only anyType
element with xsi:type="xsd:int" is a valid field). The resolution is
conservative: any wildcard, descendant axis, predicate, ref, or named
non-builtin type leaves the field unflagged, so a valid field is never rejected.
The one residual divergence path, two same-name child elements of differing
simplicity in one inline model, is an Element-Declarations-Consistent violation
the compiler already rejects.

Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
8 -> 7 (idZ010 now rejects), valid-instances-rejected held at 0 across all 14383
groups (no false positive), full suite (1837 tests), wasm, and lint all green.
An adversarial critic that read every file and probed for a false positive could
not construct one; the failure mode is under-rejection, the safe direction.
2026-06-29 09:08:25 +02:00
7ea1a88a6c
test(schema): reclassify schA2.i/schA5.i as contested; characterize the sch* tail (#380)
The sch* schema-location cluster, dissected per case rather than swept. An
adversarial analysis separated the contested tests from the genuine deviations.

schA2.i and schA5.i are contested and now excluded (invalid-instances-accepted
10 -> 8). Each group's own documentation states the inline xsi:schemaLocation
hint "is ignored" and the instance "must conform to" the collection schema, but
under exactly that prescribed behavior the instances are valid: libxml2 validates
schA2.xml against its a.xsd and schA5.xml against its a.xsd+b.xsd collection. So
the suite's invalid expectation contradicts its own documentation. Reaching
invalid requires letting the instance's hint override a schema already loaded for
that namespace, which XSD 1.0 §4.3.2 makes optional (strategy 1: do nothing when a
schema for the namespace is already known) and which would be a direct false
positive for an IDE: an instance file forcing rejection against the project's own
schema. PureXML's policy (honor hints but keep the collection's declarations on
conflict) is conformant.

schA7.i is NOT excluded. The adversarial pass showed its invalid IS spec-grounded:
with hints ignored, the instance's b:e1 (namespace ns-b) has no declaration in the
a.xsd-only collection and is demanded by root's default-strict ##any wildcard, so
§3.10.4 makes it invalid. PureXML accepts it only by honoring the ns-b hint to
gap-fill, the other conformant §4.3.2 strategy. A genuine policy-dependent
deviation, left in the count and documented.

schU3.i/schU4.i/schU5.i are also left in the count, documented as a deferred
under-rejection in an underspecified area: circular redefine chains where an
attribute group is redefined to conflicting types across an include/redefine
cycle. The suite expects the int reading (so c1="a" is invalid); PureXML resolves
the laxer type and accepts. libxml2 does not support the construct at all (rejects
the schema: multiple redefinition not supported), so matching the suite means
out-resolving the reference on a construct whose cyclic composition order the spec
does not pin down.

Full XSTS in release now 0/8/0/8, both false-positive buckets at 0. No library
behavior change; reasoning recorded in code and docs/xsts-deviations.md.
2026-06-29 07:52:13 +02:00
35e52f26cf
test(schema): reclassify anyURI_a004_1339.i as a contested XSTS test, not a deviation (#379)
The W3C XSTS group anyURI_a004_1339 pairs a `valid` schemaTest with an `invalid`
instanceTest, but the two expectations are mutually inconsistent, so PureXML's
acceptance of the instance is spec-correct rather than an under-rejection.

The schema's include, import, and redefine carry unresolvable schemaLocations
(ftp://, gopher://, mailto:). XSD 1.0 §4.2.1 makes an unresolvable include "not
an error" (no inclusion performed), the import likewise, and the empty redefine
imposes no resolution requirement (src-redefine clause 1 gates that on
non-annotation children), so the schema is conformantly valid, agreeing with the
schemaTest. The only components the instance exercises (root, bar, st, att) are
fully locally defined, and every instance value is byte-identical to one of the
type's six anyURI enumeration members, so a strict assessment of the document
element is valid. There is no spec path to valid-schema + invalid-instance.

libxml2 is no authority here: it non-conformantly fails to compile the schema on
the unresolvable include, so it disagrees with the suite's valid-schema verdict
too. Matching the suite's invalid-instance expectation would mean rejecting a
valid instance whenever an include cannot be resolved, a false positive an IDE
editing loop hits constantly, which the production bar forbids.

Exclude it from the counts via specDivergentInstances, mirroring how the suite's
own status="queried" entries are skipped, with the spec-grounded justification
recorded in code and docs/xsts-deviations.md. invalid-instances-accepted 11 -> 10;
full XSTS in release now 0/8/0/10, both false-positive buckets at 0. Two
independent adversarial analyses failed to refute the instance's validity.
2026-06-29 07:30:00 +02:00
2eb9f83958
fix(schema): back XSD \i and \c with the XML 1.0 Appendix B name classes (#378)
XSD 1.0 (Datatypes Appendix F) defines the regex escape \i as the characters
that may begin an XML Name (Letter | '_' | ':') and \c as the characters that
may appear in one (NameChar), citing the XML 1.0 Second Edition productions:
the enumerated, Unicode-2.0-era character classes of XML 1.0 Appendix B
(BaseChar, Ideographic, CombiningChar, Digit, Extender). \i and \c were wired
to PureXML.Parsing.XMLCharacter, which correctly implements the Fifth Edition
Name production used to parse live XML, but the two genuinely differ: the Fifth
Edition admits whole blocks where the Second Edition has gaps, so \c
over-accepted, e.g. the combining mark U+0346 (COMBINING BRIDGE ABOVE), which
Appendix B's CombiningChar excludes (its first range ends at U+0345).

Add XSDNameCharacter, transcribed clean-room from the Appendix B grammar
(identical across editions one through four) and stored as sorted, non-
overlapping code-point ranges with binary-search membership; the parser's own
name check is untouched. The two tables reproduce the W3C XSTS \i/\c
enumerations (reZ005, reZ006: 34516 and 35122 code points) exactly and in both
directions, so the more restrictive definition rejects no spec-valid input:
there is no false-positive surface.

Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
12 -> 11 (reZ006i, whose value is U+0346, now rejects), both false-positive
buckets at 0. Full suite (1834 tests) green; an adversarial critic brute-forced
the binary search over the whole code-point space (0 mismatches), confirmed the
tables are sorted and non-overlapping and equal the oracle sets, and that the
parser's Fifth Edition path is left intact. Found by triaging the invalid-
instance tail to a single boundary code point (U+0345 valid vs U+0346 invalid)
and reading the XML 1.0 Appendix B grammar.
2026-06-29 07:05:27 +02:00
1919b92956
fix(schema): match Ethiopic digits against \d at the pinned Unicode 3.2 repertoire (#377)
XSD 1.0 fixes the regular-expression repertoire at Unicode 3.2 (Datatypes
Appendix F), where U+1369..U+1371 (ETHIOPIC DIGIT ONE..NINE) are general
category Nd, each carrying a decimal value; a later Unicode reclassified them
to No. XSDCategory derives \d (== \p{Nd}) from the running platform's category,
pinned to 3.2 by an assigned-in-3.2 gate plus a per-code-point override table,
but the table omitted these nine, so \d used the modern No: a valid \d-constrained
Ethiopic digit was wrongly rejected (cardinal-rule false positive) and a
\D-constrained one wrongly accepted.

Pin the nine to Nd. This is the complete set of code points that were Nd at 3.2
and are not today; the reverse direction is empty (every later-Nd point was
unassigned at 3.2 and already reports Cn), verified by diffing the 3.2 and current
Unicode Nd sets. \w is unaffected: an Ethiopic numeral is category N either way,
neither punctuation, separator, nor other.

Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
14 -> 12 (reT17, reT38, the \D cases, now reject), and the matching \d-valid cases
reS17/reS38/reZ004 no longer need a suspect-test exclusion and pass as ordinary
agreements, with both false-positive buckets at 0. Full suite (1830 tests) green;
an adversarial over-rejection critic re-derived the Nd delta bidirectionally and
confirmed no false-positive surface. A regression-pinning test exercises the
production regex path, including the boundary that U+1372 (No even at 3.2) is
not \d.
2026-06-29 06:27:27 +02:00
7be7fa25f3
fix(schema): match a NaN value against a NaN enumeration (value-space identity) (#376)
OrderedValue.== compared doubles with IEEE equality (NaN != NaN), so a NaN value never matched a NaN enumeration member and was rejected: <v>NaN</v> against xs:double restricted by <xs:enumeration value="NaN"/> is valid, and PureXML rejected it (a cardinal-rule false positive). NaN is now treated as identical to itself in value-space equality, the identity the reference (libxml2) and XSD 1.1 use throughout; an adversarial critic flagged the shared comparison (also used by key/unique and fixed), so the key/fixed behaviour was verified against xmllint: libxml2 collides two NaN unique keys and matches fixed="NaN", i.e. the same identity, so one identity == matches the reference everywhere. Ordering (<) keeps NaN incomparable, so range facets are unaffected. Found by a new generated-value facet differential (FacetValidationDifferentialTests, opt-in via FACET_DIFF) over ~10000 datatype/facet values: one FP found, zero after; the four W3C XSTS buckets held, full suite passes, SchemaDoubleEnumerationTests locks all three relations.
2026-06-29 05:20:07 +02:00
fd61d4466b
fix(schema): bound attribute-group reference depth so a deep chain cannot overflow the stack (#375)
A long <xs:attributeGroup ref> chain (ag0 refs ag1 refs ag2 ...) is flat in the document, so flattening a complex type's attributes through it recursed once per link: the stack overflowed past ~6000 links and it was near-cubic before that (~17s at 3000). attributeUses already threads a visited cycle-guard set whose count is the chain depth, so bound it against maximumContentModelExpansionDepth (512): a chain past the bound is rejected with a located error rather than crashed. Behaviour-preserving within the bound, which no realistic schema or the XSTS corpus approaches; verified conformance-neutral over the full W3C XSTS corpus and the full suite. Residual: many distinct over-bound chains still reject in O(n^2) per group (attribute flattening re-done per group); a memo is the linear follow-up.
2026-06-29 03:58:18 +02:00
8acf8ffff6
perf(schema): memoize simpleContent base resolution so a chain is linear, not cubic (#374)
A long <xs:complexType> simpleContent extension chain (T0 extends T1 extends T2 ...) is flat in the document, so each of the N complex types re-walked its whole base chain to resolve the simple type its simpleContent derives from (an O(n^3) compile; a 400-link chain took ~23s). The resolved simple type is now memoized by complex-type name (SimpleContentMemo, a reference type surviving the value-type context copies, mirroring GroupParticleMemo); the same chain compiles in ~1.2s, and past the 512 expansion-depth bound it is rejected with a located error as before. Behaviour-preserving (an acyclic resolution is intrinsic to the name; a derivation cycle is rejected elsewhere), verified conformance-neutral over the full W3C XSTS corpus, the determinism differential, and the full suite. The attribute flattening along the chain is inherently quadratic in the bounded length and is tracked.
2026-06-29 03:26:07 +02:00
4024801171
perf(schema): resolve a simple-type restriction chain in linear, not cubic, time (#373)
A long <xs:simpleType> restriction chain is flat in the document, so the same base was resolved repeatedly: the named simple types were resolved by an N-pass fixpoint, and the facet-applicability variety check re-scanned the schema's children to find each base at every link of every type's chain (compiling a 600-link chain took ~4.4s). Named simple types are now resolved once in dependency order (XSDSimpleTypeOrder), and the variety check uses an O(1) name map and memoizes each type's variety (SchemaSimpleTypeVariety); the same chain now compiles in ~30ms. Behaviour-preserving, verified conformance-neutral over the full W3C XSTS corpus, the determinism differential, and the full suite.
2026-06-29 02:55:02 +02:00
3e4ca0cce3
perf(schema): bound content-model expansion depth so deep chains cannot overflow the stack (#372)
Two constructs are flat in the schema document, so they escape the parser's element-nesting maxDepth (256) yet make a content model arbitrarily deep once compiled: a <xs:group ref> chain and a complexContent derivation chain (the definitions are siblings). The particle compiler inlines each group reference and composes each derivation once per link, overflowing the stack on a long chain. The expanded depth is now bounded by one counter (XSDContext.expansionDepth, incremented at each compositor level, group-reference expansion, and derivation step) against maximumContentModelExpansionDepth (512); a model past the bound is rejected with a located error, not crashed. The counter lives in a value-type context so it tracks depth not width (no width false positive), and the rejection is harvested into the schema's errors (no silent truncation). Verified conformance-neutral over the full W3C XSTS corpus (all four buckets unchanged), the libxml2 determinism differential unchanged, and the full suite; an adversarial critic found the derivation-chain gap, which this fixes with the same counter.
2026-06-29 01:54:21 +02:00
1f8d99adfc
perf(schema): make the UPA determinism check depth-safe on group-reference chains (#371)
A flat chain of <xs:group> references is not bounded by the parser's maxDepth (the definitions are siblings, not nested), so the compositional UPA determinism check's lazy per-reference recursion overflowed the native 8 MB stack while compiling a legal schema. Group summaries form an acyclic graph, so they are now precomputed bottom-up in dependency order (GroupDependencyOrder): the walk's group lookup is a cache hit and recursion is bounded by a single group's own model nesting. The precomputed reachable set is identical to the lazy walk's, so the verdict is unchanged. Verified verdict-equivalent over the full W3C XSTS corpus, the libxml2 determinism differential (0 FP, same 3 FN), the full suite, and an adversarial critic. Residual, tracked: the particle compiler that inlines a group reference still overflows on a far longer chain; closing it needs a content-model nesting-depth limit with a located error.
2026-06-28 23:15:32 +02:00
ae37f493f9
test(schema): generated-instance validation differential vs libxml2 (#370)
The schema-compile determinism check has a generated-schema differential; this adds the
counterpart for the instance-validation path, exercising the per-instance content matcher
(the graph-structured-stack ContentRTNMatcher now in production) over the open input space.
For every generated, both-engines-compile schema it validates many generated <doc>
instances with PureXML and with libxml2 and compares the verdicts, characterising the
cardinal rule's instance half beyond the finite XSTS corpus.

It asserts the cardinal rule (PureXML never rejects an instance libxml2 accepts) and
reports false negatives for triage. Over 6568 generated validations (1357 both-valid, 5211
both-invalid) across sequences, choices, all groups, group references, occurrences, and
wildcards it found zero false positives and zero false negatives: independent confirmation
that the matcher agrees with libxml2. Opt-in via INSTANCE_DIFF and xmllint, a no-op in
plain swift test and CI; the libxml2 subprocess is guarded for WASI. No behaviour change.
2026-06-28 21:14:12 +02:00
5bd4b77305
perf(schema): assert the GSS matcher's runtime bound by count, not wall-clock (#369)
The interactive-safety bar wants a proven worst-case bound, not a cap or a timeout. The
graph-structured-stack matcher shares stack suffixes, so on a model with S program states
and an input of length N the live-configuration set and the GSS node count stay O(S*N)
where an explicit return stack reaches O(distinct stacks): a nullable doubling chain that
gives an explicit stack ~3^depth configurations gives the GSS O(depth).

ContentRTNMatcher now exposes peakLiveConfigurations and gssNodeCount, and the differential
asserts they stay linear in the chain depth (measured gss = K + 2, peak configs = 3K + 3),
by count rather than a machine-dependent wall-clock timeout, so a regression to super-linear
fails the test. Native-only (the depth-80 RTN build recurses on nesting depth and overflows
WASI's small stack; that builder depth-safety is tracked separately). No behaviour change.
2026-06-28 20:36:54 +02:00
9cce695090
fix(schema): reject a repeating sequence's trailing/leading particle overlap as a UPA violation (#368)
A repeating model whose optional trailing particle and leading particle share a name,
(c, b?, c?)*, is ambiguous, as libxml2/Xerces reject it: after b the next c could be the
trailing c? or the looped leading c, two distinct particles. The compositional determinism
check formed the internal sequence gap before the enclosing repetition's loop edge was
added, so the combined {trailing, leading} followpos was never checked, an invalid-schemas-
accepted false negative the finite XSTS corpus did not contain.

The sequence now carries each trailing end-item's internal suffix in its lastFollow, so a
parent gap or the loop edge (withRepeatEdge) forms the end-item's full followpos. The
carried suffix is always a subset of the true followpos, so the check only catches more
genuine conflicts and never over-rejects: the non-repeated (c, b?, c?) and disjoint shapes
like (a, b?)* stay valid.

Found by the new generated-schema determinism differential against libxml2. Verified over
the full W3C XSTS corpus in release (valid-schemas-rejected/valid-instances-rejected stay
0, invalid-*-accepted unchanged at 8/14), full suite on macOS/Linux/WASI, and an
adversarial over-rejection critic finding no valid schema newly rejected over 47 shapes.
The related ambiguity through two references to one group in a repeating sequence stays an
under-rejection (the cached group identity cannot distinguish a looped from a trailing
occurrence without per-occurrence tracking), named in DeterminismDifferentialTests.
2026-06-28 19:53:10 +02:00
8fc71bfa53
test(schema): generated-schema determinism differential vs libxml2 (#367)
The XSTS corpus is finite and did not exercise the multiply-referenced-group overlap that
was a real UPA gap (it was found by reasoning, not the corpus). This adds the systematic
characterisation the production-readiness bar calls for: a generated-schema differential
that fuzzes content models (elements, group references, choices, sequences, all groups,
occurrences, wildcards) and compares PureXML's Unique Particle Attribution accept/reject
verdict to libxml2's. Opt-in via DETERMINISM_DIFF and xmllint, isolated to the determinism
dimension (non-determinism rejections are skipped).

It asserts the cardinal rule, zero schemas PureXML rejects as ambiguous that libxml2
accepts, and reports false negatives for triage rather than leaving them silent. Over this
generator it found zero false positives and one genuine, named under-rejection: a repeating
model whose optional trailing particle and leading particle share a name, (c, b?, c?)*,
where the compositional check forms the internal sequence gap before the enclosing
repetition's loop edge is added, so the combined decision set is never checked. The other
disagreements are contested (unused-group checking; (a?)+ epsilon loop) with PureXML the
more-correct lenient party. The genuine gap is tracked as a follow-up, its own FP-critical
fix.
2026-06-28 19:01:42 +02:00
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
9e40278218
fix(wasm): fail check-wasm with an actionable message on a toolchain without the wasm backend (#365)
When swiftly is absent, check-wasm.sh fell back to the active `swift`. The Xcode-bundled
Apple toolchain has no WebAssembly LLVM codegen backend, so a wasm cross-compile through
it does not fail cleanly: it aborts deep inside LLVM (clang module build, "No available
targets are compatible with triple wasm32-...") with a multi-megabyte stack dump that
reads like a code bug. The SDK-presence check passed (the SDK is installed) and the build
crashed anyway, so the real cause, the wrong host toolchain, was invisible.

Detect it up front and exit 2 with an actionable message. Discriminate by the compiler's
self-reported build tag, not its path: both toolchains print "Apple Swift version X", but
the Xcode/Apple build tags it "(swiftlang-...)" while a swift.org toolchain tags it
"(swift-...-RELEASE/SNAPSHOT)". The self-report is authoritative however `swift` is
symlinked or shimmed, which a path-string match would miss. `swift -print-target-info
-target wasm32-...` is not a usable probe: it succeeds even without the backend, because
the frontend knows the triple and only codegen is missing.
2026-06-28 05:09:20 +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
116b260149
docs(design): add the code-level implementation blueprint for the GSS matcher (#362)
The previous section settled the algorithm and the bound proof; this makes the
build turnkey rather than exploratory. It is the full scope of the reverted RTN
attempt (whose group sharing was correct) with the explicit return stack replaced
by the graph-structured stack:

- Step 1: a group-definition identity (`Group.definitionID`, stamped from the
  existing `GroupParticleMemoKey` in `XSDParserParticles`), FP-neutral on its own.
- Step 2: a `ContentRTNBuilder` alongside the trusted inlining `ContentNFABuilder`,
  compiling each definition once via `call`/`return` epsilon edges, counters still
  per-configuration and reset on entry so a shared fragment does not alias.
- Step 3: the GSS simulation: a node `(returnState, parents-set)` interned per
  `(returnState, inputPosition)`, configuration `(state, counters, gssNode)`, with
  call interning and pop fan-out, deduplicated on the configuration key.
- Step 4: the differential gate against the inlining oracle (XSTS + a
  nullable-nested-group fuzz, verdict AND matched-particle equal), then the switch
  and a perf assertion on GSS/configuration COUNTS (not a wall-clock timeout, the
  reverted attempt's lesson).

Each step is its own PR; steps 1-2 keep the oracle authoritative, step 4 is the
only behaviour change and is gated to equivalence before it lands. Design only.
2026-06-28 01:40:47 +02:00
1057fc19d0
docs(design): derive the graph-structured-stack bound for the content matcher (#361)
Closes the proof gap that sank the reverted RTN matcher. The counted automaton
removed the occurrence-magnitude blow-up and the compositional determinism check
removed the determinism group-sharing blow-up, but the per-instance matcher build
(ContentNFABuilder.group) still expands a multiply-referenced group once per
reference, so a doubling chain g0=(g1,g1)... builds 2^K states and hangs on the
first validate. That is the last place stopper 4 is a hang, not a proven bound.

The reverted pushdown rewrite compiled the group DAG to call/return edges
(O(groups) program) but kept EXPLICIT return stacks, so a nullable doubling chain
made the start epsilon-closure reach ~3^depth distinct stacks: the exponential
moved from the build to the per-keystroke closure. It was verdict-correct yet
bounded nothing, and was reverted.

This section derives the fix: a graph-structured stack (the GLL/Earley device).
A GSS node (returnState, parents-set) shares stack suffixes, so configurations are
keyed on (state, counters, gssNode) and the live set is bounded by O(states), not
by the number of distinct stacks. The nullable doubling chain that produced
3^depth explicit stacks produces O(K) GSS nodes. The section states the size and
termination bounds (counters clamped to N+1, non-recursive group DAG), the
deterministic-model O(N*S*D) match, and the same alongside-then-differential-gated
rollout that made the compositional-determinism change FP-safe, with a perf
ASSERTION on configuration/GSS counts rather than a wall-clock limit (the reverted
attempt's lesson). Design only; no code change. Status: DESIGNED, not implemented.
2026-06-28 01:15:51 +02:00
b76cb5824d
test(schema): make the libxml2 differential queue unambiguous and characterised (#360)
The schema-validity differential harness (stopper 3) reported each disagreeing
schema by bare basename, but the same name recurs across subdirectories, so an
entry hid which file actually disagreed: Facets/Schemas/gMonth_maxInclusive001
(value="", correctly rejected) and Facets/gMonth/gMonth_maxInclusive001 (the
contested 2001-REC --MM-- form) collapse to one ambiguous line. The report now
uses the path relative to the corpus root.

The header records the triage of the "libxml2 stricter" queue (~66 over msData):
it is dominated by libxml2 over-strictness (a maxOccurs like 999999999999999 is
a valid unbounded-precision nonNegativeInteger libxml2 rejects on its machine-int
limit; an unresolvable include/import) and contested datatype-version readings
(gMonth --MM--, which PureXML accepts to keep valid XSTS instances; anyURI
strictness; a regex {,5} tolerated per iter 7), with a small FP-delicate residue
(base64Binary canonical padding, keyref no-namespace refer). So the queue is
characterised and named, not a backlog of bugs. Characterisation only; the
shipped validator is unchanged, and the false-positive gate stays the XSTS
suite's valid-schemas-rejected == 0.
2026-06-28 00:35:42 +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
fef8520d68
chore(release): stamp 0.4.8 (#316)
Move the [Unreleased] section to [0.4.8] - 2026-06-27. No source changes.

0.4.8 collects the interactive-safety depth-recursion program: every walk over
the value-Node/TreeNode model, the parser and XPath result ordering, the
canonical, XInclude, HTML, and XSLT serialization paths, and the XSLT
transformation engine itself are now iterative, so parsing, validating,
querying, transforming, and serializing adversarial or deeply-nested input no
longer overflows the stack. Also includes the xpointer string-range() and XSLT
id() quadratic fixes.
2026-06-27 21:22:15 +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
e096a65193
ci: run Windows only on release tags, not every PR (#329)
Windows runs the full build and test suite and is the slowest CI job
(about 22 minutes), so on every push and PR it adds the most wait for the
least marginal signal on pure-Swift changes. It now triggers only on a v*
tag push (cutting a release) or via workflow_dispatch, keeping per-PR CI
fast. The badge reflects the last release's Windows run. Deviates from the
uniform-trigger convention deliberately, for CI speed.
2026-06-26 11:50:06 +02:00
db880058c3
docs(xsts): record the libxml2 differential triage of the open tail (#328)
Triaging the 24 open XSTS failures against libxml2 (the stated reference)
separates genuine reference-aligned bugs from stricter-than-libxml2 scope
decisions. On the schema side, 9 of 10 are accepted by libxml2 too (Xerces-
level strictness, false-positive-prone); only particlesZ030_d was a real
gap, now fixed. The instance tail's reference-aligned cases are intricate
and need per-case analysis. Guides the burndown toward bugs, not non-bugs.
2026-06-26 11:43:32 +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
22d86fc22d
Revert "fix(test): bound the xmllint subprocess in the XSTS differential (#325)" (#327)
This reverts commit e99319b02c.
2026-06-26 11:23:08 +02:00
e99319b02c
fix(test): bound the xmllint subprocess in the XSTS differential (#325)
The schema differential ran xmllint and then read its stderr and waited
for exit with no timeout. xmllint infinite-loops on a few pathological
schemas (particlesZ012), so a hang blocked the harness forever and left
the child as a zombie. A bounded wait now terminates and reaps the child
at a deadline and skips the case (it cannot be compared against a hung
reference). A non-gated test proves the mechanism by terminating a hung
sleep at its deadline instead of waiting it out. Addresses the leak noted
in #194.
2026-06-26 10:14:23 +02:00
775d9ec1b3
test(api): cover the streaming, SAX, and HTML public entry points (#324)
API.swift's parse(_:sax:), parse(pullingBytes:), the three events(...)
overloads, and validateHTML were reached only through subsystem types,
not the public wrappers. Adds direct tests: SAX callbacks in document
order, an incremental byte source yielding the same tree as the string
parse, all three events overloads agreeing on event count, and
validateHTML flagging a duplicate id.
2026-06-26 10:08:57 +02:00
7122c2ef16
ci: run the XSTS conformance ratchet on every change (#323)
The schema/instance validity baselines (Tests/XSTSSuiteTests.swift) were
only checked opt-in. Now that the unmodified corpus archive is vendored,
a dedicated workflow extracts it (no network) and runs the ratchet in
release on every push and PR, so a conformance regression fails CI. Adds
the matching README badge.
2026-06-26 10:02:41 +02:00
925172cd84
chore(test): vendor the XSTS archive (unmodified) under Tests/Fixtures (#322)
Saves the W3C XML Schema Test Suite (2006-11-06) in the repo as the
complete, unmodified xsts-2007-06-20.tar.gz (4.4 MB), the only form the
W3C Document License permits redistributing. The extracted 39,399 files
are still never committed (that would be a modified redistribution).

A README records the provenance, SHA-256, and license. scripts/fetch-xsts.sh
now extracts this vendored archive when present (no network) and only
downloads as a fallback. The archive is excluded from the test bundle in
Package.swift since the runner reads it off disk via XSTS_ROOT, not from
resources.
2026-06-26 09:20:37 +02:00
6b2cf2a429
chore(test): add scripts/fetch-xsts.sh to obtain the XSTS corpus (#321)
The W3C XML Schema Test Suite (2006-11-06) drives the opt-in conformance
runner but is never vendored: the W3C Document License permits
redistribution only as the complete unmodified archive, and the corpus
is about 200 MB. This script downloads the official archive on demand,
verifies its SHA-256, and extracts it to the path XSTS_ROOT expects.
Idempotent and portable (shasum or sha256sum). Points the onboarding doc
at it.
2026-06-26 08:59:07 +02:00
dcb9a40b18
test(parsing): cover ByteEncoder output-encoding paths (#320)
ByteEncoder (the public output encoder) was reached only through the
serializer's UTF-8 default, leaving its transformation-format, single-
byte, BOM, and naming paths untested. Adds direct assertions of the
spec-defined bytes: UTF-8/16/32 (incl. an astral surrogate pair),
ISO-8859-1 with a decimal character-reference fallback for an
unrepresentable scalar, byte-order marks, supports, and canonical names.
2026-06-26 08:27:00 +02:00
3ffbff91ed
test(xslt): cover result-tree namespace fixup generation paths (#319)
Adds XSLTNamespaceFixupTests for the previously-untested live paths in
XSLTNamespaceFixup.apply: generated ns0/ns1 prefixes for namespaced
attributes with no carried prefix, and xmlns="" undeclaration of an
inherited default namespace on an unqualified element (XSLT 1.0 7.1).
Exact serialized-output assertions, matching the existing XSLT test idiom.
2026-06-26 08:22:34 +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
2037d5baf7 chore(release): stamp 0.4.8 2026-06-26 04:26:30 +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
fab8008b42
chore(release): stamp 0.4.7 (#313) 2026-06-26 03:09:54 +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
e080876eac
release: PureXML 0.4.6 (#311)
Stamp the XPath string-function and xsl:number quadratic fixes as 0.4.6:
translate() and the substring search (contains/substring-before/after) are now
linear (the substring search via Knuth-Morris-Pratt, closing a DoS vector), and
xsl:number is linear for level single/multiple and quadratic (not cubic) for
level any. An 8000-item default xsl:number drops ~3.5s to ~0.04s.
2026-06-26 01:41:16 +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
f6802c1baf
release: PureXML 0.4.5 (#307)
Stamp the attribute-index and sibling-axis quadratic fixes as 0.4.5: sorting an
element's attributes into document order was quadratic in the attribute count,
and the following-sibling/preceding-sibling axes scanned a wide parent's child
list per context node. Both now use the shared sibling-index cache (and the
sibling axes fuse the node test). Also correct the changelog: these two entries
were mistakenly grouped under the released 0.4.4 heading.
2026-06-25 23:08:02 +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
2abb130757
release: PureXML 0.4.4 (#304)
Stamp the following/preceding axis quadratic fix as 0.4.4: those axes shared no
state across context nodes, rebuilding and linearly searching the whole document
node list on each, and materialized the entire span before the node test. They
now share a per-evaluation document-order cache, skip the context subtree by
index arithmetic, and fuse the node test into the walk. A 200-context following::
query over a 20k-element document drops ~16s to ~6s (~2.6x); results identical.
2026-06-25 21:22:51 +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
8abaf570bc
release: PureXML 0.4.3 (#301)
Stamp the XSLT/XPath quadratic fixes as 0.4.3: single-node string extraction
(string(@x), value-of) and the key(), id(), and EXSLT set:leading/set:trailing
functions no longer rescan a wide parent's children per node, so they are linear
rather than quadratic over a flat fan-out. A representative transform over a
20k-element document drops ~2.3s to ~0.65s (~3.5x); key()-driven ~1.15s to
~0.43s. Output identical (Xalan gold-output byte-for-byte).
2026-06-25 19:15:10 +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
1ae71c325d
release: PureXML 0.4.2 (#298)
Stamp the post-0.4.1 parse and serialize performance work as 0.4.2: the entity
decoder's bulk literal copy and byte-level marker scan, the scanned name-prefix
colon offset, the direct TreeNode adoption and direct-from-events tree build,
and the bulk byte-level output escaping. parse goes ~9x to ~5.8x and serialize
~3x to ~2.3x slower than libxml2 across these passes, with identical results
(every change differential-tested and adversarially reviewed).
2026-06-25 16:14:57 +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
c62e02f49d
release: PureXML 0.4.1 (#290)
Stamp the post-0.4.0 XPath performance work as 0.4.1: the descendant
single-accumulator traversal, the descendant and attribute node-test fusions,
the shared evaluation Environment, and the compiled per-query node test. xpath
goes from ~5.5x to ~1.3x slower than libxml2 across these passes, with identical
results (each correctness-sensitive change differential-tested and adversarially
reviewed).
2026-06-25 10:45:19 +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
f561ddbcef
chore(release): stamp 0.4.0 (#284)
Performance across all three benchmarked operations plus one XSD conformance
fix, all backward compatible. Versus libxml2 on the 20k-item generated corpus:
parse ~30x -> ~9x, serialize ~5.5x -> ~2.7x, xpath ~13x -> ~5.5x. The fix
rejects an xsi:type that reaches a union declared type through a blocked
member (XSTS invalid-instances-accepted 15 -> 14, valid-rejected held at 0).

Verified against the full local suite (1695 tests), the Apache Xalan XSLT
gold-output corpus, the W3C XML conformance corpora, the RELAX NG spec suite,
the full W3C XSTS archive, and the WASM build.
2026-06-25 05:47:51 +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
2eb3741848
perf(xpath): skip node-set de-duplication where no duplicate can arise (#280)
Each location step accumulated its context nodes' axis results into a
Set<Node> to drop nodes reachable from more than one context, hashing and
copying every node. The wrapped TreeNode is reference-counted, so each copy
is ARC traffic, and the profile showed the Set insert, hashing, and Node
copies dominating evaluation.

De-duplication is needed only when two distinct context nodes can reach the
same node. Skip it on a single context and on the axes whose results are
disjoint per context (child, attribute, namespace, self): those produce the
identical node sequence the Set would, since every insertion succeeds, with
no hashing. Keep the Set on the axes that genuinely overlap (descendant,
ancestor, parent, the siblings, following/preceding). Predicates stay applied
per context and the path result still passes through the final
order-and-unique pass, so results are identical.

Measured xpath ~13x to ~10x slower than libxml2 on the 20k-item generated
corpus via a same-state A/B, ~28% faster, same result. XPathNodeSetDedupTests
pins that a node shared by many contexts through ancestor/parent/descendant
is still counted once, the disjoint axes return every distinct result, and an
overlapping union still de-duplicates. The full XPath suite and the Apache
Xalan XSLT gold-output corpus stay green.
2026-06-25 01:33:50 +02:00
0abbe348d7
perf(emitting): skip escaping rebuild for values that need no escaping (#279)
The serializer's escaping functions rebuilt every text, attribute, comment,
and processing-instruction value character by character, even when the value
contained nothing to escape, which is the common case. Each now scans the
value's UTF-8 bytes for the bytes it would escape (the markup characters, the
active attribute quote, the whitespace characters, a comment hyphen, a PI
question mark, and any non-ASCII byte in ASCII-only mode) and returns the
value unchanged when none is present, taking the original character path only
when something must be escaped.

Measured serialize roughly halved, ~5.5x to ~2.7x slower than libxml2 on the
20k-item generated corpus, with byte-identical output. EscapingFastPathTests
pins both the unchanged fast path and the escaped slow path for each function,
and the Apache Xalan XSLT gold-output corpus (a serialize-heavy differential)
stays green. No output changed.
2026-06-25 00:56:52 +02:00
38caec7098
perf(parsing): skip reference decoding for ampersand-free attribute values (#278)
The attribute-value scanner ran the entity decoder on every value, and the
decoder re-scanned the whole value for '&' before doing nothing when none
was present. The byte-run scanner already visits every value byte, so it
now notes any '&' and an ampersand-free value (the common case) returns its
normalized run directly. Measured against a same-machine-state baseline,
every run came in below every baseline run, ~4% faster parse on the
20k-item generated corpus.

Move the bulk byte-run scanners (contentRunBytes, attributeRunBytes) into
ReaderByteRuns.swift and centralize their cursor mutation through a single
advanceByteRun method, so Reader's position fields stay write-encapsulated
in Reader.swift while both files stay under the length cap. The byte and
Character paths are held to identical decoded values by the differential
oracle, extended with ampersand-free, entity, and character-reference
attribute cases. No spec behavior changed.
2026-06-25 00:17:31 +02:00
94e16b1d7d
fix(schema): block an xsi:type that reaches a union declared type through a member (#277)
cvc-elt.4.3 defers to cos-st-derived-OK, whose clause 2.2.4 makes a type
validly derived from a union when it is validly derived from one of the
union's member types. The block check followed only the named base chain,
so when the declared type was a union and the substitute reached it through
a member by a blocked derivation method, the check found no path and stayed
silent, wrongly accepting the substitution (W3C XSTS elemT074: block on the
element is restriction and the xsi:type restricts a union member).

Record a union's direct member types during schema compilation and follow a
derivation through a member when the base chain does not reach the union.
The member clause is existential: bar the substitution only when every
confirmed derivation path to a member is blocked, so a substitute that is
itself a member (its self-membership path uses no method) or reaches one
member by an unblocked method is accepted whatever other members it reaches
by a blocked method. The check adds a rejection only on a positively
confirmed path, so an unresolved, inline-anonymous, or nested-union member
leaves it silent: a bounded, disclosed under-rejection, never a false
positive.

Drops XSTS invalid-instances-accepted 15 to 14 with valid-schemas-rejected
and valid-instances-rejected held at 0 over the full archive. An adversarial
over-rejection review caught a first cut that order-shadowed the
self-membership path; the existential form rules it out and the case is a
regression test.
2026-06-24 21:12:35 +02:00
ead838f83c
perf(parsing): dispatch element-content markup on the byte after '<' (#276)
The element-content markup dispatch ran up to five literal string
comparisons (</, <!--, <![CDATA[, <?, <!DOCTYPE) before the start-tag
branch won, a per-element cost the parse profile indicted as
Reader.matchesBytes. Branch instead on the single byte after '<' on the
byte fast path: '/' end tag, '?' processing instruction, '!' a
declaration, otherwise a start tag.

The Character path keeps the equivalent matches()-based dispatch for a
streaming source or spliced entity text, and a '<!' that matches none of
comment/CDATA/DOCTYPE still falls through to the start-tag scanner with
the same expectedName error. Measured parse ~9.5x -> ~9.0x slower than
libxml2 on the 20k-item corpus; the byte and Character paths are held
identical by ParserPathDifferentialTests over its adversarial corpus,
and the Xalan, W3C, and RELAX NG suites stay green. No spec behavior
changed.
2026-06-24 19:25:10 +02:00
2f8ca9b289
perf(parsing): byte-level scanning fast paths halve parse time vs libxml2 (#275)
Keep the reader on its byte fast path through dispatch and name/attribute
scanning so plain-ASCII documents avoid the per-Character String machinery
(String.Iterator.next, string comparison, small-string materialization,
bridge-object ARC) that a profile indicted as the parse bottleneck.

The root cause was that a single buffered lookahead Character defeated every
existing byte path, each of which requires an empty buffer, and the content,
markup, and attribute dispatch each peeked a Character before scanning. Add a
non-buffering peekByte for dispatch, a byte-mode skipSpace, an all-ASCII
takeASCIIName, and an attributeRunBytes bulk scanner (with the 3.3.3
whitespace-to-space transform, deferring any carriage return to the Character
path so CRLF is not double-counted). Detect a text run's closing '<' via
peekByte instead of buffering it, which had cascaded the next element's whole
dispatch and name scan onto the slow path. Skip reference decoding for
ampersand-free text. The Character path remains the exact fallback for a
streaming source, spliced entity text, and any non-ASCII byte; the two paths
share scanContentMarkup so they cannot diverge.

Measured parse 20.1x -> 9.5x slower than libxml2 on the 20k-item generated
corpus, roughly a 53 percent wall-clock reduction. A differential oracle pins
the byte and Character paths to identical event streams and error marks over
an adversarial corpus, alongside boundary tests. Verified against the full
local suite, the Apache Xalan XSLT gold-output corpus, the W3C XML conformance
corpora, the RELAX NG spec suite, and the WASM build. No spec behavior changed.
2026-06-24 18:46:58 +02:00
3aca6c393a
chore(release): stamp 0.3.0 (#274)
Roll the accumulated [Unreleased] entries into the 0.3.0 section dated
2026-06-24, and open a fresh [Unreleased].
2026-06-24 17:20:20 +02:00
1e14d18f4d
fix(xslt): xsl:namespace-alias keeps the literal prefix, remapping only the URI (closes namespace19/23/24/35/113) (#273)
* fix(xslt): keep the literal prefix for xsl:namespace-alias, remap only the URI

XSLT 1.0 7.1.1: an aliased result element keeps the prefix used in the
stylesheet; only its namespace URI is replaced by the result namespace. The
serializer adopted the result-prefix as the output prefix, so axsl:foo aliased
to the XSLT namespace came out as xsl:foo. The result-prefix now only selects
the replacement namespace; the element name, attributes, and copied namespace
declarations all keep their literal prefix.

Closes Apache Xalan conformance cases namespace19, namespace23, namespace24, namespace35, namespace113.

* refactor(xslt): drop the now-unused NamespaceAlias prefix field

The namespace-alias output keeps the literal prefix, so the result-prefix is no
longer stored; only the result namespace URI is needed. Removes the dead field
and corrects the addNamespaceAlias doc comment.
2026-06-24 15:43:02 +02:00
fa3ab76c6f
fix(xslt): distribute // over union branches in an xsl:key match pattern (#272)
The xsl:key match was turned into a path by prepending //, but for a union
match like "Level1 | Level2" the // applied only to the first branch
(//Level1 | Level2), so the second branch matched relative to the root and
found nothing, dropping those nodes from the key. Distribute // over each
top-level union branch (// inside a predicate or function call is left alone).

Closes Apache Xalan conformance cases idkey45, idkey46, idkey47, idkey48.
2026-06-24 15:27:36 +02:00
12af2fbc24
fix(xslt): honor xml:space=preserve when stripping stylesheet whitespace (#271)
XSLT 1.0 3.4: a whitespace-only text node in the stylesheet is preserved when
the nearest ancestor-or-self with an xml:space attribute has value preserve.
The body parser dropped every whitespace-only run unconditionally, so a literal
result element with xml:space="preserve" lost its whitespace content. Consult
the in-scope xml:space before stripping.

Closes Apache Xalan conformance case whitespace20.
2026-06-24 10:57:47 +02:00
42a56abcbf
fix(xslt): count the from boundary node in xsl:number level=single when it matches count (#270)
XSLT 1.0 7.7: for level=single the count node may be the from-boundary node
itself when that node matches both the count and from patterns. The walk
stopped at the nearest from-matching ancestor before testing it against count,
so count="a|b" from="a" gave an empty number for a node inside an a. Test the
count pattern before the boundary stops the walk.

Closes Apache Xalan conformance case numbering63.
2026-06-24 10:47:49 +02:00
e7a8f473ef
fix(xslt): evaluate the xsl:number format attribute as an AVT (#269)
XSLT 1.0 7.7: the format attribute of xsl:number is an attribute value
template. It was stored and used as a literal string, so format="{$f}" was
never evaluated and a passed-in or computed format was ignored. Parse it as a
ValueTemplate and evaluate it per instantiation.

Closes Apache Xalan conformance case namedtemplate12.
2026-06-24 10:40:11 +02:00
95fde5b177
fix(xpath): format a large integer-valued number with no decimal point (#268)
XPath 1.0 4.2: an integer-valued number converts to a string with no decimal
point. The formatter only took the integer branch for a magnitude below 1e15,
so a value like 1234567890123456 (exact in a double, which holds integers
exactly to 2^53) printed as 1234567890123456.0. Raise the bound to 2^63, the
range where the Int64 conversion is safe; larger magnitudes still expand
through the exponent path.

Closes Apache Xalan conformance case string132.
2026-06-24 04:31:19 +02:00
9070ccc3f1
fix(xpath): parse a lone / as the root node before a terminator (#267)
The XPath grammar makes the relative part after / optional, but the compiler
only treated / as the root location path when it was at the end of the
expression (isAtEnd). When a / was followed by ), ], |, or an operator (as in
count(/), generate-id(/), or / = .), it fell through and tried to parse a step
from the terminator, yielding an empty node-set. Recognize / as the root
whenever no location step follows.

Closes Apache Xalan conformance cases position106 and mdocs15.
2026-06-24 04:21:46 +02:00
70fdcab840
fix(xslt): give an axis-prefixed match pattern its node-test default priority (#266)
XSLT 1.0 5.5: the default priority of a single-step match pattern is set by its
node test, regardless of an explicit child:: or attribute:: axis. defaultPriority
matched the whole pattern string, so attribute::node() fell through to 0 (a name
test) instead of -0.5 (a node() node test), wrongly outranking @*. Strip a
leading child::/attribute:: axis before classifying the node test.

Closes Apache Xalan conformance cases conflictres29 and conflictres30.
2026-06-24 04:08:00 +02:00
2fe8632bb2
fix(xslt): match strip-space/preserve-space element tests by namespace (#265)
XSLT 1.0 3.4: an xsl:strip-space/xsl:preserve-space elements name test is a
namespace-qualified NameTest, matched by namespace, not by prefix. A specifier
prefix:* or prefix:local now resolves its prefix to a URI at parse time, and a
source element is matched on its expanded name by specificity (exact {uri}local
over namespace wildcard {uri}* over *). The tests compared raw prefixed strings,
so ns1:* did not strip a source element bound to the same namespace under a
different prefix.

Closes Apache Xalan conformance cases whitespace06 and whitespace07.
2026-06-24 03:52:45 +02:00
3ab8637c14
fix(xslt): ignore disable-output-escaping for attribute, comment, and PI values (#264)
XSLT 1.0 16.4: disabling output escaping for a value used as something other
than a text node (an attribute, comment, or processing-instruction string
value) is an error; the recovery is to ignore it. The attribute value built
from such content kept its raw-output markers, so the serializer emitted < and
> unescaped in the attribute. Strip the markers for these three node kinds, via
a shared helper, gated on the stylesheet actually using the feature so a
private-use character in ordinary data is never removed.

Closes Apache Xalan conformance case output75.
2026-06-24 03:41:56 +02:00
74bf622275
fix(html): percent-escape non-ASCII characters in URI-valued attributes (closes output32) (#263)
* fix(html): percent-escape non-ASCII characters in URI-valued attributes

XSLT 1.0 16.2 / HTML 4.01 B.2.1: the html output method percent-escapes
non-ASCII and control characters in attributes of type %URI (href, src, cite,
action, and the rest), writing each as the uppercase hex of its UTF-8 bytes,
while keeping " and & as entities. Other attributes are unaffected. The
serializer left such characters unescaped. Grounded in the libxml2
htmlSerializeUri study; reimplemented in pure Swift, no Foundation.

Closes Apache Xalan conformance case output32.

* docs(html): disclose URI-attribute escaping round-trip normalization

Address the URI-escaping critics: the html serializer follows libxml2's HTML
output model where URI-valued attributes are always percent-escaped, so a
parse-then-serialize round-trip normalizes a space or non-ASCII byte to %HH.
Document that this is idempotent (a literal % is left as is) and that every
space is escaped (leading ones included, where libxml2 skips them). Add an
idempotency test.
2026-06-24 03:27:49 +02:00
f1bc46e99b
fix(emit): split ?> in processing-instruction data with a space (#262)
PI data may not contain ?>, which would terminate the instruction early. The
serializer emitted it raw, producing malformed output. Insert a space between
an offending ? and > (XSLT 1.0 7.3 recovery: a processor recovers from a
generated ?> by inserting a space after the ?), via a shared Escaping helper
applied at both XML PI serialization sites. A trailing ? is left alone.

Closes Apache Xalan conformance case output72.
2026-06-24 03:14:01 +02:00
c355daa00e
test(xslt): decode non-UTF-8 conformance sources via the library's encoding detection (#261)
The Xalan harness read source and stylesheet files as UTF-8, so a UTF-16
source (output80) or other non-UTF-8 input (attribvaltemplate08) was refused
as unreadable before the transform ran. Read them through PureXML's own
ByteDecoder (BOM or XML-declaration detection, UTF-8 fallback), as the harness
already does for gold files. The library round-trips them correctly, so both
cases now pass.

Closes Apache Xalan conformance cases output80 and attribvaltemplate08.
2026-06-24 03:04:45 +02:00
1ba6bf86e2
fix(xslt): serialize a namespaced element as XML under the html output method (16.2) (#260)
* fix(xslt): serialize a namespaced element as XML under the html output method

XSLT 1.0 16.2: the html output method outputs an element whose expanded-name
has a non-null namespace URI as XML, not HTML. A namespaced document element's
tree was serialized as HTML, leaving empty namespaced elements unclosed (e.g.
a void name like br in a namespace) and repeating xmlns on every element, which
is not well-formed XML. Such a tree now serializes through the XML path (empty
elements self-close, the namespace is declared once and inherited); the html
method still omits the XML declaration. Null-namespace trees are unchanged.

Grounded in XSLT 1.0 16.2 and the libxml2 HTML serializer study; reimplemented
in Swift. Apache Xalan output63 exercises this rule but stays baselined because
its gold differs further only in non-normative attribute order.

* docs(xslt): disclose the document-element granularity of the 16.2 namespace rule

Make the whole-tree approximation explicit in the serializedBody comment and
the changelog: the html-outputs-namespaced-elements-as-XML rule is applied at
document-element granularity, not per element, so a mixed-namespace tree
follows its document element's path.
2026-06-24 02:48:50 +02:00
445412b105
fix(xslt): name the html output method doctype HTML, not the element name (closes output40/48/60) (#259)
* fix(xslt): name the html output method doctype HTML, not the element name

XSLT 1.0 16.2: for the html output method the name following <!DOCTYPE should
be HTML or html, not the document element name (which is the xml method's rule,
16.1). The serializer used the document element name for both methods, so
method=html with a non-html root element (e.g. <root>) emitted
<!DOCTYPE root PUBLIC ...> instead of <!DOCTYPE HTML PUBLIC ...>.

Closes Apache Xalan conformance cases output40, output48, output60.

* fix(xslt): emit no html doctype when the result has no element

Addresses the doctype critic finding: a doctype precedes the first element
(16.2), so a text-only result must emit none even for the html method. The
fixed HTML name made the existing no-element guard unreachable; restore it by
guarding on the document element before choosing the name.
2026-06-24 02:29:26 +02:00
f3d9de2c86
fix(html): do not escape < in an attribute value in the html output method (#258)
XSLT 1.0 16.2: the html output method escapes & and " in attribute values
but, unlike the xml method, leaves < (and >) literal. The serializer was
escaping < to &lt; in attribute values. Removing that case lets it pass
through; the xml method is unchanged and still escapes both.

Closes Apache Xalan conformance cases output49 and output74.
2026-06-24 02:16:20 +02:00
37ea8fcbfa
fix(xslt): resolve string-form document() against the stylesheet element's base URI (closes mdocs12/13) (#257)
* fix(xslt): resolve string-form document() against the stylesheet element's base URI

A string-form document() reference resolves against the base URI of the
stylesheet element holding it (XSLT 1.0 12.1). For a declaration in an
included or imported file that is the included file's URI, not the top
stylesheet's, so document('data.xml') (or document('')) in an included file
must resolve against the included file.

The parser already tracked each top-level declaration's source-file base but
discarded it after collection. That base is now carried onto the compiled
templates and global variables, into the runtime context, and through to the
function table, so the document() string form resolves against it. Templates
and globals both carry it, so the rule holds wherever the call appears.

Closes Apache Xalan conformance cases mdocs12 and mdocs13.

* fix(xslt): carry base URI into for-each and sort contexts; keep document('') self-referential

Follow-up to the string-form document() base URI work. Three coverage gaps:

- xsl:for-each and xsl:sort evaluate their body and key expressions in a
  context derived from, not equal to, the enclosing template's, so a
  document() call there resolved against the empty base when the template came
  from an included file. Both now inherit the enclosing context's base URI.

- A non-empty base URI made document('') in the top stylesheet resolve to that
  base and route through the loader, rather than returning the stylesheet's own
  parsed document as before. The parsed stylesheet is now registered under its
  base URI, so document('') reaches it without the loader; document('') in an
  included file (a different base) still loads that file.
2026-06-24 02:03:32 +02:00
b6af499558
fix(xslt): resolve document() references against the correct base URI (closes 3 Xalan reluri cases) (#256)
* fix(xslt): resolve document() references against the correct base URI

The document() function resolved every relative URI reference against the
single global base, so a reference relative to a document that document()
itself had loaded from a subdirectory could not be found. XSLT 1.0 12.1
defines the base per reference: the two-argument form against the second
argument's first node, the node-set form against each reference node's own
document.

Each loaded document now records the URI it came from as its base, and a
relative reference resolves against that base before the loader applies its
own source-document base. The common one-argument string form is unchanged
(its base is empty, deferring to the loader as before).

Closes Apache Xalan conformance cases reluri09, reluri10, reluri11.

* fix(xslt): record base URI for document() fragment selections

A fragment selection (document('x#frag')) is returned as a detached subtree
whose root identity differs from the cached whole-document tree, so its base
URI was not recorded. Using such a node as the base for a relative document()
reference then resolved against the empty base instead of the fragment's
document. Record the base on each fragment subtree's own root through a shared
helper, so baseURI(of:) finds it whichever node the reference is taken from.
2026-06-24 01:30:54 +02:00
cac09eef01
fix(xslt): match template, key, attribute-set, decimal-format names by expanded QName (#255)
XSLT 1.0 names every QName-keyed declaration by expanded name, so two prefixes
bound to the same namespace name the same thing. mode and variable names were
already resolved this way; named templates, xsl:key, xsl:attribute-set, and
xsl:decimal-format names were still keyed and compared as raw prefix strings.

A declaration name now resolves to {uri}local at parse time, and the references
resolve their prefix against the in-scope namespaces the same way: call-template
and use-attribute-sets at parse time, and the key() name argument and
format-number's format name at evaluation (a shared Library.expandedName helper
covers the two runtime sites). The parse-time expansion reuses a factored
expandedQName helper shared with mode and variable.

Closes Apache Xalan conformance cases namedtemplate16, attribset48, idkey53.
Full suite 1632 tests green in debug and release; the Xalan suite passes.
2026-06-24 00:44:27 +02:00
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
3a6aad0eb2
fix(schema): reject a redefine whose self-reference duplicates a particle (UPA) (#253)
A redefined model group whose body references itself expands, in the compiled
content model, to the original group it redefines. Two gaps let an ambiguity
created that way slip through the Unique Particle Attribution check (cos-nonambig).

The compositional determinism engine treated the self-reference as contributing
an empty decision set, so the original group's particles were invisible to the
overlap test. It now expands one level to the redefine base, exactly as the
instance content-model path does, so a particle the original contributes is seen.
The expansion only surfaces genuinely reachable items, so the decision sets stay
a subset of the true model: the check can catch a real ambiguity but never invent
one, holding the false-positive direction at zero.

Separately, the UPA check walked only the root document, so an ambiguity a root
redefine creates in a complex type that lives in an included document was never
evaluated. It now runs over every schema root in the composition under the shared
redefine-effective context, mirroring the existing cross-document top-level
declaration check.

Closes XSTS schN10 (a three-level redefine chain). XSTS invalid-schemas-accepted
11 -> 10, valid-schemas-rejected held at 0. Full suite 1609 tests green in debug
and release.
2026-06-24 00:06:23 +02:00
330e827262
docs(design): derive the exact Particle Valid (Restriction) procedure (#252)
* docs(design): derive the exact Particle Valid (Restriction) procedure

The false-negative tail (particles*/elemZ) routes through one approximate
oracle, ParticleRestriction.violation. Rather than patch cases, derive the
exact XSD 1.0 3.9.6 decision procedure once, clean-room from the spec and the
Xerces reference algorithm, with the soundness (no-false-positive) argument for
each clause: normalization, substitution-group expansion, effective total
range, the compositor dispatch table, and the rcases.

Identifies the closeable gaps (subst-group expansion for elemZ026/V020,
MapAndSum effective-total-range, NameAndTypeOK union-member derivation) and
names the genuine spec limitations (Ha161/Z001 contradiction, mgO013 contested,
union-member by-name derivation) as bounded, not bugs. Sets the implementation
order, each clause gated by the proof plus the XSTS valid-rejected=0 gate and an
adversarial over-rejection critic. No behaviour change.

* docs(design): record the empirical finding -- particle-restriction tail is contested

Probing the actual cases overturned the first hypothesis. elemZ026 and
particlesV020 are the same pattern (a derived ref to a substitution-group
MEMBER restricting a base ref to the HEAD), and PureXML already accepts them
because its content model expands a head ref to admit members (for instance
validation), which the restriction oracle inherits -- the Xerces-lenient
reading. They are CONTESTED (Xerces/PureXML accept, XSTS rejects under strict
NameAndTypeOK), so the keystone (adding expansion) would entrench the
acceptance, not close them; closing them risks a false positive. Every
remaining particle case is contested or FP-delicate, so the exact-oracle
keystone yields no clean conformance win. The remaining lever toward the full
bar is the proven resource bound (pushdown matcher), not this oracle. Proof
first earned its keep: it prevented shipping against contested targets.
2026-06-23 10:50:38 +02:00
084716a1e3
fix(schema): attribute-wildcard union/intersection per Errata E1-10 (#251)
Generalise the wildcard {namespace constraint} model to the four XSD 1.0 (with
Errata E1-10) expressible forms -- any; a set (with absent); a pair of not and
a namespace name; a pair of not and absent -- replacing the any/##other/set
model that could not represent the union/intersection results. Both not forms
exclude absent, so a union admitting "every namespace except a name, plus
absent" (E1-10 rule 5.1.3) has no expressible form.

A new SchemaWildcardExpressibility compile rule rejects a complexContent
extension whose attribute-wildcard union with its base is not expressible
(src-ct.5), closing XSTS wildZ013 (test328873i). The expressible unions are
computed exactly in XSDComplexContent, so a derived type's wildcard admits the
correct attributes at instance time, closing wildZ013a and wildZ013d.

invalid-schemas-accepted 12 -> 11, invalid-instances-accepted 17 -> 15, with
valid-schemas-rejected and valid-instances-rejected held at 0 (full XSTS
archive). The model is additive for element wildcards (which never produce the
new forms); admits/narrows/UPA-overlap updated and verified unchanged for the
##other cases.

The intersection of two not forms negating different namespace names is not
expressible (src-ct.4), a deferred rule: rather than drop the wildcard and
over-reject valid cross-namespace attributes on an accepted schema, the
intersection stays the less-restrictive not(lhs) over-approximation (FP-safe,
matching prior behaviour). Union/intersection algebra taken clean-room from
Xerces-J XSWildcardDecl. Guarded by SchemaWildcardUnionExpressibilityTests.
2026-06-23 04:56:17 +02:00
7fda81426e
docs(schema): disclose the contested-corpus basis for rejecting unescaped [ (#250)
#247 rejects an unescaped [ inside a regex character class, which closes the
W3C-settled invalid cases (RegexTest_993/1477) but also rejects siblings the MS
suite marks valid (([[:]+), ([[=]+), ([[.]+), RegexTest_989/990/991/1473-1475,
and the space-bearing subtraction [a - c - [ b ] ]+, 479/480). Those entries
are W3C-queried (status="queried", Bugzilla #4118) because the "valid" marking
contradicts the Appendix F grammar (SingleCharNoEsc excludes [); the suite's
settled position (993/1477) agrees with rejecting, so PureXML follows the
grammar. The queried siblings are excluded from the XSTS conformance gate, so
this is not a counted false positive.

This was shipped in #247 without surfacing the contested nature. Document the
reasoning at the error case and in the changelog so the spec-strict choice is
explicit, not silent. No behaviour change.
2026-06-23 03:34:38 +02:00
118beeb61a
fix(schema): negated character-class subtraction excludes the subtrahend (#249)
XSD charClassSub ::= (posCharGroup | negCharGroup) '-' charClassExpr is the set
difference of the (possibly negated) group and the subtrahend, so for
[^cde-[ag]] negation applies to the base first and the subtrahend is removed
from that result: the class admits everything except {a, c, d, e, g}.

CharClass.matches applied the subtraction to the positive membership and
negated afterwards, so [^cde-[ag]] excluded only {c, d, e} and wrongly matched
a/g, accepting instance values a correct engine rejects. Negation now applies
to the base before subtraction (new result !B && !S is a strict subset of the
old !B || S, matching the Xerces-J reference exactly), so only negated classes
that also carry a subtraction change behaviour.

Closes XSTS RegexTest_430 and RegexTest_422: invalid-instances-accepted 19 ->
17, valid-instances-rejected and valid-schemas-rejected held at 0 (full XSTS
archive). Guarded by RegexCategoryTests.test_negatedSubtraction.
2026-06-23 03:10:45 +02:00
bf37375600
fix(schema): reject an unknown \p{...} category or block name (#248)
XSD 1.0 charProp is IsCategory | IsBlock, an exhaustive set: every Unicode
general category plus a fixed Unicode-3.1 block list. The block table was
missing the nine supplementary-plane blocks (Old Italic, Gothic, Deseret, the
Byzantine/Musical/Mathematical symbol blocks, CJK Unified Ideographs Extension
B, CJK Compatibility Ideographs Supplement, Tags) and the three surrogate
blocks, so parseCategory could not tell a real-but-untabulated block from a
bogus name and stayed lenient (RegexError.unsupported), accepting the pattern
\p{IsaA0-a9} and the schema declaring it (XSTS reK88).

Complete the table to the full XSD set: supplementary blocks with their ranges,
and surrogate blocks as valid names whose ranges hold no scalar (so they match
nothing). The recognised set is now authoritative, so parseCategory throws the
rejecting RegexError.invalidProperty for any unrecognised name. The now-dead
RegexError.unsupported case and the isMalformedProperty heuristic are removed.

Closes XSTS reK88: invalid-schemas-accepted 13 -> 12, valid-schemas-rejected
held at 0, instance buckets unchanged (full XSTS archive). Side effect: a
pattern naming a supplementary-plane block (e.g. \p{IsMusicalSymbols}) now
matches its characters instead of failing to compile, removing a latent
instance false positive. Block names/ranges taken from the XSD-supported list
in Xerces-J Token.java (clean-room: factual data only). The list is
spec-frozen at Unicode 3.1, so later blocks are rejected by design.
2026-06-23 02:15:26 +02:00
ba934bbb58
fix(schema): reject unescaped '[' inside a regex character class (#247)
XSD 1.0 Datatypes Appendix F defines SingleCharNoEsc as [^\[\]] and admits a
nested class only as a -[...] subtraction, so a `[` inside a character class
that is not introduced by `-` is a syntax error. RegexParser treated such a
`[` as a literal scalar, so `[a[:xyz:]` compiled and the schema declaring it
was wrongly accepted (XSTS RegexTest_993, RegexTest_1477).

nextClassMember now throws a dedicated RegexError.unescapedClassBracket, kept
distinct from the generic, deliberately-lenient badClass so the change cannot
regress a valid-but-unsupported pattern. XSDSimpleParser.rejectsPattern treats
it as a genuine syntax error, so the declaring schema is invalid.

Closes RegexTest_993 and RegexTest_1477: invalid-schemas-accepted 15 -> 13,
valid-schemas-rejected held at 0, instance buckets unchanged (full XSTS
archive). Escaped [a\[c] and subtraction [a-z-[aeiou]] stay valid; the
unterminated case [a[:xyz: now faults on the inner [.
2026-06-23 01:19:43 +02:00
bb094aa13c
test(schema): harden the streaming/tree differential over deep group chains (#246)
The streaming-vs-tree differential previously exercised group references only
through the shallow g0/g1 mutual cycle, too weak to gate a future pushdown
content-model matcher whose call/return correctness hinges on deep group
nesting. Add a deep LINEAR group-reference chain (c0 -> ... -> c{chainDepth},
single ref per level so its inlined size is O(depth), not 2^depth) reachable
from any leaf, so the two engines are compared over deep call/return without the
exponential build a doubling chain would trigger. Run divergence-free over the
standing 2000-seed gate and a 60000-seed campaign.

Reconcile docs/release-hurdles.md Part 2 with the deleted positionCap and record
the one remaining structural residual, distinct from occurrence unrolling: the
per-instance NFA build still inlines group references, so a 2^K-inlining schema
makes automaton construction exponential on first instance validation. Document
the pushdown/RTN matcher as the general fix, gated by this hardened differential
(FP-critical: naive sub-model sharing without a stack is unsound).
2026-06-23 00:09:36 +02:00
bc0638e201 perf(schema): memoize group expansion in the compiled content model
Compiling a schema with deeply nested, multiply-referenced model groups was
exponential: groupReferenceParticle inlined each <xs:group ref> into the Particle
content model, so g0=(g1,g1), g1=(g2,g2), ... built 2^K particles (depth 14 =
0.21s, depth 20 ~13-18s). The expansion is now memoized per (group definition,
cycle-visiting set) in a per-compile GroupParticleMemo shared across the value
copies XSDContext.scoped/visiting make, so sibling references share it and the
build drops to O(groups) (depth 40 now ~0.016s; previously infeasible).

The shared content term is reused by value (copy-on-write), so the compiled model
is identical and validation is unchanged: the XSTS gate holds (invalid-schemas-
accepted 15, valid-schemas-rejected 0, instance buckets unchanged) and the
streaming-vs-tree fuzz differential still passes. This closes the schema-compile
half of the interactive-safety group blowup (companion to the positionCap
determinism fix); a depth-40 compile test guards against regression. Full suite,
style, namespacing, and WASM all pass.
2026-06-22 23:24:19 +02:00
6f8222a840 feat(schema): compositional UPA determinism, delete positionCap
The Unique Particle Attribution check no longer inlines group references or caps.
A new CompositionalDeterminism engine computes first/followlast over particle
identities and summarizes each xs:group once, so a multiply-referenced nested
group whose inlined Glushkov automaton would be 2^K positions is checked in
O(nodes * particles^2) time. ContentModelDeterminism now delegates to it and the
inlining position automaton plus positionCap are deleted, closing the last named
interactive-safety resource-bound blocker (production-readiness stopper #4) and
the silent under-rejection the cap caused (stopper #3).

The compositional engine was proven verdict-equivalent to the prior inlining
automaton by a differential over the entire XSTS corpus (zero divergences) before
replacing it. The followlast detail was essential: a first attempt that emitted a
repeating end-item's loop-back and the suffix-first as separate decision sets
under-detected 13 XSTS content models; the differential caught every one.

Verification: XSTS gate unchanged (invalid-schemas-accepted 15, valid-schemas-
rejected 0, instance buckets held); SchemaDeterminismGroupBlowupTests compiles a
2^20-position model without hanging and rejects an ambiguous one the old cap would
have skipped (WASI uses a smaller depth, native time limits gated off WASI); full
suite, style, namespacing, and WASM all pass. The per-particle model and QName-only
overlap are preserved, so the false-positive direction stays at zero.
2026-06-22 22:19:44 +02:00
ba69d716fa docs(design): specify the compositional UPA algorithm to delete positionCap
Turns the 'blocked design frontier' for ContentModelDeterminism.positionCap into
a ready-to-implement, FP-safe plan derived from the current engine. Documents the
root cause (group-ref inlining gives 2^K positions), why naive position
dedup/sharing is unsound (cross-context followpos merge = false positive), the
sound compositional algorithm (per-group memoized nullable/firstParticles/
lastParticles/followlast over particle identities, cross-boundary conflicts
computed per reference-site by the parent, the star condition for repetition,
bounded O(nodes x particles^2)), and the differential-gated rollout (build
alongside the inlining oracle, prove verdict-equality on XSTS + a group-ref fuzz,
only then switch and delete the cap) with acceptance criteria.
2026-06-22 21:08:12 +02:00
2c218b6aa8 test(fuzz): add opt-in deep fuzz campaign for bounded correctness
Production-readiness stopper #3 asks for correctness bounded, not sampled. The
standing fuzz gate keeps a fast, hang-guarded 2000/250/600-iteration sample per
CI run; this adds test_deepFuzzCampaign, which runs the same streaming-vs-tree
differential over PUREXML_FUZZ_ITERATIONS seeds when that variable is set
(skipped otherwise), so a long campaign is reproducible without editing the
bounds and any divergence reproduces from its seed. It carries no .timeLimit (a
60k-seed run legitimately exceeds a minute); termination stays guaranteed by the
engine's own occurrence and state caps. A 60,000-seed campaign (plus 20,000
schema/instance and 80,000 parser-byte iterations) found zero divergences,
crashes, or hangs.
2026-06-22 20:22:36 +02:00
7c0ccb7d9b fix(schema): a complexContent restriction cannot restrict simple content
cos-ct-restricts 5.2: a complexContent restriction's content type is element-
only/mixed/empty (from its model group), which can never validly restrict a
simple content type. complexContentBaseKindFindings only flagged a simple-type
base; a complex type whose content is simpleContent slipped through. The check
is added for the restriction direction only (the extension direction has a valid
attribute-only idiom, handled by simpleContentExtensionBaseFindings). Catches
particlesZ039. XSTS invalid-schemas-accepted 16 to 15, valid-schemas-rejected
held at 0.
2026-06-22 19:40:57 +02:00
a09e68b6fa fix(schema): a content-bearing derived group cannot restrict a base element
XSD 1.0 §3.9.6 Particle Valid (Restriction) defines a case for an element
restricting a base group (RecurseAsIfGroup) but none for the reverse, so a
derived sequence/choice/all that can contribute a child never validly restricts
a single base element. The oracle had a deliberate non-spec 'mirror' extension
that bounded the derived group by the element's effective occurrence range and
accepted it; that masked a real violation inside a choice-to-choice restriction,
where a derived sequence branch was allowed to map onto a base element branch it
cannot legally restrict. The (.group, .element) case now returns invalid; a
content-free derived group is still a valid restriction of any emptiable base
(handled before this case). Catches particlesHb011. XSTS invalid-schemas-
accepted 17 to 16, valid-schemas-rejected held at 0 (differential agree-valid
unchanged).
2026-06-22 19:40:57 +02:00
9966e254e0 fix(schema): resolve defaulted/fixed IDREF attributes against IDs
cvc-id: when an instance omits an attribute, its default/fixed value applies, so
a defaulted or fixed xs:IDREF/xs:IDREFS must still resolve to a matching xs:ID.
The reference was recorded only for attributes physically present, so a dangling
defaulted IDREF was wrongly accepted. The supplied value is now recorded for
document-scoped ID/IDREF checking exactly as a present value would be (shared by
the tree and streaming validators, which both call validateAttributes). A
required absent attribute is still reported as missing, recordIDs is a no-op for
non-ID types, and a defaulted IDREF that does resolve stays valid. Catches
idZ012. XSTS invalid-instances-accepted 20 to 19, valid-instances-rejected held
at 0.
2026-06-22 18:56:36 +02:00
c83e26a273 fix(schema): optional xs:all group requires its members when present
An xs:all group's own minOccurs="0" makes the GROUP optional (it occurs zero
or one times), not each member optional. The parser forced every member's
minOccurs to 0 whenever the all-group was optional, so a non-empty but
incomplete optional all-group (a required member missing) was wrongly accepted,
and a complexContent restriction that relaxed such a required member also slipped
past the particle-restriction check (the zeroed minOccurs made it look already
optional). Members now keep their declared occurrence; the absent-group case is
handled at validation time by skipping the required-member check only when the
all-group contributes no children at all. Applied identically on the tree and
streaming paths (both call allStructureErrors). A required all-group is
unchanged. Catches mgZ001 (instance) and particlesK006 (schema). XSTS invalid-
instances-accepted 21 to 20 and invalid-schemas-accepted 18 to 17, both valid-
rejected buckets held at 0.
2026-06-22 18:29:09 +02:00
e806c33a8e fix(schema): reject a nilled element with a fixed value constraint
cvc-elt.3.2.2: an element nilled by xsi:nil="true" may not have a fixed
{value constraint} (a nilled element has no value, so it cannot take a fixed
one). A nillable element MAY declare a fixed value, so the schema stays valid;
the conflict is an instance error only when such an element is actually nilled.
The check rides the shared nilErrors chokepoint, so the tree and streaming
validators enforce it identically; it prefers the matched particle's constraint
and falls back to the by-local-name map for particle-less paths (the unique-
named root). A nillable element that supplies its fixed value, a nilled element
with no fixed constraint, and a nilled element with a default (not fixed) value
are all unaffected. Catches addB065. XSTS invalid-instances-accepted 22 to 21,
valid-instances-rejected held at 0.
2026-06-22 18:05:23 +02:00
1c836d50f2 fix(schema): reject global form in included/imported documents
The top-level declaration constraints (a global element/attribute may not carry
the local-only form/use/ref/minOccurs/maxOccurs; a global definition needs a
name) apply to every document in a schema composition, but the existing check
ran only over the document being compiled. A violation inside an xs:include,
xs:import, or xs:redefine target was therefore missed and the composed schema
wrongly accepted. The same global-component check now runs over the other
schema-document roots in the composition (the main document is excluded as
already covered; redefine entries are skipped because each schema root walks its
own redefine children). The constraint is absolute and position-independent, so
no valid schema is newly rejected; libxml2 rejects both cases for the same
reason. Catches attQ016 (import) and attQ018 (include). XSTS invalid-schemas-
accepted 20 to 18, valid-schemas-rejected held at 0.
2026-06-22 17:50:53 +02:00
0ff46f6b78 test(parsing): cover streaming ISO-2022-JP and EUC-TW decode
Exercise the byte-at-a-time ISO-2022-JP mode escapes (ASCII, JIS-Roman,
half-width katakana, JIS X 0208) and EUC-TW plane-1 plus the 0x8E four-byte
selector. Raises streaming ISO-2022-JP coverage from 54% to 92% and EUC-TW
from 52% to 76%. Output is compared by Unicode scalar to avoid a grapheme
cluster swallowing the trailing sound mark.
2026-06-22 17:11:58 +02:00
d15faed122 test(relaxng): cover include and externalRef resolution
Exercise RelaxNGParserIncludes (plain include, define override, externalRef)
through the schema loader; raises its line coverage from 24% to 81%.
2026-06-22 17:01:15 +02:00
3cf44cccb9 test(ci): add an opt-in coverage gate; close the 0% value types it surfaces
The coverage analysis noted there is no mechanism that measures test completeness,
so a source file silently sitting at 0% (untested or dead) is invisible.

Add scripts/check-coverage.sh: it runs the instrumented suite, prints overall
line/region/function coverage and the lowest-covered files, and FAILS if any source
file is at 0% line coverage (the exact signal that found the earlier gaps). It is
opt-in (instrumented build + llvm-cov are slow and macOS-only) and skips cleanly
where xcrun is unavailable; an optional COVERAGE_REGION_FLOOR enforces a floor.

Running it immediately surfaced two more 0% value types, now closed by
CoverageGateValueTypeTests: the HTML tokenizer's `Token` (a custom Equatable) and
the public `Stream.Document` (built by API consumers, never internally). Overall
line coverage is now 92.66% with no source file at 0%, so the gate passes.

Tooling + tests only; +2 tests; full suite green (1570).
2026-06-22 16:38:30 +02:00
bd0b47a535 test(schema,xslt,parsing): standing coverage for the gaps a coverage run found
A code-coverage pass found two source files at 0% in the standing suite (covered
only by opt-in gates) and one large under-covered file:

- XSDParserUnion.swift (Document(composing:), the multi-document union, #161) was
  exercised only by the opt-in XSTS harness, so plain `swift test` never ran
  multi-document composition. SchemaComposingTests now covers it via a
  cross-document substitution group (0% to 92% line).
- XSLTCollation.swift (language-tailored xsl:sort orders) was unexercised by any
  standing test. XSLTCollationTests covers the Polish/Russian tables and the
  fallback (0% to 88% line).
- StreamingDecoder.swift (byte-at-a-time CJK/UTF decode) had the streaming/split
  path under-tested. StreamingDecoderTests feeds bytes one at a time through GBK,
  Big5, Shift_JIS, EUC-KR, EUC-JP, GB18030, the UTF-16/32 BOM paths, and a
  malformed sequence (48% to 80% line, 37% to 72% region). Expected scalars were
  taken from the decoder and pinned.

Test-only; +10 tests; full suite green (1568).
2026-06-22 16:16:32 +02:00
b8c8ed56c7 refactor(schema): split Element-Declarations-Consistent out of SchemaStructure
SchemaStructure.swift was the last source file at the 400-line cap. The
cos-element-consistent check (a complex type's content model may not declare two
same-named elements with different types) is a self-contained constraint with its
own helpers (collectElementDecls, elementTypeKey) and the contentModelContainers
set used only by it.

Move that cluster into SchemaElementDeclarationsConsistent.swift;
elementDeclsConsistentErrors becomes internal (its sole caller is in
SchemaStructure). Pure code move, no behaviour change: SchemaStructure 400 -> 342,
new file 59. With this, no source file remains at the 400-line cap. Full suite and
the XSTS gate pass unchanged.
2026-06-22 15:30:58 +02:00
fc8d8faeeb refactor(schema): split redefine attribute-group restriction into its own file
SchemaRedefineSelfReference.swift had reached the 400-line limit, blocking further
redefine-restriction work without a squeeze. It also mixed three cohesive concerns:
redefine self-reference well-formedness (src-redefine.6/7.2.1), attribute-group
restriction (cos-ct-restricts over the redefinition), and model-group restriction.

Move the attribute-group restriction machinery (redefineAttributeGroupRestrictionFindings
and its helpers) into SchemaRedefineAttributeGroupRestriction.swift, following the
established one-concern-per-file pattern (IdentityFieldType.swift). The shared base-
component lookups (baseComponents/resolveBaseComponent) become internal so both files
use them. Pure code move, no behaviour change: 171 + 232 lines (was 400); full suite
and the XSTS gate pass unchanged (20/0/22/0 buckets, 1558 unit tests).
2026-06-22 15:07:53 +02:00
ca503bdee8 refactor(tests): trim redundant burn-down history from the XSTS gate file
The XSTSSuiteTests doc comment had grown a ~120-line per-step burn-down narrative
that duplicated CHANGELOG.md (every entry already said "per-step deltas in
CHANGELOG.md") and kept the file pinned at the 400-line limit, so each conformance
iteration had to hand-squeeze it to stay under the cap.

Replace the narrative with a concise pointer to CHANGELOG.md and
docs/xsts-deviations.md (their canonical home); keep the baseline explanation, the
run instructions, the pinned-count constants, and the specDivergentInstances
exclusion unchanged. File 400 -> 286 lines; the gate's behaviour and pinned counts
are unchanged (verified: XSTSSuiteTests still passes at 20/0/0/22).
2026-06-22 14:41:12 +02:00
74b9332c48 fix(schema): reject identity field whose declared target is a non-simple complex type
cvc-identity-constraint.3 / c-fields-xpaths require an identity-constraint field to
identify a node with a simple type. The prior fix rejected a field whose selected
instance node carries element children (structural). This adds the case where the
field's DECLARED type is a complex type with non-simple content (empty or
element-only/mixed): an attributes-only element resolves to an empty-content complex
type, so its instance node is childless yet not simple-typed.

The field's target type is resolved through the schema type model (the selector
target's declared type, then the field's same-named child element in its content
model) and classified. A simple or simpleContent target, a type reached only
through a named reference, a complex field path, or an unresolvable target is left
alone, so no valid field is rejected. The resolution helpers live in a separate
extension file to keep the validator within the type-body limit.

This catches W3C XSTS idG006. XSTS invalid-instances-accepted 23 -> 22,
valid-instances-rejected held at 0.
2026-06-22 13:10:30 +02:00
18fd42b726 fix(schema): apply substitution-group block to a member with an inline type
The substitution-group filter drops a member when the head element's
{disallowed substitutions} (or the head type's {prohibited substitutions}) blocks
the method by which the member's type derives from the head's type. It looked the
member's type up in the named derivation backbone, so a member with an INLINE
(anonymous) complex type had no entry and was never dropped, even when its inline
type extended the head's type and the head blocked extension.

Record an inline-typed element's derivation under a synthetic key (the element key
plus a `#` marker no QName can contain, so it cannot collide with a real type and
is unreachable as any other type's derivation base) so the filter can see the
member's method. A member reaching the head's type by an unblocked method, a plain
inline type with no derivation, and same-local-name elements in different
namespaces are all unaffected.

This catches W3C XSTS disallowedSubst00105m (and a sibling). XSTS
invalid-instances-accepted 25 -> 23, valid-instances-rejected held at 0.
2026-06-22 06:21:23 +02:00
2419ab43d9 fix(schema): reject identity-constraint field that identifies a complex-content node
Per cvc-identity-constraint.3 / c-fields-xpaths an identity-constraint
(key/unique/keyref) field must evaluate to a node with a simple type: a
simple-typed element, an element with simple content, or an attribute. The
field-cardinality clause (at most one node) was enforced, but the simple-content
clause was not, so a field selecting a complex-content element was wrongly
accepted at instance-validation time.

Add a structural, false-positive-free check: a field whose selected node is an
element carrying element children has complex (element-only or mixed) content and
can never be simple-typed, so it is flagged. A simple-typed or simple-content
element (text only), an empty or text-only node, and an attribute are left alone,
so no valid field is rejected.

This catches W3C XSTS idK012. XSTS invalid-instances-accepted 26 -> 25,
valid-instances-rejected held at 0.
2026-06-22 04:53:58 +02:00
41b3a707c3 fix(schema): flatten redefine self-reference and exclude prohibited uses in attribute uniqueness
The attribute-use uniqueness check (ag-props-correct.2 / ct-props-correct.4)
flattens a complex type's or attribute group's uses through attributeGroup
references. A reference to a redefined group whose redefinition self-references its
former self stopped at the cycle guard, so the original's attributes were never
reached and a duplicate they cause was missed.

Expand a redefinition's self-reference to the group's pre-redefine base definition
(already retained in the context for this purpose) so the original's attributes
join the set; a genuine non-redefine cycle still terminates without contributing.

Separately, exclude use="prohibited" attributes from the duplicate-name and
single-ID counts: a prohibited use is a removal marker, not a member of
{attribute uses} (XSD 3.4.2 / 3.6.2). This is the spec-correct reading and keeps a
self-reference that re-imports an attribute and then prohibits it from being
counted as a duplicate.

This catches W3C XSTS attQ011 and attQ017. XSTS schema invalid-accepted 22 -> 20,
valid-rejected held at 0.
2026-06-22 03:52:13 +02:00
64e30fe491 fix(schema): reject redefined attribute group that self-references and re-declares an attribute
A redefinition of an attributeGroup that self-references the original re-imports
all of its attribute uses, so directly re-declaring one of those attributes
produces two attribute uses of the same name, a duplicate forbidden by
ag-props-correct.2.

Flag a non-prohibited direct re-declaration of an attribute the original directly
declares, when the redefinition has a self-reference. To stay false-positive-free
the check fires only when both the original and the re-declaration land in no
namespace (a qualified attribute's target namespace depends on cross-document
form/chameleon resolution, so it is not compared); a self-referencing redefinition
that adds genuinely new attributes (attgC007/attgC038/groupB018) stays valid.

Also extracts the use-transition restriction errors into a helper to keep the
function within the complexity limit.

This catches W3C XSTS schM8. XSTS schema invalid-accepted 23 -> 22, valid-rejected
held at 0.
2026-06-22 03:07:35 +02:00
b6e5cd8f3f fix(schema): reject redefined attribute group that widens a re-declared attribute's type
A redefinition of an attributeGroup is a restriction of the original
(src-redefine / cos-ct-restricts), so a re-declared attribute's type must derive
by restriction from the original's. The attribute-restriction checks covered
drop-required, re-introduce-prohibited, add-attribute, and relax-fixed, but not a
type change that widens rather than restricts.

Add a built-in-only, namespace-gated check: it fires only when both the original
and the re-declared attribute name a built-in type resolving to the XSD namespace
and the re-declared built-in does not derive from the original's in the XSD
derivation lattice (int does not derive from boolean). A user, inline, or absent
type on either side is not compared, so the check declines and stays lenient: an
absent original type is anySimpleType (which any type restricts), a user/inline
type's derivation is not resolvable here, and a user type whose local name
coincides with a built-in resolves to a different namespace and is not mistaken
for the built-in.

This catches W3C XSTS schM4 (a redefinition re-typing an attribute from boolean
to int). XSTS schema invalid-accepted 24 -> 23, valid-rejected held at 0.
2026-06-22 02:38:24 +02:00
81805ad53e fix(schema): reject redefined attribute group adding attributes via non-self reference
A redefinition of an attributeGroup is a restriction of the original
(src-redefine / cos-ct-restricts), so it may not introduce an attribute the
original lacks unless the original carries an attribute wildcard. The existing
add-attribute check resolved only the redefinition's DIRECT declarations and was
suppressed whenever the redefinition carried any attributeGroup reference, which
conflated a self-reference (re-importing the original's own attributes, valid)
with a non-self reference (injecting foreign attributes, invalid).

Resolve the attribute names a non-self reference contributes and key the
suppression on a genuine self-reference (matched by namespace and local name).
The resolution declines, staying lenient, whenever the contributed set is not
fully knowable: an unresolved group, an attribute-by-ref, an attribute wildcard,
or a further nested reference. This catches W3C XSTS attgC028 (a redefinition
whose ref="car" replaces the original's attributes with foreign ones) while
keeping attgC007/attgC038/groupB018 (added attributes alongside a self-reference)
valid.

XSTS schema invalid-accepted 25 -> 24, valid-rejected held at 0.
2026-06-22 02:06:57 +02:00
6e7f1b7bfc fix(schema): an unprefixed type ref to a target-namespace type is undeclared
isUndeclaredReferenceType matched a no-namespace type/base/itemType/memberTypes
reference by local name, ignoring that an unprefixed QName with no default
xmlns resolves to the absent namespace: type="t" is {}t, not {urn:x}t where the
type is declared, so the reference does not resolve (XSD 1.0 §3.15.3). It now
mirrors isUndeclaredReferenceRef's nil-namespace branch: a no-namespace
reference is undeclared when the schema has a non-empty target namespace; a
chameleon-included global and an imported no-namespace type (via the foreign
pools, checked first) remain accepted.

This corrects an asymmetry where the ref side already rejected the pattern but
the type side did not. The existing SchemaXSIAttributeTests case that asserted
this very pattern compiles used an invalid schema (the same shape as XSTS
addB009/test66059); its type reference is now qualified (tns:attRef), which
preserves the test's xsi:Not-Allowed intent while being valid.

Verified against the full W3C XSTS 2006-11-06 suite: invalid-schemas-accepted
27 -> 25 (addB009/test66059 and one more), valid-schemas-rejected held at 0,
no other bucket moved. Adds a reference test for the type-side rule.
2026-06-21 23:17:14 +02:00
7efc64479b fix(schema): a ref resolving to the XSD namespace is undeclared
isUndeclaredReferenceRef fell through to a lenient accept when a ref or
substitutionGroup resolved to the XSD namespace but the schema did not target
it. The XSD namespace declares no referenceable user component (element,
group, attribute, attribute group), so such a reference is undeclared, the
asymmetry isUndeclaredReferenceType already handled for type/base.

This is reached when an unprefixed reference picks up a default xmlns of the
XSD namespace, so it is not in the target namespace where the schema's own
components live: xmlns="http://www.w3.org/2001/XMLSchema" targetNamespace="foo"
with <element ref="root"/> resolves to {XSD}root, not {foo}root (XSTS
xsd013/xsd014). A schema that itself targets the XSD namespace is the
inTargetNamespace case, and a genuinely loaded XSD-namespace document is the
foreignPools case before this branch, so both are unaffected.

Verified against the full W3C XSTS 2006-11-06 suite: invalid-schemas-accepted
29 -> 27, valid-schemas-rejected held at 0, no other bucket moved. Adds
reference tests for both reject cases and a qualified-ref guard.
2026-06-21 22:57:20 +02:00
0a76658a28 fix(schema): check UPA over an extension's effective content model
Unique Particle Attribution checked only a complexContent extension's own
particles, missing the cross-boundary non-determinism with its base: the
effective content of an extension is the base type's content followed by the
extension's, so a base particle that can recur or end optionally competes
with the extension's leading particles. An element matching either was a UPA
violation the single-node check did not see, and the schema was wrongly
accepted (XSTS particlesZ022).

Build the content model over the effective sequence (base content prepended,
resolved recursively). The base is followed only for a local
(target-namespace), non-cyclic base chain; a foreign, built-in (xs:anyType),
unresolved, or cyclic base leaves the check on the extension's own content, so
no model that cannot be fully assembled is rejected (an under-rejection, never
a false positive). For a non-extension the effective list is a single node, so
sequence([x]) reproduces the prior automaton exactly.

Verified against the full W3C XSTS 2006-11-06 suite: invalid-schemas-accepted
31 -> 29 (particlesZ022 and one more), valid-schemas-rejected held at 0, no
other bucket moved. Adds UPA tests for an ambiguous extension and a
deterministic one (the over-rejection guard); moves the content-model-node and
effective-model helpers to a new file to stay within the type-body and
file-length budgets.
2026-06-21 22:12:32 +02:00
9a2cb7eab3 fix(schema): detect UPA overlap of ##other wildcards
Unique Particle Attribution missed overlapping xs:any wildcards involving
##other: wildcardsOverlap returned false for any pair touching .other, so two
competing wildcards whose namespaces actually intersect were not flagged as
non-deterministic and the schema was wrongly accepted.

Compute the overlap correctly: ##other against ##other always overlaps (both
admit every namespace but their target and the absent one, infinite sets that
always intersect), and ##other against an enumerated set overlaps iff the set
names a namespace that is neither absent (##local) nor the target. The new
predicate matches Wildcard.admits exactly, so disjoint cases (##other vs
##local, or vs the target namespace) stay deterministic and no valid schema
is newly rejected.

Verified against the full W3C XSTS 2006-11-06 suite: invalid-schemas-accepted
35 -> 31 (wildI009/wildI013/wildI014 and one more), valid-schemas-rejected
held at 0 (no other bucket moved). Adds UPA unit tests for both rejection
shapes and a ##other-vs-##local determinism guard; updates the pinned XSTS
count 35 -> 31.
2026-06-21 21:40:56 +02:00
97c07e91f4 feat(schema): locate composition/redefine-pass schema diagnostics
Convert the five composition/redefine compile checkers to located findings,
the last of the #169 burndown (#210-#217): a resolved schemaLocation whose
content is not a schema underlines the include/import/redefine reference, a
redefined type that does not restrict or extend itself underlines the
redefined type, a redefined group or attribute group with a malformed
self-reference underlines the offending reference or redefinition, and a
redefined attribute group or model group that does not restrict its original
underlines the redefinition.

To locate the referenced-schema rule, failedSchemaReferences carries the
referencing node, captured at the load-failure site in XSDContext. Only a
top-level reference's node belongs to the main schema tree the diagnostic
resolves against; a failure nested in a loaded sub-document is captured from
a throwaway parse, so it stays unlocated (the prior schema-root behavior)
rather than mis-resolve, a strict improvement.

Every predicate, aggregation order, and reason string is unchanged; only the
location each finding carries differs, verified conformance-neutral by the
full XSTS suite (14383 groups, pinned counts unchanged). The redefine
checkers had exactly one caller each, so each is renamed in place and the
.unlocated wrap dropped. Adds a located-diagnostics test per node-locatable
checker; the attribute-group and model-group restriction redefine checkers
share the same redefinition-node idiom and are exercised by the XSTS suite.
2026-06-21 19:41:31 +02:00
96ab396eb3 feat(schema): locate names/UPA/notation-pass schema diagnostics
Convert the three names/UPA/notation-pass compile checkers to located
findings so each error attaches to its declaring node instead of the schema
root (#169): component-name uniqueness underlines the second duplicate global,
identity-constraint name uniqueness underlines the second constraint, a
keyref whose refer names no key/unique (or whose arity differs) underlines
the keyref (KeyrefInfo gains the keyref node), a Unique Particle Attribution
violation underlines the ambiguous content-model group, and notation
validity underlines the notation or enumeration.

Every predicate, aggregation order, reason string, and the message-dedup of
the UPA pass are unchanged; only the location each finding carries differs,
verified conformance-neutral by the full XSTS suite (14383 groups, pinned
counts unchanged). The findUniqueIndices circular-import (rootIndex)
workaround is left byte-identical, since it governs which containers are
examined, not where findings locate. The three checkers had exactly one
caller each (their dispatch), so each is renamed in place and the .unlocated
wrap dropped. Adds a located-diagnostics test per sub-check.
2026-06-21 19:16:54 +02:00
c6dd0f7d2f feat(schema): locate extension/substitution-pass schema diagnostics
Convert the three extension/substitution-pass compile checkers to located
findings so each error attaches to its declaring node instead of the schema
root (#169): an xs:all group reached through an extension over a base that
already has content underlines the extension (extensionAllGroupFindings); an
anonymous complex-type restriction that is not a subset of its base
underlines the restriction (anonymousRestrictionFindings); and a
substitution-group member whose type is not validly derived from its head,
including the head's final-exclusion violations, underlines the member
element (substitutionTypeFindings).

Every predicate, iteration order, and reason string is unchanged; only the
location each finding carries differs, verified conformance-neutral by the
full XSTS suite (14383 groups, pinned counts unchanged). The
substitution checker's checkExclusions helper stays [String] and is wrapped
onto the member element at the call site. The three checkers had exactly one
caller each (their dispatch), so each is renamed in place and the .unlocated
wrap dropped. Adds a located-diagnostics test per checker.
2026-06-21 18:35:38 +02:00
4dd1d330cb feat(schema): locate reference-resolution schema diagnostics
Close the last superficially-located checker (#169): referenceFindings was
dispatched as located but its body wrapped the whole aggregate in
.unlocated, so every reference-resolution error still collapsed onto the
schema root. Convert all six sub-collectors to located findings, each
attaching to the node in scope: the core type/base/itemType/memberTypes/ref/
substitutionGroup walk and the xs:-namespace builtin check locate on the
referencing element; the simpleContent base-kind check on the restriction;
the un-imported-namespace check on the referencing element; the
redefine-component-existence check on the redefine's child component; and
the import-namespace-agreement check on the import element.

Every predicate, aggregation order, and reason string is unchanged; only the
location each finding carries differs, verified conformance-neutral by the
full XSTS suite (14383 groups, pinned counts unchanged). Removes two dead
helpers exposed along the way: the unused referenceErrors [String] wrapper
and the unused locatedFindings helper in SchemaCompileValidation. The outer
aggregation is rewritten from a single + chain to incremental appends to keep
the type-checker within budget. Adds a located-diagnostics test per
node-locatable sub-collector; the loader-dependent redefine/import families
are exercised by the XSTS suite.
2026-06-21 18:09:20 +02:00
2b465e9c9c feat(schema): locate content-derivation-pass schema diagnostics
Convert the seven checkers the complexExtensionBaseValid compile rule
dispatches (the cos-ct-extends/src-ct content-derivation family) to located
findings so each error attaches to its derivation node instead of the schema
root (#169): a complexContent extension adding element content over a
simpleContent base, a simpleContent restriction whose base is a built-in or
user simple type, an unfaithful inline type in a simpleContent restriction, a
complexContent base that is a simple type, a simpleContent extension whose
base is xs:anyType, and a mismatched mixed setting on a complexContent
extension each underline their extension/restriction node; an element
carrying a default/fixed value over element-only or empty content underlines
the element.

Each predicate, iteration order, and reason string is unchanged; only the
location each finding carries differs. The seven checkers had exactly one
caller each (the dispatch), so each is renamed in place and the .unlocated
wrap dropped. To keep the file under the 400-line limit, the seven functions
and their two private helpers move to a new SchemaContentDerivation.swift;
the shared extensionAddsElementContent helper, still used by the all-group
checker that stays behind, becomes module-internal. Adds a located-
diagnostics test per checker.
2026-06-21 17:29:41 +02:00
ee5c46929c feat(schema): locate derivation/cycle-pass schema diagnostics
Convert the three derivation/cycle-pass compile checkers to located findings
so each error attaches to its declaring node instead of the schema root
(#169): an all-group referenced inside a sequence/choice/all locates on the
offending group reference (allGroupReferencePlacementFindings); a type
derived from itself locates on its complexType/simpleType
(derivationCycleFindings); and an element that is a member of its own
substitution group, or a model group or attribute group that references
itself, locates on its declaration (circularReferenceFindings).

The cycle checkers report a cycle by component name over a name-keyed graph,
so each now records the declaring node in a parallel nodeOf map built in the
same loop as the graph; a name that yields a finding always has a node, so a
mutual cycle underlines each member on its own declaration. Every predicate,
key ordering, and reason string is unchanged; only the location each finding
carries differs. The three checkers had exactly one caller each, so each is
renamed in place and the .unlocated wrap dropped. Adds a located-diagnostics
test per checker family, covering both substitution and group-reference
cycles.
2026-06-21 16:53:02 +02:00
49fe824601 feat(schema): locate attribute-pass schema diagnostics
Convert the three attribute-pass compile checkers to located findings so
each error attaches to its declaring node instead of the schema root (#169):
an attribute declaration in the XSI namespace locates on the attribute
(xsiNamespaceAttributeFindings); a duplicate attribute use or a second
ID-typed attribute locates on its declaring complexType or attributeGroup,
and an attribute reference contradicting a global fixed value locates on the
reference (attributeUseFindings); and a complex-type restriction that
relaxes a base attribute use or widens the attribute wildcard locates on the
restriction (attributeRestrictionFindings).

Each predicate, traversal order, and reason string is unchanged; only the
location each finding carries differs. The set-level uniqueness/single-ID
findings reuse the existing attributeSetErrors helper, mapping each onto its
declaring container node. An attribute restriction violation has no source
node of its own (the attribute use is a resolved model value), so it locates
on the restriction performing the illegal narrowing. The three checkers had
exactly one caller each, so each is renamed in place and the .unlocated wrap
dropped. Adds a located-diagnostics test per checker family.
2026-06-21 16:28:10 +02:00
356e87581c feat(schema): locate simple-type-pass schema diagnostics
Convert the four simple-type-pass compile checkers to located findings so
each error attaches to its own declaring node instead of the schema root
(#169): a list whose item type is itself a list or a union containing a
list and a list declaring both or neither of itemType/inline simpleType
(simpleTypeVarietyFindings), an empty union, a simpleType restriction whose
base resolves to a complex type (simpleTypeBaseNotComplexFindings), an
itemType/memberType referencing a type final for list/union derivation
(simpleTypeFinalFindings), and an attribute typed by a complex type
(attributeTypeMustBeSimpleFindings).

Each predicate, traversal order, and reason string is unchanged; only the
location each finding carries differs, from the schema root to the
offending list, union, restriction, or attribute node. The four checkers
had exactly one caller each (their compile-rule dispatch), so each is
renamed in place and the .unlocated wrap dropped, leaving no dead wrapper.
Adds a located-diagnostics test per checker in a new suite kept within the
type-body budget.
2026-06-21 15:55:59 +02:00
3a56d9f400 feat(schema): locate structure-pass schema diagnostics
Convert the seven structure-pass compile checkers to located findings so
each error attaches to its own declaring node instead of the schema root
(#169): a facet inapplicable to a list/union variety, a restriction of
xs:anySimpleType and a constraining facet on anySimpleType-based simple
content, a top-level definition missing its name or carrying a local-only
attribute, a nested named definition, an empty targetNamespace or import
namespace, and an all-group reference with an illegal minOccurs/maxOccurs.

The rewire in structureFindings preserves the prior finding order and
count; each error predicate and reason string is unchanged, only the
location each finding carries differs. Adds a located-diagnostics test
per checker family in a new suite kept within the type-body budget.
2026-06-21 15:23:13 +02:00
d53cdaad1c docs(changelog): note located value-constraint diagnostics (#169) 2026-06-21 14:37:29 +02:00
76ef2d5b83 feat(schema): locate value-constraint type errors
Convert the three value-constraint checkers (built-in, named user type,
and inline type) to located findings so a default/fixed value that is
invalid for its declared type, or an illegal combination of value
constraints, is attached to its declaring element or attribute instead
of the schema root. An editor can underline the offending declaration
by line and column.

Error predicates and reason strings are unchanged; only the location
each finding carries differs. Adds located-diagnostics tests for the
built-in, user-type, and inline-type paths.
2026-06-21 14:37:29 +02:00
f5059de42e feat(schema): locate ID-typed value-constraint errors
Convert idValueConstraintErrors to idValueConstraintFindings so each
a-props-correct.3 / e-props-correct.5 violation is attached to its
declaring element or attribute node, letting an editor underline the
offending declaration by line and column instead of the schema root.

The error predicate and reason strings are unchanged: only where each
error points differs. Adds a located-diagnostics test asserting the
finding resolves to the declaring component's source range.
2026-06-21 14:37:29 +02:00
fa3552876a fix(schema): saturate effectively-unbounded content-model counters (#192, partial)
The counted content-model automaton represents its active set as
(state, counter-vector) configurations bounded by states times the product of
the per-counter saturation caps. An unbounded counter already saturates at its
minimum (every higher count is matching-equivalent). A finite maximum the input
cannot reach is equivalent: the active set increments a counter at most to its
cap, the cap is at most inputLength+1, so when maximum > inputLength+1 the value
can never reach maximum, belowMaximum is always satisfied, and every value at or
above the minimum is indistinguishable. Saturate such a counter at its minimum
too, so a huge but finite maxOccurs on a nested counter no longer multiplies the
active set by an inputLength factor per nesting level.

A document that did not terminate within a minute against the Z035 worst case
(nested sequences/choices with maxOccurs up to 1e11) now validates in linear
time. No accept/reject decision changes: the full XSTS suite holds at
0/35/0/26, and an adversarial no-caps reference-matcher differential found no
false positive or false negative, including the nullable-body case (where the
builder makes canExit unconditional, so the counter value is matching-irrelevant)
and the occurrence boundary. Guarded by ContentMatcherWorstCaseTests.

Partial: a model with several moderate (input-reachable) maxOccurs nested deeply
still enumerates a large active set; the general polynomial bound (a position-
indexed DP over the counting automaton) remains under #192.
2026-06-21 13:16:24 +02:00
7a126a9b0a test(schema): enforce streaming-vs-tree agreement as a standing gate
The streaming XSD validator is a separate implementation of the same semantics
as the tree validator (the conformance oracle), and three false positives in
it were found and fixed this cycle (#186, #200, #202) only by adversarial
hunting, because the XSTS gate exercises the tree path, not validate(streaming:).
A snapshot is not an invariant.

Add test_streamingMatchesTreeDifferential to the generative fuzz suite: 2000
deterministic schema/instance pairs (15 on WASI), asserting that streaming and
the tree validator agree on whether each document is valid (so streaming never
rejects a valid document and never accepts an invalid one the tree rejects),
and that the tree's errors are a subset of streaming's (streaming may add
errors on an already-invalid document, #206, but may never miss a real error
or invent a rejection). A failure prints the seed, schema, instance, and both
error sets, so it reproduces exactly.

This runs in the standard suite on all five platforms, so a future streaming
false positive fails CI rather than reaching users.
2026-06-20 19:54:05 +02:00
ca7a7d1433 fix(schema): a reference to an all group must have maxOccurs 1
XSD 1.0 cos-all-limited: a reference to a model group whose content is an
`all` group is itself an all-group particle, so its maxOccurs must be 1 and
its minOccurs 0 or 1. The direct `<all>` element was already checked by
allGroupLimitedErrors, but a `<group ref>` resolving to an all group was not,
so corpus particlesEa025 (`<group ref="x:G" maxOccurs="2">` to an all group)
was accepted.

Add allGroupReferenceMaxOccursErrors: collect the names of group definitions
whose content is an all group, then flag a group reference to one whose
maxOccurs is not 1 or whose minOccurs is not 0/1. The check runs over a single
schema document tree, where group names are unique, so the by-local-name match
cannot collide; a cross-document reference lives in a separate container and is
under-rejected (never falsely rejected).

XSTS invalid-schemas-accepted 36 -> 35; valid-schemas-rejected held at 0.
2026-06-20 19:14:33 +02:00
ee676cb470 fix(schema): restriction may not narrow an element to an extension-derived type
XSD 1.0 cos-particle-restrict NameAndTypeOK.2.2: when a complexContent
restriction narrows an element, the element's type must be validly derived
from the base element's type EXCLUDING extension (and list/union), i.e. by
restriction only. The element-type check followed the derivation chain
regardless of method, so a type derived from the base by extension was wrongly
accepted as a valid restriction (corpus particlesIj008).

Add typeDerivesByRestriction (as typeDerivesOrEqual, but the user-declared
chain is followed across restriction steps only; an extension step breaks the
walk) and use it on the non-union fall-through of elementTypeRestrictionOK.
The shared typeDerivesOrEqual is untouched, so substitution-group validity
(where extension is legal) is unaffected. Built-in lattice and union-base
derivation are unchanged, so valid restrictions, including the union-member
restriction addB150, still compile.

XSTS invalid-schemas-accepted 37 -> 36; valid-schemas-rejected held at 0
across all 14383 groups. Union-base (Ik011/Ik025/Ik027) and substitution-group
(V020) cases need the by-name union-member and ref-resolution clauses and stay
deferred.
2026-06-20 18:34:50 +02:00
f6159191db fix(schema): compare simpleContent fixed value in value space
A fixed value on an element whose type is a complex type with simpleContent
was compared as a raw string, so value-space-equal content was wrongly
rejected (e.g. "05" against an xs:int fixed "5"). The complex-element fixed
check on both the tree path (validate(_:against:)) and the streaming path
passed no value type to elementFixedErrors, falling through to a string
compare; both now pass the simpleContent simple type as the value type, in
lockstep, so the comparison is in value space (cvc-elt.5.2.2.1) as the
simple-typed-element path already does.

XSTS gate held at 0/37/0/26 across all 14383 groups.
2026-06-20 18:08:32 +02:00
aeff7869f9 fix(schema): streaming validator applies default/fixed value constraints
The streaming XSD validator ignored an element's default/fixed value constraint
in simple-content validation, so an empty element with a value constraint was
validated against the empty string (a false positive), and a present fixed
value was compared as a raw string rather than in the type's value space.

Mirror the tree path's validateChild for the simple-typed element:

- an empty element takes its constraint value via a shared effectiveSimpleText
  helper both paths use; the constraint comes from the matched particle (keyed
  by namespaced identity), not the flat by-local-name elementConstraints map,
  so same-local-name elements in different types do not collide.
- a present fixed value is compared in the type's value space and against the
  matched particle's fixed value (elementFixedErrors with valueType and
  particleFixed), not a raw string compare ("05" equals int fixed "5").

The particle constraint is looked up from the parent's content model at
element-close time. Streaming now agrees with the tree-path oracle on value
constraints, verified by an adversarial differential (13 configurations).
No XSTS bucket changes; gate held at 0/37/0/26.
2026-06-20 17:38:45 +02:00
f9b56077cc fix(schema): streaming xsi:type and type resolution match the tree path
The streaming XSD validator resolved element and xsi:type references by bare
local name, while its type tables are keyed by namespaced identity
({ns}local). A prefixed xsi:type in an imported namespace missed (a false
"unknown xsi:type") or hit a same-local-name type in the wrong namespace, and
two global elements sharing a local name across namespaces collided.

Resolve through in-scope prefix bindings accumulated down the element stack,
keyed by namespaced identity, mirroring the tree path:

- the xsi:type override type, the undeclared-type check, and root/child
  element declarations are namespaced; root resolution mirrors the tree's
  globalElementDeclaration (bare local-name map only for a no-namespace root).
- run the full cvc-elt.4 substitution gate the tree path runs (blocked /
  abstract / not-validly-derived / list-or-union / anyType-for-anySimpleType
  override, and an abstract declared type carrying no xsi:type).
- resolve a type from an element's own xsi:type where there is no governing
  declaration: an undeclared root (the Sun target-namespace fallback) and a
  strict-wildcard child, both via a shared xsiTypeNamedType helper.

Streaming now agrees with the tree-path oracle across these, verified by an
adversarial streaming-vs-tree differential. No XSTS bucket changes (the tree
path is the conformance oracle); gate held at 0/37/0/26.
2026-06-20 16:38:56 +02:00
8bb7568f6e test(schema): pin restriction mixed-content asymmetry
XSD cos-ct-restricts restriction mixedness is one-directional: a restriction
may keep mixedness or narrow mixed to element-only, but must not add mixed
content to an element-only base. The two #184 targets are already rejected
(ctZ010d by extensionMixedAgreementErrors, ctZ010e by ParticleRestriction's
mixed-vs-element-only case) and the four guard schemas already compile, so no
behavior change is needed. This adds the isolated regression pin the
restriction half lacked, guarding against a bidirectional "derived mixed must
equal base mixed" rule, which regressed four valid schemas when last tried.
2026-06-20 15:03:51 +02:00
d8794579db fix(schema): forbid attribute declarations in the XSI namespace
XSD 1.0 Schema Component Constraint "xsi: Not Allowed" (§3.2.6): an attribute
declaration's {target namespace} must not be the XSI namespace. A schema may
target XSI and declare unqualified attributes (they land in no namespace),
but a top-level attribute, or a local one made qualified by form="qualified"
or attributeFormDefault="qualified", lands in XSI and is forbidden.

Add xsiNamespaceAttributeErrors, scoped to schemas whose target namespace is
XSI, registered as the pre-compile rule attributeDeclarationsNotInXSI. It
rejects corpus attKb018a (qualified attribute-group attributes) and attKa015
(top-level attributes), while attKb018 and attKc018 (local unqualified) stay
valid.

XSTS invalid-schemas-accepted 39 -> 37; valid-schemas-rejected held at 0; no
other bucket moved.
2026-06-20 14:33:14 +02:00
4735acbf03 docs(roadmap): add epic #195 and sync XSTS counts
Add the #195 xsi:type/substitution derivation epic to the README roadmap
diagram (the epic-coverage gate requires every epic issue to appear), and
update the settled XSTS counts to 0/39/0/26.
2026-06-20 13:50:55 +02:00
7256df1ceb fix(schema): reject references into never-imported namespaces
On the cross-document skip path (a schema declares import/include/redefine
but no external document was loaded), the reference check stood down whole,
so a prefixed reference whose namespace is never imported was accepted even
though it can resolve to no component under any loading (corpus schZ011's
d:d, where namespaces a/b/c are imported but d is not).

Add undeclaredNamespaceReferenceErrors, run only on that skip path, to flag
a reference whose namespace is neither the target, the XSD or XML namespace,
nor named by any xs:import. To stay sound when the import closure is unknown,
it stands down entirely the moment any external carries a schemaLocation:
that document may transitively import the referenced namespace (corpus
schZ004 imports only a, yet a's document imports b, so b:b resolves). It
applies only when every external is location-less and the closure is
provably complete.

XSTS invalid-schemas-accepted 43 -> 39 (schZ011_a-d); valid-schemas-rejected
held at 0; no other bucket moved.
2026-06-20 13:50:55 +02:00
2a9a5afaa9 fix(schema): a non-nillable element must carry no xsi:nil at all (cvc-elt.3.1)
cvc-elt.3.1: when an element's declaration is not nillable, the xsi:nil attribute
is forbidden entirely, whatever its value. PureXML only rejected xsi:nil="true" on
a non-nillable element, so xsi:nil="false" slipped through. ComplexValidator.nilErrors
now flags the presence of any xsi:nil on a non-nillable element via the new
nilAttributeValue helper, while a nillable element still accepts xsi:nil="false"
(explicitly not nilled, valid empty-or-content per its type).

XSTS invalid-instances-accepted 28 -> 26 (both xsi:nil-on-non-nillable cases), no
other bucket moved (valid-schemas-rejected and valid-instances-rejected held at 0
across all 14383 groups). 1452 unit tests pass.
2026-06-20 03:22:05 +02:00
45f2e7dbf5 fix(schema): an untyped substitutionGroup member inherits its head's type
XSD 1.0 3.3.2: an element with neither a `type` attribute nor an inline type, but
a `substitutionGroup`, takes the {type definition} of its head. PureXML defaulted
such an element to `anyType`, so an instance of it accepted any content.
`XSDParser.elementType` now resolves the head declaration through
`context.globalElements` (transitively, with a cycle guard) and uses its type.

XSTS invalid-instances-accepted 29 -> 28 (typeDef00204m: <root>Yes</root> is no
longer accepted where root substitutes a boolean head), no other bucket moved
(valid-schemas-rejected and valid-instances-rejected held at 0 across all 14383
groups). 1451 unit tests pass.
2026-06-20 02:47:07 +02:00
6927eb26f1 fix(schema): reject xsi:type=anyType on an anySimpleType-declared element
cvc-elt.4.3.2.1 requires an xsi:type to name a type DERIVED from the element's
declared type. anyType is the ur-supertype of every type, so it is never derived
from anySimpleType (its own subtype); an anyType override on an anySimpleType
element is therefore invalid. The general not-derived check stays silent on
ur-type declared types to avoid false positives, so this adds the narrow,
provably-invalid anyType-for-anySimpleType pairing only, leaving anyType valid on
an anyType-declared element (which resolves to a complex ur-type and is excluded).

XSTS invalid-instances-accepted 30 -> 29 (stZ056), no other bucket moved
(valid-schemas-rejected and valid-instances-rejected held at 0 across all 14383
groups). 1450 unit tests pass.
2026-06-20 02:09:51 +02:00
01650a454e fix(schema): reject year 0000 in dateTime, date, gYearMonth, gYear
XSD 1.0 has no year zero (the year before 0001 is -0001), so 0000-01-01T00:00:00
and the other 0000 forms are not valid values. DateTimeParser.parseYearOnly now
rejects a zero magnitude for both signs, so negative years like -0001 still parse.

Closes the XSTS dateTime011 false negative: invalid-instances-accepted 31 -> 30,
no other bucket moved (valid-schemas-rejected and valid-instances-rejected held at
0). 1449 unit tests pass; the full XSTS suite settles at 0/43/0/30.
2026-06-20 01:31:14 +02:00
d9ebfc1998 docs: correct the last stale valid-schemas-rejected figure
The Antigravity onboarding definition-of-done checklist still read
'valid-schemas-rejected still 1'; the enforced baseline is 0
(Tests/XSTSSuiteTests.swift), matching the rest of this file and GEMINI.md.
Caught by the PR review pass.
2026-06-19 23:32:04 +02:00
cd6201ca5c fix(schema): make the container-location root fallback live
containerLocationMap stored `loc ?? rootLocation` into a [ObjectIdentifier:
String?]. Because the value type is itself optional the coalescing bound at the
outer optional and rootLocation was never used (the compiler flagged it), so a
container with no location of its own (the compile root) mapped to nil even when
a circular import had loaded the root under a schemaLocation. An explicit String?
annotation keeps the coalescing at the String level so the root inherits
rootLocation.

Conformance is unchanged: 1448 unit tests pass and the full XSTS suite still
settles at 0/43/0/31. The SchemaNameUniqueness root-index dedup case is kept, not
removed: an experiment removing it on the theory it was now redundant regressed
three valid schemas to rejected, so it does real work beyond the fixed fallback.
2026-06-19 23:32:04 +02:00
73ba9b1f38 fix(schema): bound content-matcher configuration set per counter
The counted content automaton clamped every occurrence counter to a single
inputLength + 1 ceiling, so a content model with nested occurrence bounds let
the epsilon-closure grow toward inputLength raised to the counter count. The
XSTS particlesQ005 instance (about 1000 elements against
sequence{1,1000}(e1{1,unbounded})) drove that to roughly 16M configurations per
closure and hundreds of millions of hash operations, so validation did not
terminate in reasonable time.

Each counter now saturates at min(maximum, inputLength + 1), and an unbounded
counter at its minimum: once the minimum is reached every higher count is
matching-equivalent, because canExit only tests the minimum and belowMaximum is
vacuous, so collapsing them is exact. The active-configuration set is then
bounded by the content model rather than by the input length.

particlesQ005 goes from a multi-minute near-hang to about two seconds with
identical results. The 1448 unit tests pass and the full XSTS suite still
settles at 0/43/0/31, and now completes instead of hanging.
2026-06-19 23:32:04 +02:00
8d30010e0d docs: align stale figures with code and the enforced XSTS gate
- README, roadmap: XSTS status quad and gates table now read the enforced
  0/43/0/31 (Tests/XSTSSuiteTests.swift); test count 986/169 to 1448/258;
  encoding list and the remaining-work section reflect shipped reality;
  check-all expansion lists the validation and changelog gates
- GEMINI.md, onboarding-antigravity.md: valid-schemas-rejected 1 to 0 and the
  pinned baselines updated to current
- SECURITY.md: complete the reporting address
- Parsing.InputEncoding: doc comment lists the full shipped encoding set
- Schema.XSDParserUnion: guard the localName interpolation with ?? "" to match
  the same line and silence an optional-interpolation warning (internal dedup
  key, behavior preserving)
- CHANGELOG: Unreleased entries for the above
2026-06-19 23:32:04 +02:00
b73384bc2e fix(schema): count content-model occurrences 2026-06-19 23:32:04 +02:00
47c3a230e4 docs(schema): design counted content automaton 2026-06-19 09:23:05 +02:00
f8fd865456 fix(schema): carry global ref metadata into restrictions 2026-06-19 09:03:49 +02:00
55713bbbc0 fix(schema): stop blocked substitution chaining 2026-06-19 08:37:19 +02:00
f04ab8afa1 Reject complex substitutions for anySimpleType heads 2026-06-18 23:04:11 +02:00
00995071b0 Reject mixedness-changing complexContent extensions 2026-06-18 22:37:49 +02:00
59b006cb72 Exclude mirrored enforcement docs from style scan 2026-06-18 22:37:44 +02:00
ed38a76acd Reject invalid simpleContent inline restrictions 2026-06-18 22:01:01 +02:00
54a731e190 Mirror mandatory Swift rules 2026-06-18 22:00:41 +02:00
d0805f21bb docs: analyze the release hurdles to 1.0 (false-negative tail, proven bounds)
Companion to production-readiness.md and roadmap.md. Characterizes, with code
evidence, the two biggest hurdles. (1) The invalid-accepted tail concentrates in
ParticleRestriction.violation's deliberately under-approximated clauses (MapAndSum,
RecurseAsIfGroup, NameAndTypeOK): a single high-fan-in oracle where tightening
risks a false positive. (2) ContentMatcher unrolls occurrence values into NFA
states, so the occursUnrollCap/totalStateCap/positionCap caps are band-aids that
silently degrade to star; a counting automaton is what turns the cap into a proven
bound.
2026-06-18 20:46:12 +02:00
83e6e27d12 style(format): adopt --importgrouping testable-bottom from canonical rules-swift
Group @testable imports at the bottom, per public/rules-swift's swiftformat. A
behavior-preserving import-ordering reformat across 252 files (mostly tests).
--trailingclosures was deliberately not adopted (cosmetic, and swiftformat flags
it as caution-worthy).
2026-06-18 20:33:07 +02:00
c6f305357c chore(rules): align lint config and pointer with canonical rules-swift
Make public/rules-swift the canonical source (per project direction) and adopt
its opt-in lint rules that PureXML lacked: empty_count, explicit_init, first_where,
sorted_first_last, unused_import, plus its todo disable. All pass with zero
violations on the existing sources.

PureXML keeps its coherent, stricter tuning where the canon is a self-described
'starting point': force_unwrapping/force_try/force_cast = error (safety), and
trailing commas mandated in both swiftformat (--commas always) and swiftlint
(mandatory_comma) to resolve the canon's formatter-adds vs linter-flags mismatch.
docs/rules/README.md now points at public/rules-swift and documents the tuning.
2026-06-18 20:30:46 +02:00
d046189ad6 refactor(schema): extract shared base-component resolution for redefines
The attribute-group and model-group redefine-restriction rules built the
namespaced base-component pool and did the namespaced-key-plus-chameleon-fallback
lookup with duplicated code and near-identical comments. Extract baseComponents(in:
named:) and resolveBaseComponent(_:name:owner:) so the recurring namespace-keying
and chameleon-fallback pattern lives in one place. Behavior-preserving: all redefine
unit tests pass and the XSTS buckets are unchanged (0/48/0/31).
2026-06-18 20:20:26 +02:00
f8ba6fef5e fix(schema): reject a constraining facet on anySimpleType-based simpleContent
A constraining facet may not be applied where a simpleContent restriction's base
chain resolves to xs:anySimpleType, the ur simple type, which carries no
constraining facets (XSD Part 2 3.2.1); stZ010 (a complex type extending
xs:anySimpleType, restricted by another that adds minLength) is now rejected.

The base chain is followed through local complex types' own simpleContent bases;
it stops at any other built-in, a simple type, or an external/unresolved base, and
a restriction supplying its own nested simpleType base is skipped (the facet
applies to that), so a facet on a string-, int-, or other real-typed simpleContent
stays accepted.

XSTS invalid-schemas-accepted 49 -> 48, no false positive (valid-schemas-rejected
and valid-instances-rejected held at 0; an adversarial critic probed 12 valid
schemas across every resolver branch and confirmed FP-safety), no other bucket
moved.
2026-06-18 19:27:28 +02:00
afad5f822c fix(schema): reject adding an attribute in an attribute-group redefine
A redefinition of an attribute group may not ADD an attribute the original does
not declare (a superset is not a restriction, src-redefine / cos-ct-restricts);
schM3 (redeclaring the original's attributes plus a new b4) is now rejected. The
base is resolved by namespaced identity with a {}name chameleon fallback for a
no-targetNamespace redefined schema.

False-positive-free: the added-attribute check runs only when the original's
attribute set is fully known from its direct declarations and the redefinition
carries no attributeGroup reference (a self-reference brings in the original's
attributes, so declaring more alongside it is a valid redefine, not an addition,
W3C attgC007/attgC038/groupB018, test75083/xsd003b), and a use=prohibited
attribute the base lacks forbids rather than permits a name, so it does not widen
the usable set and is left alone.

XSTS invalid-schemas-accepted 51 -> 49, no false positive (valid-schemas-rejected
and valid-instances-rejected held at 0; five self-reference over-rejections were
caught by the gate and one prohibited-attribute over-rejection by an adversarial
critic, all fixed before commit), no other bucket moved.
2026-06-18 18:41:13 +02:00
af5d7882b0 fix(schema): enforce model-group redefine restriction (cos-group-restrict)
A non-self-referencing model group redefined inside xs:redefine must be a valid
restriction of the group it redefines (cos-group-restrict / src-redefine 6.1.2),
so its content model may not widen the original's language. The check reuses the
particle-restriction oracle used for complex-type restriction, resolves the
original group by namespaced identity with a {}name chameleon fallback, and runs
post-compile where element type compatibility is available.

Now rejected: adding required content to an empty base (schZ006), dropping a
required all member (schL1), reordering an all (schL5/L6, the Recurse rule is
order-preserving even for all), growing to a superset (schL8), and the
multi-document/chained redefines schN4/5/6/7 and schL10. To stay
false-positive-free the check skips a redefinition carrying a self-reference and
declines to judge a base with a maxOccurs=0 pointless particle (the contested
case W3C accepts, mgO013); genuine restrictions (dropping an optional member, an
unchanged redefinition) are accepted.

XSTS invalid-schemas-accepted 64 -> 51, no false positive (valid-schemas-rejected
and valid-instances-rejected held at 0; an over-rejection of mgO013 surfaced by
the gate was fixed before commit, and an adversarial critic confirmed FP-safety),
no other bucket moved. Disclosed under-rejections: self-referencing redefinitions
(schN10) and maxOccurs=0-base redefines.
2026-06-18 18:24:58 +02:00
1d17d2089f fix(schema): reject relaxing a fixed value in an attribute-group redefine
A redefinition of an attribute group restricts the original (src-redefine /
cos-ct-restricts), so it may not relax a fixed value the original sets. schM10
(changing a fixed attribute to a default one) is now rejected. The check fires
only when the redefinition declares the matching attribute as non-prohibited
with a differing fixed value; keeping the same fixed value, prohibiting it, or
omitting it is left alone, and it rides the existing namespaced base lookup, so
no valid redefinition is rejected.

XSTS invalid-schemas-accepted 65 -> 64, no false positive (valid-schemas-rejected
and valid-instances-rejected held at 0), no other bucket moved.
2026-06-18 17:57:43 +02:00
be9ba9fc1e fix(schema): a redefined attribute group keeps required and re-prohibits prohibited attributes; invalid-schemas-accepted 67 to 65 (#145, #161)
A redefinition of an attribute group restricts the original (src-redefine /
cos-ct-restricts), so it may neither eliminate a required attribute the original
declares (attgZ002) nor re-introduce a prohibited one as usable (attgZ003).
redefineAttributeGroupRestrictionErrors compares the redefinition to the original
attribute group and reports either violation.

False positives the verification caught and the rule guards against: re-declaring
a base prohibited attribute AS prohibited keeps it prohibited and is valid (the
check requires a non-prohibited use); the drop-required check is skipped when the
redefinition pulls attributes back via a nested attributeGroup reference; and the
base is matched by NAMESPACED identity ({target}name), so a same-local-name
attribute group imported from a different namespace is never mistaken for the base
(the same namespace guard the redefine self-reference rule uses).

invalid-schemas-accepted 67 to 65, no false positive (valid-schemas-rejected and
valid-instances-rejected held at 0). The gate caught a re-prohibition false
positive and an adversarial review caught a cross-namespace collision false
positive; both are fixed and covered by tests.
2026-06-18 17:46:54 +02:00
872e6ea7d0 fix(schema): preserve list/union variety through a simpleContent derivation (#147)
A complex type whose simpleContent derives from a list or union simple type now
keeps that variety, so its element content is validated as a list or union rather
than collapsing to an atomic value space. simpleContentType built the content type
with only the resolved base and facets, dropping the variety, so a list-typed
simpleContent accepted any whitespace-separated text and an invalid item (e.g. a
non-int in a list of int) was not caught.

A valid list/union value is unaffected; the change only tightens validation of
content that was wrongly accepted. The W3C XSTS corpus does not exercise this
case, so all four bucket counts are unchanged (vsr=0, isa=67, vir=0, ivi=31); the
gap was surfaced by the libxml2 differential and is covered by a unit test.
2026-06-18 17:23:11 +02:00
0167c92790 fix(schema): a list/union substitution-group member derives only from anySimpleType; invalid-schemas-accepted 69 to 67 (#145)
A substitution-group member whose type is a list or union derives only from
xsd:anySimpleType (XSD Part 2), so it may validly affiliate only to a head typed
anySimpleType, to the same type, or as a recorded restriction of the head's type
(cos-equiv-derived-OK). substitutionTypeErrors skipped the derivation check
entirely when either the member or the head type was a list/union, so a
list/union-typed member affiliated to an unrelated head type (addB141: a union
member of a list-typed head) was accepted.

checkSingleElement now rejects a list/union member type that is not a recorded
derivation of a non-ur head type, before the blanket list/union skip. A list/union
member of an anySimpleType (or untyped, hence anyType) head, the same type, and a
restriction of the head's list/union type are all left valid (isDerived covers the
restriction chains via typeDerivesOrEqual).

invalid-schemas-accepted 69 to 67, no false positive (valid-schemas-rejected and
valid-instances-rejected held at 0). An adversarial review verified untyped and
anySimpleType heads, multi-level restriction-of-list/union members, same-type and
cross-namespace affiliations all still compile.
2026-06-18 16:30:33 +02:00
c3393bcaad fix(schema): key a local element's block by its effective instance namespace; invalid-instances-accepted 32 to 31 (#147)
The namespaced derivation backbone keyed every element by the container target
namespace, but a local element is in the target namespace only when qualified (its
own form, else the schema's elementFormDefault). An unqualified local element's
block was keyed {targetNS}name while the instance element is {}name, so
blockedSubstitutionError's lookup missed and a blocked xsi:type substitution on it
was accepted (typeDef00801: a local Element with block="extension" did not bar an
extension substitution).

gatherElement now keys a local element by localElementNamespace (target namespace
when qualified, none otherwise); global elements stay keyed by the target
namespace, and the bare-keyed tables are unchanged.

invalid-instances-accepted 32 to 31, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0). An adversarial review confirmed qualified
local elements are unchanged, global and same-named local element keys stay
distinct, and a non-blocked substitution on a local element is still accepted.
2026-06-18 16:12:08 +02:00
7e765a3bc1 fix(schema): reject an xsi:type that names an abstract type; invalid-instances-accepted 34 to 32 (#147)
An abstract type cannot be the type of an instance element (cvc-elt.4.3.2), so an
instance xsi:type naming an abstract type is invalid as a substitution even when
the element's declared type would otherwise admit it (typeDef01101, xsi:type on an
anyType-declared element). The validator only checked for an abstract DECLARED
type with no xsi:type; abstractXsiTypeError now also rejects an abstract xsi:type
substitute (matched against the namespaced abstract-type set). A concrete type is
unaffected.

invalid-instances-accepted 34 to 32, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0; a valid instance never names an abstract
type via xsi:type), no other bucket moved.
2026-06-18 15:58:08 +02:00
f432254f5c fix(schema): a fixed mixed element must have no element children; invalid-instances-accepted 37 to 34 (#147)
An element with a fixed value constraint carries its fixed character value and no
element children, even under a mixed content type (cvc-elt.5.2.2.2.1). The fixed
check compared only the concatenated text, so a mixed fixed element whose text
matched the fixed value but which also contained child elements (ctZ013c/d) was
accepted. elementFixedErrors now rejects a fixed element that has element
children. An empty element (which takes the fixed value) and text equal to the
fixed value remain valid.

invalid-instances-accepted 37 to 34, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0; a fixed element never validly carries
element children), no other bucket moved.
2026-06-18 15:51:25 +02:00
ad80877d27 fix(schema): resolve a simpleContent base through complex-type chains; invalid-instances-accepted 39 to 37 (#147)
A complex type's simpleContent derivation can name another complex type with
simpleContent as its base. simpleContentType resolved a non-XSD, non-simpleType
base via context.simpleTypes only, so a complex-type base (absent from that map)
fell back to xs:string and left the content unconstrained: text invalid for the
real base type was accepted (baseTD00101, where Test restricts Test2 which extends
xs:int, yet "b" was accepted).

simpleContentBaseType now resolves the base to an XSD built-in, a named simple
type, or, when the base is a complex type with simpleContent, that type's own
resolved simple type by recursing through complexTypeNodes (cycle-guarded by a
visited set); the deriving node's facets are then applied over the resolved base's
facets. So Test resolves to int and non-int text is rejected, while a valid int
and the accumulated facets along the chain are honoured.

invalid-instances-accepted 39 to 37, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved. An adversarial
review exercised facet accumulation, named-simpleType bases in the chain,
multi-level and extension chains, and cycles, and found no over-rejection.
2026-06-18 15:44:54 +02:00
488f58701a fix(schema): exclude use="prohibited" attributes from effective uses at instance time; invalid-instances-accepted 43 to 39 (#147)
use="prohibited" removes an attribute from a complex type's effective {attribute
uses} (XSD 1.0 §3.4.2): the attribute is then undeclared, so its presence is
rejected when no attribute wildcard admits it (attJ), but is still admitted (and
validated per the wildcard's processContents) when one does (attZ002). The use
had been parsed as merely optional, so a prohibited attribute was always allowed.

AttributeUse gains a `prohibited` flag, set for both a local declaration and an
<attribute ref> use site. The declaration is kept in the type's attribute list so
schema-validity checks still see it, while instance validation excludes prohibited
uses: the matching loop and the wildcard's declared-set skip them, and the ID-type
count ignores them. There is no explicit "prohibited attribute present" error, so
a wildcard that admits the attribute name still governs it (an earlier attempt to
reject outright wrongly overrode the wildcard).

invalid-instances-accepted 43 to 39, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0, invalid-schemas-accepted unchanged at 69).
An adversarial review confirmed prohibited attributes admitted by a wildcard
(every namespace/processContents variation, local and ref), restriction removal,
and the ID-count interaction are all handled without over-rejection.
2026-06-18 15:28:35 +02:00
91894d5759 fix(schema): reject two ID-typed attributes on one element at instance time; invalid-instances-accepted 45 to 43 (#147)
XSD 1.0 §3.4.6 (cvc-complex-type) allows an element at most one attribute whose
type is xs:ID. A complex type that DECLARES two ID-typed attribute uses is already
a compile-time schema error, but two ID-typed attributes can also reach an element
through an xs:anyAttribute wildcard that admits two ID-typed global attributes
(attZ014), which was accepted.

validateAttributes now counts the present attributes whose effective type is
xs:ID, taking the type from the declared use an attribute matches or the global
declaration a matching wildcard admits (so a restriction of xs:ID counts), and
reports an error when more than one is present. xs:IDREF/xs:ENTITY and a single
ID attribute are unaffected, and a valid instance never carries two, so the check
cannot over-reject.

invalid-instances-accepted 45 to 43, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved.
2026-06-18 15:01:04 +02:00
ca56caf11c fix(schema): default/fixed value for identity-constraint fields; invalid-instances-accepted 50 to 45 (#147)
An identity-constraint (unique/key/keyref) field that selects an absent attribute
or an empty element whose declaration carries a default or fixed value now takes
that value as its identity-tuple component, so two members can collide on the
defaulted value (idG011/idG012 default, idF016/idF017 fixed) where the duplicate
was previously missed.

The default/fixed value constraints on identity-field targets are gathered at
compile time (identityFieldConstraints, mirroring identityFieldTypes) and threaded
through to the identity validator. The substitution is exact about absent versus
present-empty: an @attr field is defaulted only when the attribute is ABSENT (an
empty node-set), never when present with value ""; a "." element field only when
the selected element has no character content. Applying a default only adds an
identity value where the schema declares one, so it can create a genuine
duplicate-key detection but never reject a valid instance.

To keep within the file/type-size and one-type-per-file limits, FieldValue,
XPathQueryCache, and the schema-location hints were extracted to their own files
(behavior-preserving moves).

invalid-instances-accepted 50 to 45, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved. An adversarial
review probed present-empty attributes, element content winning over a default,
fixed values, keyref matching, multi-field keys, and merged-schema defaults, and
found no over-rejection.
2026-06-18 14:47:47 +02:00
75bb89dac7 fix(schema): bar a substitution-group member blocked by the head's type; invalid-instances-accepted 53 to 50 (#147)
A block on a substitution-group head's TYPE (its {prohibited substitutions}), not
only on the head element, bars a member whose type reaches the head's type by the
blocked derivation method. filterSubstitutions consulted only the head element's
own block, so block="restriction" on the head's complex type let a
restriction-derived member substitute.

The substitution-group filter now drops a member when its derivation method to the
head's type is in the union of the head element's {disallowed substitutions} and
the head type's {prohibited substitutions}. The element-level block="substitution"
outright bar stays element-only (a type's prohibited-substitutions never carries
substitution), and a member derived by a method the block does not list still
substitutes.

invalid-instances-accepted 53 to 50, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0). An adversarial review confirmed members
derived by an allowed method, the positive disallowedSubst instances, transitive
chains, and blockDefault interactions all still substitute. (A separate
cross-document cross-namespace substitution-group rejection was confirmed
pre-existing on HEAD, not introduced here.)
2026-06-18 14:02:14 +02:00
b3a2bcfe6a fix(schema): flag a baseless or ancestor complex xsi:type as not derived; invalid-instances-accepted 60 to 53 (#181, #147)
A complex type that records no base of its own derives only from xsd:anyType, so
it cannot validly substitute (cvc-elt.4.3.2.1) for an element whose declared type
is a more specific complex type. The not-derived check required the substitute to
be present in the derivation backbone, so naming the declared type's own complex
ancestor, or an unrelated baseless complex type, via xsi:type was accepted.

notDerivedSubstitutionError now treats a complex substitute as in scope even when
it is absent from the backbone: its non-derivation from a non-ur declared type is
certain (a baseless complex type derives only from anyType, and any real
extension/restriction chain is recorded in the backbone, where derivationMethods
finds it). An atomic substitute must still be in the backbone, so a built-in
stays silent. The declared guard excludes the ur-types, so a baseless complex
substitute under an anyType declaration is admitted; a genuine extension or
restriction substitute is accepted.

invalid-instances-accepted 60 to 53, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved. An adversarial
review exercised deep, cross-namespace, redefine, and element-ref-path derivation
chains plus anyType-declared baseless substitutes, and found no over-rejection.
2026-06-18 13:51:17 +02:00
31a4ebacfb fix(schema): extend the xsi:type not-derived check to atomic substitutes; invalid-instances-accepted 66 to 60 (#181, #147)
cvc-elt.4.3.2.1 requires an xsi:type to name a type derived from the element's
declared type. The not-derived check covered only complex substitutes, so an
xsi:type naming an atomic type unrelated to the declared atomic type, such as a
sibling restriction or the declared type's own ancestor, was accepted.

notDerivedSubstitutionError now admits atomic simple substitutes too (via
isAtomicOrComplex, which excludes list/union, handled separately). It still fires
only when both the substitute and the declared type are recorded in the
namespaced derivation backbone and derivationMethods finds no path between them.
Built-in types and the ur-types are absent from the backbone, so a built-in
substitute or declared type leaves the check silent: a valid built-in derivation
such as xsi:type="xs:int" on an xs:decimal element is never flagged, which was
the source of an earlier false-positive regression. A genuine user restriction
chain stays inside the backbone the whole way down, so a validly derived
substitute is accepted.

invalid-instances-accepted 66 to 60, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved. An adversarial
review probed built-in derivation in both directions, deep and cross-namespace
restriction chains, and sibling/ancestor substitutes, and found no over-rejection.
2026-06-18 13:37:49 +02:00
c80659d80d fix(schema): resolve the declared type through the element-ref chain for the list/union xsi:type check; invalid-instances-accepted 78 to 66 (#181, #147)
The list/union xsi:type derivation check decided whether the declared type admits
a list or union substitute from a single type-table lookup, so it never reached
the concrete type of an element declared by reference (`<xs:element ref="x">`):
such an element's list/union xsi:type was wrongly accepted.

declaredAdmitsAnySimpleType now resolves the declared type through the full
element-ref / type-reference chain (resolvedDeclaredType, the type-returning
companion to resolvedDeclaredTypeName) to the concrete .simple/.complex before
deciding. A list or union xsi:type on a ref'd element whose declaration resolves
to a more specific atomic type is now rejected; the chain is cycle-bounded and
falls through to suppress (under-reject) when it cannot resolve, so a ur-typed,
list/union-typed, or unresolvable declared type still admits the substitute.

invalid-instances-accepted 78 to 66, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved. An adversarial
review exercised ref chains to anySimpleType/anyType/untyped declarations, named
and aliased list/union declarations, a deep alias chain, cross-namespace imported
refs, substitution-group heads, atomic restriction substitutes, and a self-
referential alias cycle, and found no over-rejection.
2026-06-18 13:24:32 +02:00
05a0aa4305 fix(schema): reject a list/union xsi:type on a non-ur-typed element; invalid-instances-accepted 81 to 78 (#181, #147)
A list or union simple type is derived only from xsd:anySimpleType (XSD Part 2),
so an xsi:type naming a list or union can validly stand in (cvc-elt.4.3.2.1) only
for an element whose declared type is a ur-type (anySimpleType/anyType). Naming
one on an element of a more specific atomic or complex type, such as
xsi:type="myList" on an xs:int element, is not a valid derivation and is now
rejected.

listUnionSubstitutionError flags the case; declaredAdmitsAnySimpleType suppresses
it when the declared type is a ur-type, is itself a list or union (whose inline
identity cannot be matched), or is an unresolvable reference, so the check biases
to under-reject and never over-rejects. A genuinely derived restriction
substitute, a list/union under an anySimpleType declaration, and a restriction of
a list (which keeps list variety) are all left accepted.

This widening became safe once the derivation backbone was keyed by namespaced
identity (#186); the conservative atomic and built-in not-derived cases remain
under-rejected pending the full type-identity relation.

invalid-instances-accepted 81 to 78, no false positive (valid-instances-rejected
and valid-schemas-rejected held at 0), no other bucket moved. An adversarial
review exercised a list-restriction substitute, a union declared type, ur-type
and typeReference-chain declarations, and cross-namespace list types, and found
no over-rejection.
2026-06-18 12:55:46 +02:00
1fe670581f feat(schema): key the instance derivation/substitution backbone by namespaced identity; re-land blockDefault; invalid-instances-accepted 89 to 81 (#186, #147)
The instance-validity derivation and substitution tables were keyed by bare
local name, so two same-local-name types or elements in different namespaces
collided. In multi-document validation this let one schema's block/derivation
facts shadow another's, and it blocked honouring blockDefault (whose re-land
widened that collision into an over-rejection).

Key the instance subsystem by namespaced identity {ns}local instead. The shared
DerivationTables keeps its bare-keyed fields for the schema-validity machinery
(Particle-Valid-Restriction and the consistency rules, which resolve QNames to
bare names and walk the same backbone) and gains parallel ns* fields built in
the same pass, resolving each container's components through its target
namespace and prefix bindings. Only the instance consumers
(blockedSubstitutionError, notDerivedSubstitutionError, abstractTypeError, the
substitution-group closure, and referenceParticle) read the ns* tables, via a
derivationKey(fromReference:) that reduces a type:{ns}local reference, a bare
name, or an element ref to a {ns}local backbone key. A key miss stays permissive
(no constraint, substitution allowed), so the re-keying cannot over-reject.

With the namespaced backbone in place, blockDefault is re-landed FP-free: a
schema's blockDefault supplies the {disallowed substitutions} of every element
and complex type without an explicit block (own block overrides; block="" clears;
intersected with the methods each component admits), so blockDefault="extension"
bars an extension substitution and "#all" bars all, without one namespace's
defaults shadowing another's.

The pure re-keying preserves single-document behaviour exactly (the corpus is
overwhelmingly single-namespace, where {ns}local and bare coincide): all four
XSTS buckets held at vsr=0 / isa=69 / vir=0 / ivi=89 after re-keying alone.
blockDefault then drove invalid-instances-accepted 89 to 81, no false positive
(valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket
moved. An adversarial review exercised same- and cross-namespace substitutions,
no-targetNamespace keys, simple-type restriction, transitive substitution groups,
and blockDefault override/over-block cases, and the prior multi-document
cross-namespace blockDefault over-rejection is now accepted while a same-namespace
blocked substitution is still rejected (precise, not merely permissive).
2026-06-18 12:41:18 +02:00
0d3fbf1b0a fix(schema): intersect a complex type's attribute wildcard sources; invalid-instances-accepted 94 to 89 (#147)
A complex type's effective {attribute wildcard} is the intersection of the
anyAttribute wildcards it draws from its own declaration and each referenced
attribute group (XSD 1.0 cos-aw-intersect), not their union. An attribute is
admitted only if every source admits it, so two attribute groups constraining
disjoint namespaces admit nothing and an attribute in either is rejected.

attributeWildcard(under:_:visited:) combined the sources with Wildcard.union,
which let through an attribute that only one source allowed. It now uses a new
Wildcard.intersection covering the namespace cases (##any identity, enumerated
set intersection, enumerated-with-##other exclusion via exclude(_:otherThan:)).
A type with a single anyAttribute source is unchanged (intersection with the
nil identity is the lone wildcard), and a complexContent extension's union with
its base wildcard (XSDComplexContent) is untouched. The inexpressible
##other/##other-different-target and mismatched processContents cases resolve to
the less restrictive result, so the change never over-rejects.

XSTS invalid-instances-accepted 94 to 89, no false positive (valid-instances-
rejected and valid-schemas-rejected held at 0 across the whole corpus), no other
bucket moved. An adversarial review exercised nested groups, ##other/##local and
cross-import namespace cases, processContents, extension-vs-restriction, and
single-source invariance, and found no new over-rejection.
2026-06-18 11:21:32 +02:00
cadfb0d871 fix(schema): compare identity-constraint fields across the decimal value space; invalid-instances-accepted 95 to 94 (#147)
An identity constraint (unique/key/keyref) compares its field values in their
value space. Two fields typed by different members of the decimal-derived
numeric family denote the same value when their numbers are equal, so a
decimal "1" and an unsignedByte "1" (supplied here via xsi:type) collide for a
unique. FieldValue equality previously required the two declared base types to
match exactly and reported them unequal when they differed, so the duplicate
went undetected.

Add BuiltinType.isDecimalDerived (decimal plus the integer family, which share
one value space) and compare those values as DecimalValue. The unification is
confined to that family: float and double are distinct primitive spaces and a
number is never equal to a string, so no valid instance is newly rejected.

XSTS invalid-instances-accepted 95 to 94, no false positive (valid-instances-
rejected and valid-schemas-rejected held at 0 across the whole corpus), no
other bucket moved.
2026-06-18 11:01:05 +02:00
94c6950ae3 fix(schema): process xsd:anyType wildcards as lax; invalid-instances-accepted 101 to 95 (#146, #147)
The ur-type xsd:anyType modeled its element and attribute wildcards with
processContents="skip", so a child element or attribute of an untyped element
(or an element typed xs:anyType) was never validated. XSD 1.0 §3.4.7 specifies
those wildcards as "lax": content that has a global declaration is validated
against it, while undeclared content is admitted. This is why an invalid
declared value reached through a wildcard (e.g. a duration-typed attribute with
value "P" matched by <xsd:anyAttribute/>) was accepted.

Set both wildcards to .lax at all three ur-type construction sites and loosen
isUrType to recognise the lax (or skip) shape.

The lax change required making the wildcard lookup of a global element
declaration namespace-exact. A global element is always in the schema target
namespace, so an unqualified instance element must match only a no-namespace
global, which the qualified key element:{}local already covers. The previous
bare-local-name fallback conflated namespaces and would have validated an
unqualified child against a same-named namespaced declaration; it is removed in
the document path and both streaming sites.

XSTS invalid-instances-accepted 101 to 95, no false positive (valid-instances-
rejected and valid-schemas-rejected held at 0; the spec's own errC007 anyType-
lax case validates correctly), no other bucket moved. An adversarial review
re-ran the full suite and found no new over-rejection.
2026-06-18 10:44:33 +02:00
67451055f5 fix(schema): block="substitution" bars members even for an untyped head; invalid-instances-accepted 107 to 101 (#147)
block="substitution" (or a block set containing it, including #all) on a
substitution-group head forbids every member from substituting, regardless of
the head's or members' types. filterSubstitutions gated the whole filter --
including this type-independent substitution ban -- on the head having a type,
so an abstract untyped head (the particlesDc cluster) skipped filtering and its
members still substituted. The substitution ban now applies first and
type-independently; the type-derivation block (extension/restriction) still
needs the head's type, unchanged.

An adversarial review confirmed no false positive: the head's own appearance is
computed independently of the member list (a concrete blocked head still
validates), and the typed-and-substitution-blocked case is provably equivalent
to the prior per-member rejection. Disclosed pre-existing under-rejection (not
introduced here): blockDefault="substitution" is not honored, only an explicit
block on the head.

XSTS: invalid-instances-accepted 107 to 101 (particlesDc004/005/006/008/009 and
a sibling), valid-instances-rejected and valid-schemas-rejected held at 0.
2026-06-18 10:13:45 +02:00
8e873dc929 fix(schema): an xsi:type must be derived from the declared type (cvc-elt.4.3.2.1); invalid-instances-accepted 133 to 107 (#181)
First instance-bucket reduction of the campaign. An instance xsi:type naming a
complex type must be derived from the element's declared type. notDerivedSubstitutionError
flags a complex substitute on a different branch of the hierarchy; previously
only a derived-but-blocked substitution was caught (blockedSubstitutionError
returns nil when there is no derivation path), so a non-derived xsi:type was
silently accepted (the test006 cluster, 32 instances, and more).

Conservatively scoped after a broader first cut took 20 false positives (now
reverted): it fires only when the substitute resolves to a COMPLEX type and both
substitute and declared names are in the derivation backbone with no path between
them. Simple/list/union substitutes, ur-types, and unresolvable references are
left alone -- under-reject, never over-reject. An adversarial review confirmed
the conservative guards eliminate the prior false positives; the one residual is
the pre-existing bare-local-name keying of the derivation backbone (shared with
blockedSubstitutionError), disclosed in the code, corpus-absent.

The three xsi:type override checks (blocked / not-derived / abstract) are folded
into one xsiTypeOverrideError helper to keep validateChild under the complexity limit.

XSTS: invalid-instances-accepted 133 to 107 (26 caught), valid-instances-rejected
and valid-schemas-rejected both held at 0, no other bucket moved.
2026-06-18 10:07:05 +02:00
0994f5dd2a fix(schema): include and redefine require a schemaLocation; invalid-schemas-accepted 71 to 69 (#145)
xs:include and xs:redefine require a schemaLocation attribute (only xs:import's
is optional, being a resolution hint). An include or redefine declared without
it is now rejected in the structure walk.

A required-attribute check with no name resolution or reference walking; it
cannot over-reject (schemaLocation is mandatory on these per the
schema-for-schemas), so the full critic loop is not needed here -- reserved for
the false-positive-prone resolution rules. XSTS: invalid-schemas-accepted 71 to
69 (schB3 and a sibling), valid-schemas-rejected held at 0, no other bucket moved.
2026-06-18 01:33:00 +02:00
7a033d28e6 fix(schema): a redefined schema's targetNamespace must match the redefiner (src-redefine); invalid-schemas-accepted 74 to 71 (#161)
A schema pulled in by xs:redefine must have the same targetNamespace as the
redefining schema, or no targetNamespace (chameleon) -- the same constraint
xs:include already enforced. includeCompositionFindings now also walks
xs:redefine, selecting the loaded redefined schema document (guarding the
container lookup with localName == "schema" so the redefine wrapper, which
carries no targetNamespace, is not matched) and comparing its target namespace
to the redefiner's effective (chameleon-resolved) namespace.

An adversarial review confirmed no false positive: same-namespace, chameleon
(no targetNamespace on the redefined schema), and both-no-namespace redefines
are never flagged; only a present-and-different targetNamespace fires.

XSTS: invalid-schemas-accepted 74 to 71 (schI2/schI5 and a schN cross-namespace
case), valid-schemas-rejected held at 0, no other bucket moved.
2026-06-18 01:25:34 +02:00
24a9299975 docs(xsts): account for the remaining invalid-schemas-accepted tail (74)
Adds a dated accounting to the XSTS deviation worklist: the labelled-suite
ratchet stands at 0/74/0/133 with false positives held at 0 across the campaign.
Partitions the remaining 74 into fixable-but-intricate debt (redefine
restriction, NameAndTypeOK union membership, wildcard UPA, chameleon/cross-ns
composition, heterogeneous particle edges) and irreducible-by-design or disclosed
items (RecurseAsIfGroup spec ambiguities, the deliberate regex-tolerance), with
the root cause and false-positive risk for each so the burndown is driven by
understanding. Records that the adversarial critic loop caught three genuine
false positives this session that the suite and differential both missed.
2026-06-18 01:19:38 +02:00
0f90b3a4d3 fix(schema): generalize redefine self-reference to attributeGroups + bound the walk; invalid-schemas-accepted 75 to 74 (#161)
Extends the redefine self-reference rule (redefineSelfReferenceErrors, moved to
SchemaRedefineSelfReference.swift) to attribute groups: a group or attributeGroup
inside xs:redefine may reference itself at most once (src-redefine.6.1.1 /
7.2.1), and a group self-reference must occur exactly once. Catches a redefined
attributeGroup with two self-references (attgC044).

Fixes a latent false positive an adversarial review found in the group case: the
search now uses a BOUNDED walk (boundedSelfReferenceNodes) that stops at nested
element/complexType scopes, matching the established cycle-rule boundary. The
prior unbounded walk would miscount a recursive group reference inside a nested
element's content (a data-structure recursion, legal) as a redefinition
self-reference. attributeGroup content has no such nesting, so it is unaffected.

XSTS: invalid-schemas-accepted 75 to 74 (attgC044), valid-schemas-rejected held
at 0, no other bucket moved. The validation description broadened accordingly
(configuration pin updated).
2026-06-18 01:15:16 +02:00
6379e5b25a fix(schema): an identity-constraint xpath may not use an undeclared prefix; invalid-schemas-accepted 77 to 75 (#145)
A selector/field xpath in an identity constraint may name elements/attributes
with namespace-qualified name tests; every prefix used must be a declared
xmlns: prefix in scope (the implicit xml prefix excepted). identityXPathErrors,
after the existing syntax check, now resolves each name-test prefix against the
in-scope bindings (namespaceBindingsInScope, which captures the schema root and
every ancestor) and rejects an undeclared one.

An adversarial review confirmed no false positive: a prefix declared on the
schema root, any intermediate ancestor, or the selector/field element resolves
correctly; xml, the child::/attribute:: axis keywords (colon-free), prefix:*,
and inter-token whitespace are all handled.

XSTS: invalid-schemas-accepted 77 to 75 (idI010, idJ011), valid-schemas-rejected
held at 0, no other bucket moved. (Two existing xpath-syntax fixtures used an
undeclared 'ns' prefix to test name-test syntax; the test schema now declares
xmlns:ns so they exercise syntax with a bound prefix.)
2026-06-18 01:01:12 +02:00
880137a955 fix(schema): a redefined group's self-reference must occur exactly once (src-redefine.6.1.2); invalid-schemas-accepted 79 to 77 (#161)
A group inside xs:redefine may reference itself (the group being redefined) at
most once, and that self-reference must have minOccurs = maxOccurs = 1. A
self-reference with minOccurs="0", maxOccurs="unbounded", or a count above 1 is
invalid. redefineGroupSelfReferenceErrors flags it, surfaced by the pre-compile
Validation redefineGroupSelfReferencesAreUnit.

The self-reference is matched by NAMESPACE (referenceNamespace == the redefining
schema's targetNamespace), not local name alone, so a redefined group that
references an imported group merely sharing its local name is correctly left
unconstrained -- the false-positive boundary an adversarial review flagged
before merge, mirroring the namespace guard the sibling redefine/cycle rules use.

XSTS: invalid-schemas-accepted 79 to 77 (schR3, schR4), valid-schemas-rejected
held at 0, no other bucket moved.
2026-06-18 00:52:40 +02:00
c45e1eb856 fix(schema): a local element must have a name or a ref; invalid-schemas-accepted 80 to 79 (#145)
A local element declaration (one not a child of schema) must identify itself
with a name or a ref; an xs:element with neither is invalid. localElementErrors
now flags it, alongside the existing local-element forbidden-attribute checks.

XSTS: invalid-schemas-accepted 80 to 79 (elemP005), valid-schemas-rejected held
at 0, no other bucket moved.
2026-06-18 00:34:50 +02:00
75dd044d93 fix(schema): a local complexType must be anonymous; invalid-schemas-accepted 81 to 80 (#145)
A complexType nested in an element is a local (anonymous) type definition and
may not carry a name attribute; only a top-level complexType (a child of schema,
or a redefine redefinition) is named. localComplexTypeNameErrors flags a named
complexType whose parent is an element, wired into the structure-walk validation.

XSTS: invalid-schemas-accepted 81 to 80 (ctA042), valid-schemas-rejected held
at 0, no other bucket moved.
2026-06-18 00:17:35 +02:00
059763f5e9 refactor(schema): surface facet/pattern validity as a Validation, not a parse-time report (#161)
The convergence audit for "validators everywhere" found the one remaining XSD
schema-validity verdict reported outside the Validator framework: facet
definition validity and pattern syntax validity were reported straight to the
diagnostics collector during simple-type compilation (validation folded into
parsing, which validation-rules.md forbids).

These checks genuinely need the resolved base type, computed during compilation,
so the finding is gathered into a context finding sink (XSDContext
.facetFindingSink) during type resolution and surfaced by the named post-compile
Validation simpleTypeFacetsAreValid -- located findings preserved, now pinned in
the configuration test and removable by identity like every other rule.

Re-sweep confirms the XSD path has converged: the only remaining throws are
notASchema (a not-a-schema precondition) and inconsistent (the framework's own
surfacing throw); no validity verdict bypasses the framework. No bucket change
(invalid-schemas-accepted 81, valid-schemas-rejected 0); full suite + fuzz pass.
2026-06-18 00:02:20 +02:00
4def6a2c07 refactor(schema): redefine self-derivation as a Validation; drop the redundant all-group throw (#161)
Continues incorporating every schema-validity check into the Validator framework
(Rules/universal/validation-rules.md) rather than ad-hoc parse-pipeline throws.

- checkRedefine (src-redefine.5: a redefined type restricts or extends itself)
  becomes the pre-compile Validation redefinitionsDeriveFromThemselves;
  redefineDerivationErrors returns findings instead of throwing.

- checkAllGroups is removed entirely: every check it made is already produced by
  Validations -- "a member must be an element" by schemaStructureValid
  (allowedChildren[all]), and the occurrence limits by allGroupLimitedErrors
  (cos-all-limited.2). Verified redundant and not load-bearing: an empirical
  probe with the calls removed left invalid-schemas-accepted at 81 unchanged,
  the fuzz suite's "compile and validation never crash or hang" still passes
  (the NFA builder's own guards hold), and false positives stayed 0.

compileRule is now internal so composition Validations can live in their own
file (SchemaCompositionValidations.swift). Both new pre-compile rules are pinned
in ValidationConfigurationTests. The pre-existing public SchemaError.invalidAllGroup
case is kept (released API) though no longer thrown. No bucket change
(invalid-schemas-accepted 81, valid-schemas-rejected 0); full suite + fuzz pass.
2026-06-17 23:49:30 +02:00
d3a0b60d67 refactor(schema): express the src-resolve check as a Validation, not a pipeline throw (#161)
Per the OpenAPIKit validation idiom (Rules/universal/validation-rules.md), every
schema-validity check should run through the Validator framework as a described,
composable Validation rather than an ad-hoc throw. The src-resolve check (a
resolved include/import/redefine schemaLocation must be a well-formed schema)
was a parse-pipeline throw; it is now the pre-compile Validation
referencedSchemasResolveToSchemas.

failedSchemaReferences (formerly the throwing checkSchemaLocations) returns the
resolved-but-not-a-schema locations; the pipeline records them on XSDContext and
the validation reports one finding each. Same rejection behaviour (the finding
makes the schema invalid), now located in the framework, pinned in the
configuration test. Removed the now-unused SchemaError.invalidSchemaReference
case (added earlier this branch, never released).

No bucket change (invalid-schemas-accepted stays 81, valid-schemas-rejected 0);
full suite and fuzz pass (report-and-continue does not crash downstream).
2026-06-17 23:34:33 +02:00
a444604a80 fix(schema): a redefined type must derive from itself, not a same-named foreign type (src-redefine.5); invalid-schemas-accepted 84 to 81 (#161)
A type inside xs:redefine must restrict or extend the type it redefines, which
lives in the redefining schema's own target namespace. checkRedefine compared
local names only (derivationInfo strips the prefix), so a base like foo:b-ct
-- an imported type sharing the redefined type's local name -- was treated as
self and accepted. Now the base's namespace is resolved: a base bound to an
explicit namespace other than the redefining schema's target namespace is not
self and is rejected (src-redefine.5).

redefineBaseIsForeign (SchemaImportNamespace.swift) leaves a base with no
resolvable namespace alone, so an unprefixed or own-namespace self-reference
never over-rejects -- the false-positive boundary, the same namespace-aware
fix applied across the campaign.

XSTS: invalid-schemas-accepted 84 to 81 (schQ2/schQ4/schP3),
valid-schemas-rejected held at 0, no other bucket moved. Differential:
agree-valid unchanged at 3161, PureXML-stricter unchanged at 87 over 9618
schemas. Third step of the cross-document composition campaign (#161).
2026-06-17 23:00:07 +02:00
c418147d30 fix(schema): an import namespace must match the imported targetNamespace (src-import.3); invalid-schemas-accepted 87 to 84 (#161)
src-import.3.1/3.2: an xs:import's namespace attribute must equal the
targetNamespace of the schema document it resolves to (both present and equal,
or both absent). Importing a no-namespace document under a declared namespace,
or one whose target namespace differs, is now rejected.

importNamespaceErrors (new SchemaImportNamespace.swift) builds a resolved
schemaLocation -> targetNamespace map from the loaded containers and compares
each import node's namespace to it. Checked only when the schemaLocation
resolved to a loaded schema (an unresolved import is the processor's choice,
not an error) -- the false-positive boundary; imports keep their own target
namespace so no chameleon adjustment applies.

XSTS: invalid-schemas-accepted 87 to 84 (schF6/schG10/schG13),
valid-schemas-rejected held at 0, no other bucket moved. Differential:
agree-valid unchanged at 3161, PureXML-stricter unchanged at 87 over 9618
schemas. Second step of the cross-document composition campaign (#161).
2026-06-17 22:47:24 +02:00
7f2943cf5c fix(schema): a resolved schemaLocation must be a valid schema (src-resolve); invalid-schemas-accepted 95 to 87 (#161)
When an xs:include / xs:import / xs:redefine schemaLocation IS resolved (the
schema loader returns content for it), that content must be a well-formed
schema document. Not-well-formed XML, or well-formed XML whose root is not
xs:schema, now makes the composing schema invalid (SchemaError
.invalidSchemaReference). collectContainers previously loaded externals with
`try? parseTree` and silently dropped a malformed one, accepting the schema.

A location the loader does NOT resolve (returns nil) is not an error, since
resolution is the processor's choice (src-resolve) -- so a self-contained
schema, or one whose externals are simply unavailable, is unaffected. This is
the false-positive boundary: fire only when the loader returned content that
is not a schema.

XSTS: invalid-schemas-accepted 95 to 87 (eight caught), valid-schemas-rejected
held at 0, no other bucket moved. Differential: agree-valid unchanged at 3161,
PureXML-stricter unchanged at 87 over 9618 schemas (zero new over-rejection).
First step of the cross-document composition campaign (#161).
2026-06-17 22:34:21 +02:00
615f668fc2 fix(schema): an attribute's type must be a simple type (au-props-correct); invalid-schemas-accepted 96 to 95 (#145)
The {type definition} of an attribute is a simple type (au-props-correct /
src-attribute). An attribute whose type resolves to a complex type, or to the
complex ur-type xs:anyType, is now rejected; a built-in or user simple type,
an inline simpleType, or no type (anySimpleType) stays valid. A locally-
declared type takes precedence over the built-in reading, consistent with the
src-ct rules.

Strictly zero false positive: an attribute can never carry a complex type, so
no valid schema is affected. XSTS: invalid-schemas-accepted 96 to 95 (attD002),
valid-schemas-rejected held at 0, no other bucket moved. Differential:
PureXML-stricter unchanged at 87 over 9618 schemas.
2026-06-17 22:12:01 +02:00
a2047956cb fix(schema): enforce element value-constraint content type (cos-valid-default); invalid-schemas-accepted 99 to 96 (#145)
An element with a default or fixed value constraint must have a content type
that admits character data: a simple type, or mixed content (cos-valid-default
/ e-props-correct). An element whose type is, or whose inline complex type has,
element-only or empty content may not carry a value constraint.

elementValueConstraintContentErrors flags only a content type that is certainly
element-only or empty; a simple type, mixed content, xs:anyType (mixed), an
untyped element (anyType), a complexContent-derived type (mixedness may be
inherited), and a foreign or unresolved type are all left alone, so it cannot
over-reject.

XSTS: invalid-schemas-accepted 99 to 96 (elemG003/elemG004/elemD004),
valid-schemas-rejected held at 0, no other bucket moved. Differential:
PureXML-stricter unchanged at 87 over 9618 schemas.
2026-06-17 22:00:23 +02:00
9c57dedb64 fix(schema): enforce derivation base-type kind (src-ct.1/.2); invalid-schemas-accepted 104 to 99 (#145)
A content-model derivation's base must match its content-model keyword
(XSD 1.0 Structures 3.4.6):

- src-ct.1: a complexContent restriction/extension base must be a complex
  type. A simpleType or a built-in simple type (xs:string) is rejected; the
  ur-type xs:anyType is complex and remains a valid base.

- src-ct.2 (extension): a simpleContent extension base must be a simple type
  or a complex type with simple content. A complex type with element-only,
  mixed, or empty content, including xs:anyType, is rejected.

A locally-declared type takes precedence over the built-in reading, so a
schema that targets the XSD namespace and extends its own complex types
(the schema-for-schemas, e.g. xs:openAttrs) is not rejected. This caught a
self-inflicted false positive during development; the fix restores it.

XSTS: invalid-schemas-accepted 104 to 99 (ctE003/ctE004/ctK002/ctJ002/
ctJ003), valid-schemas-rejected held at 0, no other bucket moved. The
differential corroborates: PureXML-stricter unchanged at 87 over 9618
schemas (zero new over-rejection).
2026-06-17 21:49:12 +02:00
606bc35a45 fix(schema): enforce identity-constraint content model; invalid-schemas-accepted 110 to 104 (#145)
An xs:unique / xs:key / xs:keyref content model is (annotation?, selector,
field+): exactly one selector, then one or more fields, in that order. The
structure walk previously checked only that the children were drawn from
{annotation, selector, field}, not their cardinality or order, so a field
before the selector, a second selector, or a missing field was accepted.

identityConstraintContentErrors enforces it, wired into the per-component
structural dispatch. XSTS: invalid-schemas-accepted 110 to 104 (six caught:
idA044/046, idB044/046, idC047/049), valid-schemas-rejected held at 0, no
other bucket moved.
2026-06-17 21:04:16 +02:00
b391db6a74 fix(schema): enforce model-group occurrence ranges; invalid-schemas-accepted 130 to 110 (#145)
Enforce the two occurrence-range constraints on model groups that PureXML
was not checking:

- p-props-correct.1: a particle's minOccurs may not exceed its maxOccurs,
  both defaulting to 1 when the attribute is absent. occurrenceOrderErrors
  previously fired only when BOTH attributes were present, so it missed
  minOccurs="2" with a defaulted maxOccurs (2 > 1) and maxOccurs="0" with a
  defaulted minOccurs (1 > 0). It now orders against the effective defaults.

- cos-all-limited.2: an all group's maxOccurs must be 1 and its minOccurs
  0 or 1, and every particle it contains is limited to maxOccurs 0 or 1.

Both are zero-false-positive rules (no valid schema violates them). XSTS:
invalid-schemas-accepted 130 to 110 (20 caught), valid-schemas-rejected
held at 0, no other bucket moved. The differential harness corroborates:
agree-invalid 1751 to 1767, PureXML-stricter unchanged at 87 (no new
over-rejection across 9618 schemas).
2026-06-17 20:55:22 +02:00
bbd616b3a4 test(diff): add opt-in differential schema-validity harness vs libxml2 (#145)
Production-readiness stopper #3: characterise correctness against an independent
reference. For each .xsd under XSTS_ROOT, compares PureXML's compile verdict with
libxml2 (xmllint --schema) and writes disagreements to /tmp/xsts-differential.txt.

Over 9618 schemas: agree on 4912 (3161 valid, 1751 invalid). The 87 where PureXML
is stricter are triaged as PureXML being more correct (libxml2 omits facet
consistency, EDC, whiteSpace restriction, au-props, notation), independent
corroboration that PureXML does not over-reject. The 117 where libxml2 is stricter
are a false-negative triage queue (stopper #2). Opt-in; xmllint and the corpus are
test-harness-only, not package dependencies.
2026-06-17 20:15:17 +02:00
d92b9149df fix(schema): eliminate the last false positive (particlesZ001); false-positives = 0 (#148)
Read element-vs-choice Particle Valid (Restriction) through effective total
range: an element validly restricts a base choice when its leaf is a
NameAndTypeOK restriction of an element the choice admits and its effective
occurrence range is within the choice's. This fixes particlesZ001 (the
RecurseAsIfGroup rule its own test calls ambiguous; W3C reads it valid) while
keeping particlesM002/003 valid and particlesL rejected.

Named, bounded under-rejection (exact in-branch sequencing unmodelled), the
production-sanctioned direction: invalid-schemas-accepted 128->130,
invalid-instances-accepted 132->133 (particlesZ001.i, a masked gap now exposed).

XSTS valid-schemas-rejected 1->0. With valid-instances-rejected already 0,
false positives are now 0 in both buckets (production stopper #1).
2026-06-17 19:56:57 +02:00
a23757e958 test(xsts): exclude the \d=\p{Nd} spec-divergent instances; valid-instances-rejected 0 (#146)
reS17/reS38/reZ004v assert \d matches Ethiopic digits (category No), contrary to
the normative \d = \p{Nd} (XSD Datatypes Appendix F). Verified our \d matches Nd
(incl. Khmer U+17E0) and rejects Ethiopic No, as conformant processors do.
Excluded as a named, bounded spec divergence rather than corrupting \d.

XSTS valid-instances-rejected 3 to 0; the false-positive metric now reflects
spec correctness on the instance side (one schema, particlesZ001, remains).
2026-06-17 19:34:49 +02:00
ceca006c45 fix(schema): a restriction's attribute wildcard must subset the base's (#145)
cos-ct-restricts.4: a base type with no attribute wildcard admits none (so a
restriction may not introduce one), and a wider namespace constraint such as
##any over ##other, or a weakened processContents, is not a subset. Reuses the
wildcard subset check (ParticleRestriction.narrows, now internal).

XSTS invalid-schemas-accepted 131 to 128, no other bucket moved.
2026-06-17 18:46:04 +02:00
c029505c3e fix(schema): a restriction may not add an attribute outside the base wildcard (#145)
An attribute a complexContent restriction declares with no matching base
attribute use must be admitted by the base type's anyAttribute wildcard
(cos-ct-restricts.3); e.g. a no-namespace attribute is not admitted by a
##other wildcard. A prohibited declaration still validly removes an attribute.

XSTS invalid-schemas-accepted 132 to 131, no other bucket moved.
2026-06-17 18:31:40 +02:00
f61e7d0613 fix(schema): reject an empty-string namespace value (#145)
A namespace name must be a non-empty anyURI, so <schema targetNamespace=""> and
<import namespace=""> are invalid. Omitting the attribute (no target namespace,
or a no-namespace import) stays valid.

XSTS invalid-schemas-accepted 134 to 132, no other bucket moved.
2026-06-17 18:07:11 +02:00
30421073a1 fix(schema): a wildcard restriction may not weaken processContents (#145)
A restricting xs:any must have processContents at least as strong as the base
wildcard's (strict > lax > skip; Wildcard Subset, XSD 1.0 sec 3.10.6), in
addition to narrowing the namespace constraint.

XSTS invalid-schemas-accepted 137 to 134, no other bucket moved.
2026-06-17 17:56:47 +02:00
903345e764 fix(schema): a simpleContent restriction's base must be a complex type (#145)
Restricting a built-in or user simple type through <simpleContent><restriction>
is invalid (cos-ct-restricts.1 / src-ct.2): a complex type with simple content
is built by extending a simple type, not by restricting one. A unit-test
fixture that restricted xs:string directly was spec-invalid and now restricts a
complexType with simple content instead.

XSTS invalid-schemas-accepted 142 to 137, no other bucket moved.
2026-06-17 17:37:55 +02:00
bdd9e96634 fix(schema): enforce NameAndTypeOK nillable clause in particle restriction (#145)
A restricting element may not be nillable unless the base element it restricts
is also nillable. The element particle (Term.element) now carries a nillable
flag, populated from the element's nillable attribute.

XSTS invalid-schemas-accepted 144 to 142, no other bucket moved.
2026-06-17 17:25:10 +02:00
ef28eac926 fix(schema): enforce NameAndTypeOK fixed-value clause in particle restriction (#145)
When the base element has a fixed value constraint, a restricting element must
also be fixed to the same value, compared in the base type's value space so a
value-equal pair written in different lexical forms is not wrongly flagged.

XSTS invalid-schemas-accepted 148 to 144, no other bucket moved.
2026-06-17 17:08:55 +02:00
d43d1bb1bb fix(schema): subset-check anonymous complex-type restrictions (#145)
Particle Valid (Restriction) was keyed by type name, so an inline complexType
deriving by complexContent restriction was never checked against its base. Such
anonymous restrictions are now compiled and run through the same subset
algorithm as named types.

XSTS invalid-schemas-accepted 163 to 148, no other bucket moved.
2026-06-17 17:02:20 +02:00
ee5d180ba3 fix(schema): enforce the NameAndTypeOK block-superset rule (#145)
Particle Valid (Restriction) now requires a restricting element's {disallowed
substitutions} to be a superset of the base element's: a restriction may block
more substitutions, never fewer. The element particle (Term.element) carries a
block set, populated from the element's block attribute or the schema's
blockDefault.

XSTS invalid-schemas-accepted 179 to 163, no other bucket moved.
2026-06-17 16:45:35 +02:00
c3876a261f fix(schema): check RecurseAsIfGroup occurrence in particle restriction (#145)
An element restricting a base model group is treated as a synthetic group with
minOccurs=maxOccurs=1 holding the element (XSD 1.0 sec 3.9.6), so its {1,1}
occurrence must be within the base group's range: a base group that must occur
two or more times cannot be restricted to a single element. The element's own
occurrence is still matched against the base branch it maps to, so an optional
element restricting a once-or-more base group stays valid.

XSTS invalid-schemas-accepted 181 to 179, no other bucket moved.
2026-06-17 16:34:09 +02:00
817d3e46df chore(test): condense the XSTS baseline rationale log
The per-iteration invalid-schemas rationale had grown to the 400-line file
limit and blocked further baseline updates. Each rule's exact bucket delta is
recorded per-entry in CHANGELOG.md, so the log is condensed to a pointer plus
the recent steps and the residual-tail note. No behavior change.
2026-06-17 16:21:12 +02:00
97817ab611 fix(schema): reject a redefine that redefines a component twice (#145)
A redefine may redefine a given component at most once (src-redefine.7.2.2);
two redefinitions of the same kind and name in one redefine are invalid.

XSTS invalid-schemas-accepted 182 to 181, no other bucket moved.
2026-06-17 16:17:00 +02:00
4551508dd9 fix(schema): a redefine must name components that exist in the redefined schema (#145)
Once the redefined document is loaded through a schemaLoader, every component a
redefine names (complexType, simpleType, group, attributeGroup) must have a
same-kind component of the same name in the redefined schema (src-redefine.6 /
7.2.1). A redefinition of a component that does not exist there is rejected; a
stand-alone schema (target not loaded) is unaffected.

XSTS invalid-schemas-accepted 183 to 182, no other bucket moved.
2026-06-17 16:10:57 +02:00
379a9ccd8c fix(schema): validate value constraints against inline types (#145)
An element or attribute default/fixed value is now validated against an inline
type (an inline simpleType, or an inline complex type with simpleContent) the
same way it already was against a named type reference. Validated against the
simpleContent base's value space, so only a clear value-space mismatch (a
non-numeric default on a decimal-based type) is flagged.

XSTS invalid-schemas-accepted 185 to 183, no other bucket moved.
2026-06-17 15:45:06 +02:00
4460508924 fix(schema): inherit finalDefault as a complex type's final (#145)
A complex type without its own final attribute now inherits the schema's
finalDefault as its {final}, so finalDefault=#all or restriction/extension
blocks a complexContent restriction or extension derived from it. An explicit
final= overrides the default to empty. Complex types previously honoured only
an explicit final (elements already inherited finalDefault).

XSTS invalid-schemas-accepted 189 to 185, no other bucket moved.
2026-06-17 15:31:48 +02:00
96373cd6a3 fix(schema): forbid global-only properties on a local element (#145)
A local element declaration (an element particle, not a direct child of schema)
may not carry abstract, final, or substitutionGroup, which belong to the
top-level element form only. Their presence is invalid at any value, so
abstract=false on a local element is rejected as well as abstract=true.

XSTS invalid-schemas-accepted 194 to 189, no other bucket moved.
2026-06-17 15:18:33 +02:00
fd97faf291 fix(schema): require complexContent extension mixed to agree with its base (#145)
A complexContent extension that explicitly states mixed must match its base
type's mixedness (cos-ct-extends.1.4.3.2.2.1): a mixed extension extends only a
mixed base, an element-only extension only an element-only base. An extension
that omits mixed inherits the base's setting and stays valid, so only an
explicit conflict is rejected (ctZ012b, which inherits, is not flagged).

XSTS invalid-schemas-accepted 197 to 194, no other bucket moved.
2026-06-17 15:11:13 +02:00
b0479fd329 fix(schema): require a content derivation in simpleContent/complexContent (#145)
A complexContent or simpleContent's content model requires exactly one
restriction or extension. An empty <complexContent/> declares no derivation
and is now rejected as a structural error.

XSTS invalid-schemas-accepted 200 to 197, no other bucket moved.
2026-06-17 14:58:58 +02:00
947278bb56 fix(regex): reject a malformed \p{...} property name in a pattern facet (#145)
A \p{...}/\P{...} property name that can be no XSD charProp under any
repertoire (empty, the bare prefix Is, a non-block name carrying a non-letter,
or Is followed by a non-block character) is now a schema-validity error,
distinct from a well-formed but unrecognised category or block name, which
stays a lenient engine limitation.

XSTS invalid-schemas-accepted 203 to 200, no other bucket moved.
2026-06-17 14:53:32 +02:00
7e30526b2c fix(schema): reject attribute ref+form and an attribute named xmlns (#145)
An attribute ref is a use of a global declaration, so it may not also carry
form (src-attribute.3.2). And no attribute declaration may be named xmlns,
which is reserved for namespace declarations (no-xmlns).

XSTS invalid-schemas-accepted 207 to 203, no other bucket moved.
2026-06-17 14:39:31 +02:00
d402732d23 fix(schema): hold an attributeGroup reference to (annotation?) content (#145)
A referencing attributeGroup (one with a ref) names a definition elsewhere; its
own content model is (annotation?), so it may not also declare attributes,
nested attributeGroup references, or an anyAttribute. Only a defining
attributeGroup (with a name) carries those.

XSTS invalid-schemas-accepted 210 to 207, no other bucket moved.
2026-06-17 14:27:21 +02:00
709d23cc34 fix(schema): include notation names in component-name uniqueness (#145)
A notation declaration occupies its own symbol space, so two notations sharing
a name and target namespace are a duplicate just as two global types or
elements would be. Notations were omitted from the uniqueness check.

XSTS invalid-schemas-accepted 211 to 210, no other bucket moved.
2026-06-17 14:21:57 +02:00
616ec4876a fix(schema): hold xsd:notation to its (annotation?) content model (#145)
A notation declaration's content model is (annotation?): it may carry only an
optional annotation child. Any other element child, or any non-whitespace
character content, is now rejected.

XSTS invalid-schemas-accepted 214 to 211, no other bucket moved.
2026-06-17 14:17:27 +02:00
d2438a957a fix(schema): require a list or union to declare its member types (#145)
A list simple type must draw its item type from exactly one of an itemType
attribute or an inline simpleType child; neither, or both, is invalid. A union
must declare at least one member type through a memberTypes attribute or an
inline simpleType child; an empty union declares none (src-simple-type.2/.3).

XSTS invalid-schemas-accepted 215 to 214, no other bucket moved.
2026-06-17 14:11:26 +02:00
8420c7e2aa fix(schema): inherit finalDefault as a simple type's final (#145)
A simple type that declares no `final` of its own takes the schema's
`finalDefault` as its `{final}` property (XSD 1.0 Schemas section 3.14.2). The
list and union derivation checks read the `final` attribute directly but
ignored `finalDefault`, so `finalDefault="list"` or `"union"` did not block a
list item type or union member type and the schema was wrongly accepted.

The final map now falls back to `finalDefault` for a type without its own
`final`; an explicit `final=""` still overrides the default back to no
restriction, and a built-in item or member type stays unaffected (it is matched
only when the reference resolves to the schema's own namespace).

Caught two invalid schemas: XSTS invalid-schemas-accepted 217 -> 215, no other
bucket moved.
2026-06-17 13:59:19 +02:00
50c6fb9b1c fix(regex): reject a pattern facet whose regex ends mid-token (#145)
A `pattern` facet's value must be a valid XSD regular expression. Two
truncation cases were compiled with a non-rejecting error and the schema was
wrongly accepted:

- a trailing `\` with no character to escape (`a\`), and
- a `[` character class the expression ends before closing with `]`
  (`a[`, `[a[:xyz:`).

Both are unambiguously invalid, never an engine limitation on an otherwise
valid pattern, so they now raise the dedicated `RegexError.incompleteEscape`
and `RegexError.unterminatedClass` and `rejectsPattern` treats them as
schema-validity errors. A completed escape (`\[`, `\\`), a closed class, and a
class containing an escaped `]` or a subtraction stay valid.

Caught thirteen invalid schemas: XSTS invalid-schemas-accepted 230 -> 217, no
other bucket moved.
2026-06-17 13:53:41 +02:00
c80a8e06c4 fix(schema): reject an attribute reference whose fixed value contradicts its global (#145)
An attribute use that references a global attribute carrying a `fixed` value
constraint may not contradict it (au-props-correct.2): a `fixed` on the
reference whose value differs from the global's, or a `default` on a reference
to a fixed attribute, is invalid. A reference that omits its own value
constraint inherits the global's and stays valid.

The two `fixed` values are compared in the attribute type's value space, so a
value-equal pair written in different lexical forms (`01` for `1`) is not
flagged. The global is resolved by local name and only checked once its own
namespace matches the reference's resolved namespace, so a same-local-name
attribute imported from another namespace cannot surface as the wrong global
and contradict a valid reference.

Caught two invalid schemas: XSTS invalid-schemas-accepted 232 -> 230, no other
bucket moved.
2026-06-17 13:43:42 +02:00
a96e5ce3ac fix(schema): reject extending a non-empty all-group base with element content (#145)
Extending a complexType whose whole content is a non-empty xs:all group, via
complexContent extension that adds element content, nests that all in the
sequence joining base and extension content, which cos-all-limited forbids
(an all group must be the whole content model). Such schemas were accepted.

This is the symmetric companion to the existing rule (an all group in the
extension over a base with content); both now live in one pass. An empty-all
base contributes no content, and an attribute-only extension adds none, so
both stay valid.

XSTS invalid-schemas-accepted 233 to 232, no other bucket moved.
2026-06-17 13:23:26 +02:00
ac242a980b fix(schema): reject complexContent extension adding element content over a simpleContent base (#145)
A complexContent extension that adds element content (a non-empty model
group) produces an element-only/mixed content type, so its base must have
complex or empty content, not simpleContent (cos-ct-extends.1.4.2.2). Such
schemas were accepted.

The check requires the extension to add actual element content (an element or
wildcard, not an empty group), so an attribute-only complexContent extension
of a simpleContent base stays valid (the complexContent-around-simpleContent
idiom, e.g. particlesZ031).

XSTS invalid-schemas-accepted 235 to 233, no other bucket moved.
2026-06-17 13:02:37 +02:00
39649a1940 fix(schema): reject named-group compositor occurrence and XSD-namespace attributes (#145)
Two schema-for-schemas structural constraints were not enforced, so the
malformed schemas were accepted:

- The compositor of a top-level named xs:group (all/choice/sequence) may not
  carry minOccurs/maxOccurs; those belong on a group reference, not the
  definition.
- An attribute in the XML Schema namespace (e.g. xsd:type) is never valid on a
  schema element, since the vocabulary defines no namespaced attributes. A
  foreign attribute in any other namespace stays the author's own.

XSTS invalid-schemas-accepted 244 to 235, no other bucket moved. (A third
candidate, form on a top-level declaration, was already enforced by the
existing top-level attribute rule, so no new check was needed.)
2026-06-17 11:08:33 +02:00
b4c1285d8e fix(regex): reject malformed pattern facets as schema-invalid (#145)
A pattern facet with an unambiguously malformed regular expression was
compiled leniently and the schema accepted. Three such constructs are now
rejected: a reversed character-class range ([b-a]), an empty class ([]),
and a reversed bounded quantifier ({37,17}). The parser raises distinct
RegexError cases (reversedRange, emptyClass, reversedQuantifier) that the
schema-validity check treats as invalid.

Constructs the engine merely does not support (an untabulated \p{Is...}
block, the lenient {,m} form) stay non-rejecting, so a valid pattern is
never refused. XSTS invalid-schemas-accepted 262 to 244, no other bucket
moved; the 27 regex unit tests still pass.
2026-06-17 10:49:09 +02:00
fe8a6e8d6e fix(schema): resolve identity attribute-field types for value-space keys (#147)
An identity-constraint attribute field's type was resolved only among the
constraint host's descendant elements, so when the selector targets a global
element referenced from the host (e.g. unique over `.//uid` with field
`@val`), the field type was unknown and values compared lexically. Two keys
equal in value space but not lexically (3.0 and 3 for xsd:decimal) were then
treated as distinct and the duplicate was accepted.

The attribute type now resolves through the selector's target global element,
so values compare in their value space. XSTS invalid-instances-accepted
133 to 132 (idF010), no other bucket moved.
2026-06-17 10:34:19 +02:00
39a91c258e fix(schema): resolve identity-constraint XPath prefixes against the schema (#147)
Identity-constraint selector and field XPath prefixes were resolved against
the instance document's namespace bindings, so a constraint declared with
xmlns:v="..." and a selector like .//v:vehicle matched nothing when the
instance used a default namespace instead of the v prefix. The selector then
selected no nodes, no key tuples were collected, and duplicate-key and
keyref violations went undetected.

Prefixes now resolve against the namespace bindings in scope where the
constraint is declared in the schema (XSD Part 1, Identity-constraint
Definition), captured at parse time onto IdentityConstraint. A QName field
value still compares using the instance's bindings, so QName keys are
unaffected.

XSTS invalid-instances-accepted 143 to 133: the idc004/5/6 namespaced
key/keyref cluster and namespace-resolution cases (idZ010 and kin), no other
bucket moved.
2026-06-17 00:03:13 +02:00
9769f022dd fix(schema): enforce identity-constraint field cardinality (#147)
An identity-constraint field must evaluate to a node-set with at most one
member (cvc-identity-constraint.3). The validator built a value tuple per
target without checking how many nodes each field selected, so an instance
whose unique/key/keyref field matched several nodes was accepted.

fieldCardinalityError now rejects a target whose field selects more than one
node, in both the key/unique and keyref paths. XSTS invalid-instances-accepted
4 → fewer (147 to 143), no other bucket moved.
2026-06-16 23:41:30 +02:00
4df9eda52e feat(schema): compile import-chained documents as a union (#161)
Schema documents that form an import chain are now compiled as a single
union (Document.init(composing:)) rather than as separately compiled
documents merged after the fact. Merging froze each document's content
models with only the substitution-group members visible in its own import
closure, so a substitution member declared in an importing document never
reached the head's content model in the imported document.

This clears XSTS ctZ007, a cross-document substitution group, taking
valid-instances-rejected (#146) from 4 to 3 with no other bucket moving
(now 1/262/3/147). The union pools each document's container closure into
one context, deduplicating a document supplied directly and also reached
through another's import by target namespace and component-name set.

The XSTS runner uses the union only for import chains, detected by matching
import/include/redefine schemaLocation basenames against the listed
documents; independent roots stay on the lenient per-document merge, which a
union would wrongly trip with cross-document checks.

The remaining three valid-instance rejections are the Ethiopic-digit \d
cases (reS17, reS38, reZ004v), which contradict \d = \p{Nd}.
2026-06-16 23:16:26 +02:00
be8a39394d fix(schema): advance XSTS valid-instances-rejected to 4 (#146)
Consolidates the in-progress #146 work; valid-instances-rejected reaches 4
on the 2006-11-06 XSTS archive (buckets now 1/262/4/147).

Regex: match Unicode scalars rather than grapheme clusters; the empty
pattern matches only the empty string; pin the category repertoire to
Unicode 3.2, the version the corpus is consistent at, so code points added
in 3.2 (Tagalog, Cyrillic Komi) are word characters while later additions
(the Tamil digit zero) stay unassigned and match \D. New XSDCategory and a
3.2 assignment table derived from DerivedAge, replacing the old Unicode31
files.

Content model: assess each child against the particle the content automaton
matched, not a by-name lookup (#180). Two same-named particles keep their
own fixed values, and an element matching a skip wildcard is no longer
validated against its global declaration. Selection is made reproducible
when an ambiguous schema slips past the UPA check.

XSTS runner: compile and merge every schemaDocument in a schemaTest, not
just the first; this also surfaced one previously-masked invalid-instance
acceptance (recorded in the baselines).

Also folds in attribute-group anyAttribute inheritance, extension wildcard
union, namespace-qualified type tables for multi-document
xsi:schemaLocation, per-container elementFormDefault, strict and lax
attribute wildcard validation, built-in type resolution, and xs:redefine
simpleType overrides.
2026-06-16 22:22:05 +02:00
489275c6b8 feat(schema): locate compile diagnostics with validation coding paths (#169)
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.
2026-06-16 10:26:11 +02:00
229d7df3d0 feat(validation,schema): composable validation framework and cross-document composition
Ship the OpenAPIKit-style validation layer (builtin rules, warnings channel,
Validator tiers, coverage gates) and wire XSD compile checks through it. Advance
#161 with chameleon include validation, multi-container reference resolution,
and include targetNamespace composition rules so merged schema sets compile
correctly when a schemaLoader is provided.
2026-06-16 09:57:43 +02:00
5f6170a57a docs(roadmap): add living roadmap index and README focus link
Document the restructured GitHub issue hierarchy (epic #167, gates,
milestones M1-M5, task labels) and point README Status at the current
production-readiness focus and v0.2.0 XSTS baselines.
2026-06-16 01:30:37 +02:00
baa90cfcaa chore(release): prepare 0.2.0
Cut the second pre-release: schema-validity burn-down to XSTS baselines
1/266/171/155, component child-content shape checks, and synced conformance
docs. CHANGELOG updated for v0.2.0.
2026-06-16 01:27:10 +02:00
55daca46be fix(schema): implement facet applicability and validation constraints 2026-06-16 00:02:04 +02:00
5cdcde9055 fix(schema): enforce element order and import constraints 2026-06-15 23:29:47 +02:00
790 changed files with 49131 additions and 6913 deletions

View File

@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and ideas
url: https://github.com/mihaelamj/PureXML/discussions
about: Ask questions, float ideas, or discuss design in Discussions.
url: https://git.aleahim.com/NodetteHQ/PureXML/issues
about: Ask questions, float ideas, or discuss design in an issue.
- name: Security issue
url: https://github.com/mihaelamj/PureXML/blob/main/SECURITY.md
url: https://git.aleahim.com/NodetteHQ/PureXML/src/branch/main/SECURITY.md
about: Do not file security issues publicly. Follow the security policy instead.

27
.github/workflows/rules-swift.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: rules-swift gate
# Runs the codified rules-swift gates on every pull request. The corpus-pin
# integrity check must stay green (it fails if the vendored canon drifts from its
# pinned commit). The full strict gate runs in report mode (continue-on-error)
# because it is intentionally red on a repo's pre-existing gaps, recorded in
# docs/rules-swift-audit.md; flip it to required (remove continue-on-error, or add
# branch protection) once the repo reaches conformance.
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
rules-swift:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Corpus pin integrity (required)
run: sh scripts/check-rules-corpus-coverage.sh
- name: Full rules gate (reports pre-existing gaps; non-blocking)
run: sh scripts/check-rules-swift.sh
continue-on-error: true

View File

@ -1,9 +1,12 @@
name: Swift (Windows)
# Windows runs the full build and test suite but is the slowest CI job, so it
# runs only when cutting a release (a `v*` tag push) or on demand, not on every
# push and PR. This keeps per-PR CI fast; the badge reflects the Windows run as
# of the last release.
on:
push:
branches: [main]
pull_request:
tags: ['v*']
workflow_dispatch:
permissions:

27
.github/workflows/swift-xsts.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: XSTS conformance
# Runs the W3C XML Schema Test Suite ratchet (Tests/XSTSSuiteTests.swift) on every
# change, so a regression in the schema/instance validity baselines fails CI. The
# corpus is extracted from the vendored archive (Tests/Fixtures/xsts), so this needs
# no network. The gate is release-only because the debug run is far slower.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
xsts:
name: XSTS conformance
runs-on: ubuntu-latest
container: swift:6.2
steps:
- uses: actions/checkout@v6
- name: Extract the vendored XSTS corpus
run: XSTS_DEST=/tmp/xsts bash scripts/fetch-xsts.sh
- name: Conformance ratchet
run: XSTS_ROOT=/tmp/xsts/xmlschema2006-11-06 swift test -c release --filter XSTSSuiteTests

1
.rules-version Normal file
View File

@ -0,0 +1 @@
2.14.0

View File

@ -18,6 +18,8 @@
--wrapparameters before-first
--wrapcollections before-first
--self remove
# Adopted from canonical rules-swift: @testable imports group at the bottom.
--importgrouping testable-bottom
--exclude .build,DerivedData
# PureXML uses OpenAPIKit-style `test_...` method names with Swift Testing.

View File

@ -5,10 +5,18 @@
# build plus test ships with the engine package.
opt_in_rules:
- force_unwrapping
# Adopted from the canonical rules-swift opt-in set.
- empty_count
- explicit_init
- first_where
- sorted_first_last
- unused_import
disabled_rules:
# Suggests Foundation's String(bytes:encoding:); PureXML is Foundation-free, so
# String(decoding:as:) from the standard library is the correct conversion.
- optional_data_string_conversion
# rules-swift disables todo; PureXML tracks TODOs in issues, not the linter.
- todo
excluded:
- .build
- DerivedData

View File

@ -8,19 +8,42 @@ Guidance for anyone writing code in PureXML.
## Rule Loading
At the start of a session, read this file and the rules under `docs/rules/` that
match the task. Confirm by replying with `rules-loaded` and name the files you
loaded.
At the start of a session, read this file and the applicable rules under
`third_party/rules-swift/` for the area being touched. Confirm by replying with
`rules-loaded` and name the files you loaded.
The canonical upstream Swift rule set is vendored at
`third_party/rules-swift/`, a pinned snapshot recorded in
`docs/rules-swift-corpus.sha256` and audited in `docs/rules-swift-audit.md`.
That checkout is the canon this repo is measured against; keep the
PureXML-specific constraints in this file as stronger project overrides.
For code changes, load at minimum:
- `docs/rules/code-style.md`
- `docs/rules/namespacing.md`
- `docs/rules/cross-platform.md`
- `docs/rules/testing.md`
- `docs/rules/verification.md`
- `docs/rules/commits.md`
- `docs/rules/research-first.md` (when stuck: read the research repo and the literature before guessing)
- `third_party/rules-swift/code-style.md`
- `third_party/rules-swift/namespacing.md`
- `third_party/rules-swift/cross-platform.md`
- `third_party/rules-swift/testing.md`
- `third_party/rules-swift/core/verification.md`
- `third_party/rules-swift/core/commits.md`
- `docs/research-first.md` (when stuck: read the research repo and the literature before guessing)
- `third_party/rules-swift/core/no-shortcuts-first-principles.md`
- `third_party/rules-swift/core/proof-discipline.md` (when making correctness or conformance claims)
- `third_party/rules-swift/parsing-rules.md` (when changing parser behavior)
- `third_party/rules-swift/validation-rules.md` (when changing validation behavior)
Run the local gates before hand-off:
```sh
scripts/check-rules-swift.sh
scripts/check-local-gate.sh
```
Install hooks once per clone:
```sh
scripts/install-hooks.sh
```
## What PureXML Is

File diff suppressed because one or more lines are too long

View File

@ -18,12 +18,12 @@ Non-negotiables:
## Read First
- `AGENTS.md`
- `docs/rules/code-style.md`
- `docs/rules/namespacing.md`
- `docs/rules/cross-platform.md`
- `docs/rules/testing.md`
- `docs/rules/verification.md`
- `docs/rules/research-first.md` (when stuck: read `PureXML-research` sources and the literature before guessing; clean-room only)
- `third_party/rules-swift/code-style.md`
- `third_party/rules-swift/namespacing.md`
- `third_party/rules-swift/cross-platform.md`
- `third_party/rules-swift/testing.md`
- `third_party/rules-swift/core/verification.md`
- `docs/research-first.md` (when stuck: read `PureXML-research` sources and the literature before guessing; clean-room only)
- `docs/production-readiness.md` (the bar for shipping this in an IDE; read every conformance iteration)
Confirm rule loading with `rules-loaded`.

View File

@ -8,7 +8,7 @@ rule here and a guideline elsewhere conflict, this file wins.
PureXML is an XML/XSD validator. **Never reject a valid schema or a valid
document.** In the conformance counts this is `valid-schemas-rejected`; it is
**1** and must **never rise**. Under-rejecting (missing an invalid schema) is
**0** and must **never rise**. Under-rejecting (missing an invalid schema) is
acceptable; over-rejecting (rejecting a valid one) is a failure and must not be
merged. When unsure, do less.
@ -67,6 +67,6 @@ merged. When unsure, do less.
## Definition of done
All gates green; conformance differential shows invalid-accepted down and **no
other bucket up** (valid-rejected still 1); the critic found zero false
other bucket up** (valid-rejected still 0); the critic found zero false
positives; baseline/CHANGELOG/burndown updated. If you rejected even one valid
schema, the change is wrong regardless of how many invalid ones it caught.

View File

@ -25,6 +25,9 @@ let package = Package(
name: "PureXMLTests",
dependencies: ["PureXML"],
path: "Tests",
// The vendored XSTS archive is consumed off-disk via XSTS_ROOT, never
// from the test bundle, so keep it out of the copied resources.
exclude: ["Fixtures/xsts"],
resources: [
.copy("Fixtures"),
],

View File

@ -0,0 +1,64 @@
import PureXML
import Testing
/// Exercises the public entry points in `API.swift` that the rest of the suite
/// reaches only through subsystem types: the SAX and incremental-source parse
/// overloads, the streaming `events` overloads, and HTML content-model validation.
@Suite("Public entry points")
struct PublicEntryPointTests {
private let xml = #"<r a="1"><c>hi</c></r>"#
@Test("parse delivers SAX callbacks in document order")
func test_parseSax() throws {
var elements: [String] = []
var text = ""
let handler = PureXML.Parsing.SAXHandler(
startElement: { name, _ in elements.append(name.localName) },
characters: { text += $0 },
)
try PureXML.parse(xml, sax: handler)
#expect(elements == ["r", "c"])
#expect(text == "hi")
}
@Test("parse from an incremental byte source yields the same tree as the string parse")
func test_parsePullingBytes() throws {
var bytes = Array(xml.utf8)[...]
let streamed = try PureXML.parse(pullingBytes: { bytes.isEmpty ? nil : bytes.removeFirst() })
#expect(try PureXML.serialize(streamed) == PureXML.serialize(PureXML.parse(xml)))
}
@Test("every events overload streams the document to completion")
func test_eventsOverloads() throws {
var fromBytes = try PureXML.events(bytes: Array(xml.utf8))
var byteEvents = 0
while try fromBytes.next() != nil {
byteEvents += 1
}
#expect(byteEvents > 0)
var chars = Array(xml)[...]
var fromChars = PureXML.events(pulling: { chars.isEmpty ? nil : chars.removeFirst() })
var charEvents = 0
while try fromChars.next() != nil {
charEvents += 1
}
#expect(charEvents == byteEvents)
var sourceBytes = Array(xml.utf8)[...]
var fromPulledBytes = PureXML.events(pullingBytes: { sourceBytes.isEmpty ? nil : sourceBytes.removeFirst() })
var pulledByteEvents = 0
while try fromPulledBytes.next() != nil {
pulledByteEvents += 1
}
#expect(pulledByteEvents == byteEvents)
}
@Test("validateHTML flags a duplicate id")
func test_validateHTML() throws {
let duplicate = try PureXML.parse(#"<root><a id="d"></a><b id="d"></b></root>"#)
#expect(!PureXML.validateHTML(duplicate).isEmpty)
let unique = try PureXML.parse(#"<root><a id="d"></a><b id="e"></b></root>"#)
#expect(PureXML.validateHTML(unique).allSatisfy { !$0.reason.lowercased().contains("id") })
}
}

View File

@ -39,7 +39,9 @@ struct PublicSurfaceTests {
var chunks = ["<r><a>", "hello", "</a></r>"].makeIterator()
var characters: [Character] = []
var reader = PureXML.Parsing.EventReader(pulling: {
if characters.isEmpty, let chunk = chunks.next() { characters = Array(chunk) }
if characters.isEmpty, let chunk = chunks.next() {
characters = Array(chunk)
}
return characters.isEmpty ? nil : characters.removeFirst()
})
var events = 0

View File

@ -1,10 +1,11 @@
# PureXML
[![Style and namespacing](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml)
[![Swift macOS](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml)
[![Swift Linux](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml)
[![Swift Windows](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml)
[![Swift WASM](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml)
[![Style and namespacing](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/style.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=style.yml)
[![Swift macOS](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-macos.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-macos.yml)
[![Swift Linux](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-linux.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-linux.yml)
[![Swift Windows](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-windows.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-windows.yml)
[![Swift WASM](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-wasm.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-wasm.yml)
[![XSTS conformance](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-xsts.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-xsts.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
PureXML is a dependency-free XML package written entirely in Swift.
@ -19,7 +20,7 @@ intentionally strict about portability:
- root Swift package layout
- macOS, Linux, Windows, and WASI build gates
It is a sibling project to [PureYAML](https://github.com/mihaelamj/PureYAML) and
It is a sibling project to [PureYAML](https://git.aleahim.com/NodetteHQ/PureYAML) and
follows the same structure, rules, and verification gates.
## Roadmap
@ -71,9 +72,13 @@ 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 corpus-clean parity, tracked under two epics:
**#121** (the rest of the W3C xmlconf archive, including the DTD-validity layer)
and **#122** (the official spec suites for the schema and transform stack).
done; the remaining distance is **production readiness for IDE use**, tracked under
epic [#3](https://git.aleahim.com/NodetteHQ/PureXML/issues/3) (schema authority,
differential oracle, located diagnostics). Conformance suite maintenance is
closed, leaving [#7](https://git.aleahim.com/NodetteHQ/PureXML/issues/7) (XSLT xalan
burn-down) as the only active runner work; see `docs/roadmap.md`. Issue numbers
in the historical narrative below refer to the pre-migration tracker that was
lost with the GitHub repository.
```mermaid
flowchart TB
@ -98,16 +103,22 @@ flowchart TB
NSConstraints["#136 namespace constraints: complete"]:::done
EncodingMismatch["#137 encoding-declaration mismatches: complete"]:::done
BaseURI["#138 per-entity base URIs: complete"]:::done
Epic122["#122 official spec suites"]:::epic
XSTS["#129 W3C XSD test suite (XSTS): first measurement 2026-06-13 baselined (deviations 75/2467/604/582); occurrence-explosion and reversed-range crashes fixed, suite green in 8s"]:::review
XSLTTS["#130 XSLT 1.0 suite (xalan-test): runner + baselines in, burning down (125)"]:::review
Epic122["#122 conformance suite maintenance: complete (#130 survivor)"]:::epic
XSTS["#129 XSTS runner shipped; gates #145-#148 at 0/39/0/26"]:::review
XSLTTS["#7 XSLT 1.0 suite (xalan-test): 26 pinned 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
Epic139["#6 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["#3 production readiness for IDE use"]:::epic
Oracle171["schema differential harness (shipped)"]:::done
CrossDoc161["#161 cross-document composition"]:::todo
Gates145["#4 / #5 XSTS gates: burn-down 8 and 4 to 0"]:::review
Loc169["located schema diagnostics (shipped)"]:::done
Epic195["#195 xsi:type and substitution-group derivation correctness"]:::epic
Epic105 --> Epic121
Epic105 --> Epic122
Epic121 --> Oasis
@ -124,13 +135,21 @@ flowchart TB
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 ([#3](https://git.aleahim.com/NodetteHQ/PureXML/issues/3)). XSTS settled baselines (valid schemas rejected / invalid schemas accepted / valid instances rejected / invalid instances accepted), enforced in `Tests/XSTSSuiteTests.swift`: **0 / 8 / 0 / 4**. See [`docs/roadmap.md`](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 **986 tests in 169 suites** (`swift test`).
currently runs **1448 tests in 258 suites** (`swift test`).
### Shipped (libxml2-aligned surface)
@ -143,8 +162,10 @@ currently runs **986 tests in 169 suites** (`swift test`).
(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), ISO-8859-1, and Windows-1252
from raw bytes or streaming byte input.
- **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
@ -193,25 +214,23 @@ 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 toward full libxml2 feature parity
### Remaining work
The original parity epics (the core I/O stack, query, transform, the schema
languages, the validation framework, and editor integration) are all shipped. The
remaining work is the depth gaps a read-only conformance audit surfaced, tracked
under epic **#71** (and its parser-breadth sub-epic **#74**):
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.
| Open work | Issues | What it adds |
|---|---|---|
| XSLT 1.0 breadth | #81 #82 | Missing top-level elements and functions, html output |
| HTML5 | #83 #84 | Full tree construction, tokenizer states, entity table |
| C14N | #85 | Node-subset canonicalization, `xml:*` inheritance, 2.0 prefix rewrite |
| Legacy encodings | #97 #99 #102 #103 | Single-byte, CJK multi-byte, full GB18030, and Big5 to-Unicode tables |
| Validator decomposition | #101 | Inner XSD/DTD validators as composable rules |
| Partial follow-ups | #79 #80 #86 #87 #88 #90 #91 | Remaining bullets on already-advanced areas |
Each open issue carries a checklist of done vs remaining bullets. The encoding
tables (#97, #99) are deferred until they can be vendored from authoritative
Unicode mapping files rather than transcribed.
The remaining distance is **production readiness for IDE use**, tracked under epic
[#3](https://git.aleahim.com/NodetteHQ/PureXML/issues/3): driving the XSTS conformance
buckets toward zero (currently **0 / 8 / 0 / 4**, gates
[#4](https://git.aleahim.com/NodetteHQ/PureXML/issues/4) and
[#5](https://git.aleahim.com/NodetteHQ/PureXML/issues/5)); located schema diagnostics
and the schema differential oracle are shipped. The one active runner burn-down
is [#7](https://git.aleahim.com/NodetteHQ/PureXML/issues/7) (the XSLT 1.0 xalan
suite). See [`docs/roadmap.md`](docs/roadmap.md).
## Usage
@ -274,6 +293,9 @@ 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

View File

@ -5,7 +5,7 @@
If you believe you have found a security issue in PureXML, please report it
privately. Do not open a public issue for security problems.
Email **mihaelamj.com** with:
Email **mihaelamj@gmail.com** with:
- A description of the issue and its impact.
- Steps to reproduce, or a proof of concept.

View File

@ -2,8 +2,8 @@
Need help with PureXML? Here is where to go.
- **Questions and ideas**: open a [Discussion](https://github.com/mihaelamj/PureXML/discussions).
- **Bug reports**: open an [issue](https://github.com/mihaelamj/PureXML/issues/new/choose)
- **Questions and ideas**: open an [issue](https://git.aleahim.com/NodetteHQ/PureXML/issues).
- **Bug reports**: open an [issue](https://git.aleahim.com/NodetteHQ/PureXML/issues/new/choose)
using the bug form.
- **Feature requests**: open an issue using the feature form.
- **Security issues**: do not open a public issue. See [SECURITY.md](SECURITY.md).

View File

@ -86,7 +86,7 @@ public extension PureXML {
) throws -> [Validation.ValidationError] {
let parsed = try Parsing.Parser().parseWithDocumentType(xml, limits: limits, resolver: resolver)
let schema = Validation.DTDSchema(parsed.documentType, standalone: parsed.declaration?.standalone == true)
return Validation.DTD.validator(strict: strict).errors(for: parsed.node, in: schema)
return Validation.DTD.validator(strict: strict).findings(for: parsed.node, in: schema)
}
/// The XML declaration (`version`, `encoding`, `standalone`) at the start of
@ -165,7 +165,7 @@ public extension PureXML {
limits: Parsing.Limits = .default,
resolver: Parsing.EntityResolver = .refusing,
) throws -> Model.TreeNode {
try Model.TreeNode(parse(xml, limits: limits, resolver: resolver))
try Parsing.Parser().parseTree(xml, limits: limits, resolver: resolver)
}
/// Reads a possibly-invalid document without throwing, returning the maximal
@ -210,4 +210,21 @@ public extension PureXML {
) throws {
try validator.validate(node)
}
/// The HTML5 content-model validation errors for a parsed node; empty when the
/// tree satisfies the intrinsic rules (void elements, required parents, unique
/// `id` values). Built on ``Validation/HTML``.
static func validateHTML(_ node: Model.Node) -> [Validation.ValidationError] {
HTML.validationErrors(in: node)
}
/// Lints a possibly-invalid document against an XSD schema, merging parse
/// diagnostics and schema validation findings into source-ranged
/// ``LintDiagnostic``s. See ``lint(_:validate:)``.
static func lint(
_ xml: String,
schema: Schema.Document,
) -> [LintDiagnostic] {
lint(xml, validate: schema.validate)
}
}

View File

@ -0,0 +1,78 @@
extension PureXML.Canonical.Canonicalizer {
/// The apex element with its inherited `xml:*` attributes added, and (for
/// inclusive mode) the namespaces in scope from omitted ancestors merged in
/// as declarations so the apex renders its full namespace context.
static func augmentedApex(
_ apex: PureXML.Model.Element,
inheritedXML: [PureXML.Model.Attribute],
mergingNamespaces ancestorNamespaces: [String: String]?,
stripApexBase: Bool,
) -> PureXML.Model.Element {
// Under 1.1, the apex's own xml:base is folded into the merged base in
// inheritedXML, so drop the original to avoid emitting it twice.
let own = stripApexBase
? apex.attributes.filter { !($0.name.prefix == "xml" && $0.name.localName == "base") }
: apex.attributes
var attributes = own + inheritedXML
if let ancestorNamespaces {
let declared = Set(apex.attributes.map(\.name.description))
for (prefix, uri) in ancestorNamespaces {
let name = prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)"
if !declared.contains(name) {
attributes.append(PureXML.Model.Attribute(name, uri))
}
}
}
return PureXML.Model.Element(name: apex.name, attributes: attributes, children: apex.children)
}
/// The namespace declarations in scope from a node's omitted ancestors,
/// nearest ancestor winning (so an inner redeclaration is preserved).
static func inScopeNamespaces(above node: PureXML.Model.TreeNode) -> [String: String] {
var result: [String: String] = [:]
var current = node.parent
while let ancestor = current {
for attribute in ancestor.attributes {
guard let prefix = declaredPrefix(of: attribute.name), result[prefix] == nil else { continue }
result[prefix] = attribute.value
}
current = ancestor.parent
}
return result
}
/// The `xml:*` attributes in scope from a node's omitted ancestors that the
/// apex does not already set, nearest ancestor winning.
static func inheritedXMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, mergeBase: Bool) -> [PureXML.Model.Attribute] {
let present = Set(apex.attributes.map(\.name.description))
if mergeBase {
return inherited11XMLAttributes(above: node, apex: apex, present: present)
}
// Canonical XML 1.0: the nearest of every in-scope xml:* attribute the
// apex does not already set.
var seen: Set<String> = []
var result: [PureXML.Model.Attribute] = []
var current = node.parent
while let ancestor = current {
for attribute in ancestor.attributes where attribute.name.prefix == "xml" {
let key = attribute.name.description
guard !present.contains(key), seen.insert(key).inserted else { continue }
result.append(attribute)
}
current = ancestor.parent
}
return result
}
/// The prefix a namespace declaration binds (empty for the default
/// namespace), or nil when the attribute is not a namespace declaration.
static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
if name.prefix == nil, name.localName == "xmlns" {
return ""
}
if name.prefix == "xmlns" {
return name.localName
}
return nil
}
}

View File

@ -1,3 +1,20 @@
/// A mutable bag of rebuilt nodes a branch's children gather into, so the
/// canonical prefix rewrite's deferred build step assembles the parent once they
/// are done.
private final class CanonicalRewriteAccumulator {
var nodes: [PureXML.Model.Node] = []
}
/// One unit of canonical prefix-rewrite work: visit a source node (appending its
/// rebuilt result to `target`), or, after a branch's children are gathered, build
/// the element or document. File-scoped so the work stack is not a third level of
/// type nesting.
private enum CanonicalRewriteWork {
case visit(PureXML.Model.Node, inScope: [String: String], isRoot: Bool, target: CanonicalRewriteAccumulator)
case buildElement(PureXML.Model.Element, childInScope: [String: String], isRoot: Bool, gathered: CanonicalRewriteAccumulator, target: CanonicalRewriteAccumulator)
case buildDocument(CanonicalRewriteAccumulator, CanonicalRewriteAccumulator)
}
extension PureXML.Canonical {
/// The Canonical XML 2.0 sequential prefix rewrite. Implemented as a pre-pass
/// that rebuilds the tree using canonical prefixes (`n0`, `n1`, ... in
@ -16,41 +33,47 @@ extension PureXML.Canonical {
var assignment: [String: String] = [:]
var order = 0
assign(node, inScope: [:], labels: labels, &assignment, &order)
return transform(node, inScope: [:], assignment: assignment, labels: labels, isRoot: true)
return transform(node, assignment: assignment, labels: labels)
}
// MARK: First pass: assign a canonical prefix per visibly-used namespace
private static func assign(
_ node: PureXML.Model.Node,
_ root: PureXML.Model.Node,
inScope: [String: String],
labels: [QNameAwareLabel],
_ assignment: inout [String: String],
_ order: inout Int,
) {
switch node {
case let .document(children):
for child in children {
assign(child, inScope: inScope, labels: labels, &assignment, &order)
// Iterative pre-order walk so a deeply-nested document does not overflow
// the stack; children push reversed so namespaces are recorded in
// document order, the order the canonical prefixes n0, n1, ... assign in.
var stack: [(node: PureXML.Model.Node, inScope: [String: String])] = [(root, inScope)]
while let (node, inScope) = stack.popLast() {
switch node {
case let .document(children):
for child in children.reversed() {
stack.append((child, inScope))
}
case let .element(element):
var childInScope = inScope
for (prefix, uri) in declarations(element) {
childInScope[prefix] = uri
}
record(element.name.namespaceURI, &assignment, &order)
for attribute in element.attributes where attribute.name.prefix != nil {
record(attribute.name.namespaceURI, &assignment, &order)
}
for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
}
record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
for child in element.children.reversed() {
stack.append((child, childInScope))
}
default:
break
}
case let .element(element):
var childInScope = inScope
for (prefix, uri) in declarations(element) {
childInScope[prefix] = uri
}
record(element.name.namespaceURI, &assignment, &order)
for attribute in element.attributes where attribute.name.prefix != nil {
record(attribute.name.namespaceURI, &assignment, &order)
}
for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
}
record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
for child in element.children {
assign(child, inScope: childInScope, labels: labels, &assignment, &order)
}
default:
break
}
}
@ -73,46 +96,72 @@ extension PureXML.Canonical {
// MARK: Second pass: rebuild with canonical prefixes
/// Rebuilds the tree with canonical prefixes without recursing on its depth.
/// An explicit work stack threads the in-scope declarations and the root
/// flag down per element, while each branch gathers its rebuilt children
/// into a reference accumulator that a deferred build step assembles, so a
/// deeply-nested document does not overflow the stack.
private static func transform(
_ node: PureXML.Model.Node,
inScope: [String: String],
_ root: PureXML.Model.Node,
assignment: [String: String],
labels: [QNameAwareLabel],
isRoot: Bool,
) -> PureXML.Model.Node {
switch node {
case let .document(children):
.document(children.map { transform($0, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot) })
case let .element(element):
.element(transform(element, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot))
default:
node
let result = CanonicalRewriteAccumulator()
var stack: [CanonicalRewriteWork] = [.visit(root, inScope: [:], isRoot: true, target: result)]
while let work = stack.popLast() {
switch work {
case let .visit(node, inScope, isRoot, target):
visitForRewrite(node, inScope: inScope, isRoot: isRoot, target: target, stack: &stack)
case let .buildElement(element, childInScope, isRoot, gathered, target):
var attributes = isRoot ? canonicalDeclarations(assignment) : []
for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
}
// A QName-labelled element's single text child is rewritten to a
// canonical-prefixed QName, replacing its rebuilt children.
let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
target.nodes.append(.element(PureXML.Model.Element(
name: rename(element.name, assignment: assignment),
attributes: attributes,
children: qualified.map { [PureXML.Model.Node.text($0)] } ?? gathered.nodes,
)))
case let .buildDocument(gathered, target):
target.nodes.append(.document(gathered.nodes))
}
}
return result.nodes.first ?? root
}
private static func transform(
_ element: PureXML.Model.Element,
/// Pushes a node's rebuild work: a document or element schedules its build
/// step then its children (with the in-scope declarations extended), a leaf
/// is appended to `target` unchanged.
private static func visitForRewrite(
_ node: PureXML.Model.Node,
inScope: [String: String],
assignment: [String: String],
labels: [QNameAwareLabel],
isRoot: Bool,
) -> PureXML.Model.Element {
var childInScope = inScope
for (prefix, uri) in declarations(element) {
childInScope[prefix] = uri
target: CanonicalRewriteAccumulator,
stack: inout [CanonicalRewriteWork],
) {
switch node {
case let .document(children):
let gathered = CanonicalRewriteAccumulator()
stack.append(.buildDocument(gathered, target))
for child in children.reversed() {
stack.append(.visit(child, inScope: inScope, isRoot: isRoot, target: gathered))
}
case let .element(element):
var childInScope = inScope
for (prefix, uri) in declarations(element) {
childInScope[prefix] = uri
}
let gathered = CanonicalRewriteAccumulator()
stack.append(.buildElement(element, childInScope: childInScope, isRoot: isRoot, gathered: gathered, target: target))
for child in element.children.reversed() {
stack.append(.visit(child, inScope: childInScope, isRoot: false, target: gathered))
}
default:
target.nodes.append(node)
}
var attributes = isRoot ? canonicalDeclarations(assignment) : []
for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
}
let children = element.children.map { transform($0, inScope: childInScope, assignment: assignment, labels: labels, isRoot: false) }
let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
return PureXML.Model.Element(
name: rename(element.name, assignment: assignment),
attributes: attributes,
children: qualified.map { [PureXML.Model.Node.text($0)] } ?? children,
)
}
/// One `xmlns:nK="uri"` declaration per assigned namespace, hoisted to the
@ -159,8 +208,12 @@ extension PureXML.Canonical {
}
private static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
if name.prefix == nil, name.localName == "xmlns" { return "" }
if name.prefix == "xmlns" { return name.localName }
if name.prefix == nil, name.localName == "xmlns" {
return ""
}
if name.prefix == "xmlns" {
return name.localName
}
return nil
}
}

View File

@ -0,0 +1,8 @@
/// One step of the iterative canonical node-subset serialization (C14N over a
/// selected subtree): a `TreeNode` to emit with its in-scope and already-rendered
/// namespace context, or a deferred element close. The mutable-tree counterpart
/// to ``CanonicalStep``.
enum CanonicalSelectedStep {
case open(PureXML.Model.TreeNode, inScope: [String: String], rendered: [String: String])
case close(String)
}

View File

@ -0,0 +1,6 @@
/// One step of the iterative canonical serialization: a node to emit with its
/// in-scope and already-rendered namespace context, or a deferred element close.
enum CanonicalStep {
case open(PureXML.Model.Node, inScope: [String: String], rendered: [String: String])
case close(String)
}

View File

@ -5,7 +5,9 @@ extension PureXML.Canonical.Canonicalizer {
static func inherited11XMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, present: Set<String>) -> [PureXML.Model.Attribute] {
var result: [PureXML.Model.Attribute] = []
for local in ["lang", "space"] where !present.contains("xml:\(local)") {
if let attribute = nearestXMLAttribute(local, above: node) { result.append(attribute) }
if let attribute = nearestXMLAttribute(local, above: node) {
result.append(attribute)
}
}
if let base = mergedBase(above: node, apex: apex) {
result.append(PureXML.Model.Attribute("xml:base", base))
@ -94,27 +96,35 @@ extension PureXML.Canonical.Canonicalizer {
var input = Substring(path)
var output = ""
while !input.isEmpty {
if input.hasPrefix("../") { input = input.dropFirst(3)
if input.hasPrefix("../") {
input = input.dropFirst(3)
continue
}
if input.hasPrefix("./") { input = input.dropFirst(2)
if input.hasPrefix("./") {
input = input.dropFirst(2)
continue
}
if input.hasPrefix("/./") { input = "/" + input.dropFirst(3)
if input.hasPrefix("/./") {
input = "/" + input.dropFirst(3)
continue
}
if input == "/." { input = "/"
if input == "/." {
input = "/"
continue
}
if input.hasPrefix("/../") { input = "/" + input.dropFirst(4)
if input.hasPrefix("/../") {
input = "/" + input.dropFirst(4)
removeLastSegment(&output)
continue
}
if input == "/.." { input = "/"
if input == "/.." {
input = "/"
removeLastSegment(&output)
continue
}
if input == "." || input == ".." { break }
if input == "." || input == ".." {
break
}
let start = input.hasPrefix("/") ? input.index(after: input.startIndex) : input.startIndex
let next = input[start...].firstIndex(of: "/") ?? input.endIndex
output += input[..<next]
@ -192,11 +202,19 @@ private struct URIParts {
/// Recomposes the components into a URI string (RFC 3986 section 5.3).
var recomposed: String {
var result = ""
if let scheme { result += scheme + ":" }
if let authority { result += "//" + authority }
if let scheme {
result += scheme + ":"
}
if let authority {
result += "//" + authority
}
result += path
if let query { result += "?" + query }
if let fragment { result += "#" + fragment }
if let query {
result += "?" + query
}
if let fragment {
result += "#" + fragment
}
return result
}
}

View File

@ -68,7 +68,9 @@ public extension PureXML.Canonical {
for child in children {
var rendering = ""
emit(child, inScope: inScope, rendered: rendered, output: &rendering)
if !rendering.isEmpty { renderings.append(rendering) }
if !rendering.isEmpty {
renderings.append(rendering)
}
}
output += renderings.joined(separator: "\n")
case let .element(element):
@ -77,7 +79,9 @@ public extension PureXML.Canonical {
let text = options.trimTextNodes ? value.trimmingXMLWhitespace() : value
output += Self.escapeText(text)
case let .comment(value):
if options.includeComments { output += "<!--\(value)-->" }
if options.includeComments {
output += "<!--\(value)-->"
}
case let .processingInstruction(target, data):
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
}
@ -89,6 +93,35 @@ public extension PureXML.Canonical {
rendered: [String: String],
output: inout String,
) {
// Deferred-close work stack so a deeply-nested element does not
// overflow the stack; children push reversed to emit in document order.
var stack: [CanonicalStep] = [.open(.element(element), inScope: inScope, rendered: rendered)]
while let step = stack.popLast() {
switch step {
case let .close(name):
output += "</\(name)>"
case let .open(node, inScope, rendered):
guard case let .element(element) = node else {
emitLeaf(node, into: &output)
continue
}
let child = emitOpenTag(element, inScope: inScope, rendered: rendered, into: &output)
stack.append(.close(element.name.description))
for childNode in element.children.reversed() {
stack.append(.open(childNode, inScope: child.inScope, rendered: child.rendered))
}
}
}
}
/// Emits an element's start tag (namespaces then attributes, in canonical
/// order) and returns the namespace context its children inherit.
private func emitOpenTag(
_ element: PureXML.Model.Element,
inScope: [String: String],
rendered: [String: String],
into output: inout String,
) -> (inScope: [String: String], rendered: [String: String]) {
let declarations = Self.namespaceDeclarations(element)
var childInScope = inScope
for (prefix, uri) in declarations {
@ -109,10 +142,25 @@ public extension PureXML.Canonical {
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
}
output += ">"
for child in element.children {
emit(child, inScope: childInScope, rendered: childRendered, output: &output)
return (childInScope, childRendered)
}
/// Emits a non-element node (text, CDATA, comment, or processing
/// instruction) the same way the node-level `emit` does.
private func emitLeaf(_ node: PureXML.Model.Node, into output: inout String) {
switch node {
case let .text(value), let .cdata(value):
let text = options.trimTextNodes ? value.trimmingXMLWhitespace() : value
output += Self.escapeText(text)
case let .comment(value):
if options.includeComments {
output += "<!--\(value)-->"
}
case let .processingInstruction(target, data):
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
case .element, .document:
break
}
output += "</\(element.name.description)>"
}
// MARK: Namespace selection
@ -156,7 +204,9 @@ public extension PureXML.Canonical {
for prefix in utilized {
let uri = inScope[prefix] ?? ""
if prefix.isEmpty {
if uri != (rendered[""] ?? "") { result.append(("", uri)) }
if uri != (rendered[""] ?? "") {
result.append(("", uri))
}
} else if !uri.isEmpty, rendered[prefix] != uri {
result.append((prefix, uri))
}
@ -173,15 +223,21 @@ public extension PureXML.Canonical {
private static func attributeOrder(_ lhs: PureXML.Model.Attribute, _ rhs: PureXML.Model.Attribute) -> Bool {
let leftURI = lhs.name.namespaceURI ?? ""
let rightURI = rhs.name.namespaceURI ?? ""
if leftURI != rightURI { return leftURI < rightURI }
if leftURI != rightURI {
return leftURI < rightURI
}
return lhs.name.localName < rhs.name.localName
}
private static func namespaceDeclarations(_ element: PureXML.Model.Element) -> [(String, String)] {
element.attributes.compactMap { attribute in
let name = attribute.name
if name.prefix == nil, name.localName == "xmlns" { return ("", attribute.value) }
if name.prefix == "xmlns" { return (name.localName, attribute.value) }
if name.prefix == nil, name.localName == "xmlns" {
return ("", attribute.value)
}
if name.prefix == "xmlns" {
return (name.localName, attribute.value)
}
return nil
}
}
@ -192,77 +248,6 @@ public extension PureXML.Canonical {
return name.prefix != "xmlns" && !(name.prefix == nil && name.localName == "xmlns")
}
}
// MARK: Node-subset context
/// The apex element with its inherited `xml:*` attributes added, and (for
/// inclusive mode) the namespaces in scope from omitted ancestors merged in
/// as declarations so the apex renders its full namespace context.
private static func augmentedApex(
_ apex: PureXML.Model.Element,
inheritedXML: [PureXML.Model.Attribute],
mergingNamespaces ancestorNamespaces: [String: String]?,
stripApexBase: Bool,
) -> PureXML.Model.Element {
// Under 1.1, the apex's own xml:base is folded into the merged base in
// inheritedXML, so drop the original to avoid emitting it twice.
let own = stripApexBase
? apex.attributes.filter { !($0.name.prefix == "xml" && $0.name.localName == "base") }
: apex.attributes
var attributes = own + inheritedXML
if let ancestorNamespaces {
let declared = Set(apex.attributes.map(\.name.description))
for (prefix, uri) in ancestorNamespaces {
let name = prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)"
if !declared.contains(name) { attributes.append(PureXML.Model.Attribute(name, uri)) }
}
}
return PureXML.Model.Element(name: apex.name, attributes: attributes, children: apex.children)
}
/// The namespace declarations in scope from a node's omitted ancestors,
/// nearest ancestor winning (so an inner redeclaration is preserved).
private static func inScopeNamespaces(above node: PureXML.Model.TreeNode) -> [String: String] {
var result: [String: String] = [:]
var current = node.parent
while let ancestor = current {
for attribute in ancestor.attributes {
guard let prefix = declaredPrefix(of: attribute.name), result[prefix] == nil else { continue }
result[prefix] = attribute.value
}
current = ancestor.parent
}
return result
}
/// The `xml:*` attributes in scope from a node's omitted ancestors that the
/// apex does not already set, nearest ancestor winning.
private static func inheritedXMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, mergeBase: Bool) -> [PureXML.Model.Attribute] {
let present = Set(apex.attributes.map(\.name.description))
if mergeBase { return inherited11XMLAttributes(above: node, apex: apex, present: present) }
// Canonical XML 1.0: the nearest of every in-scope xml:* attribute the
// apex does not already set.
var seen: Set<String> = []
var result: [PureXML.Model.Attribute] = []
var current = node.parent
while let ancestor = current {
for attribute in ancestor.attributes where attribute.name.prefix == "xml" {
let key = attribute.name.description
guard !present.contains(key), seen.insert(key).inserted else { continue }
result.append(attribute)
}
current = ancestor.parent
}
return result
}
/// The prefix a namespace declaration binds (empty for the default
/// namespace), or nil when the attribute is not a namespace declaration.
private static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
if name.prefix == nil, name.localName == "xmlns" { return "" }
if name.prefix == "xmlns" { return name.localName }
return nil
}
}
}
@ -281,49 +266,54 @@ public extension PureXML.Canonical.Canonicalizer {
}
private func emitSelected(
_ root: PureXML.Model.TreeNode,
inScope: [String: String],
rendered: [String: String],
including predicate: (PureXML.Model.TreeNode) -> Bool,
output: inout String,
) {
// Deferred-close work stack so a deeply-nested subtree does not overflow
// the stack; children push reversed to emit in document order, threading
// each element's in-scope and already-rendered namespace context.
var stack: [CanonicalSelectedStep] = [.open(root, inScope: inScope, rendered: rendered)]
while let step = stack.popLast() {
switch step {
case let .close(name):
output += "</\(name)>"
case let .open(node, inScope, rendered):
switch node.kind {
case .document:
for child in node.children.reversed() {
stack.append(.open(child, inScope: inScope, rendered: rendered))
}
case .element:
stack.append(contentsOf: selectedElementSteps(node, inScope: inScope, rendered: rendered, including: predicate, into: &output))
default:
emitSelectedLeaf(node, including: predicate, into: &output)
}
}
}
}
/// Emits a selected element's start tag and returns the steps to push for its
/// subtree: its children to visit, preceded by a close step (so the close pops
/// after them). An omitted element renders nothing and pushes no close, but its
/// declarations still stay in scope for descendants, so a selected descendant
/// re-declares them itself.
private func selectedElementSteps(
_ node: PureXML.Model.TreeNode,
inScope: [String: String],
rendered: [String: String],
including predicate: (PureXML.Model.TreeNode) -> Bool,
output: inout String,
) {
switch node.kind {
case .document:
for child in node.children {
emitSelected(child, inScope: inScope, rendered: rendered, including: predicate, output: &output)
}
case .element:
emitSelected(element: node, inScope: inScope, rendered: rendered, including: predicate, output: &output)
case .text, .cdata:
if predicate(node) { output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value) }
case .comment:
if predicate(node), options.includeComments { output += "<!--\(node.value)-->" }
case .processingInstruction:
if predicate(node) { output += node.value.isEmpty ? "<?\(node.name?.description ?? "")?>" : "<?\(node.name?.description ?? "") \(node.value)?>" }
case .doctype, .entityReference, .namespace:
break
}
}
private func emitSelected(
element node: PureXML.Model.TreeNode,
inScope: [String: String],
rendered: [String: String],
including predicate: (PureXML.Model.TreeNode) -> Bool,
output: inout String,
) {
into output: inout String,
) -> [CanonicalSelectedStep] {
let element = PureXML.Model.Element(name: node.name ?? .init(""), attributes: node.attributes, children: [])
var childInScope = inScope
for (prefix, uri) in Self.namespaceDeclarations(element) {
childInScope[prefix] = uri
}
guard predicate(node) else {
// Omitted element: its namespaces stay in scope for descendants, but
// nothing is rendered, so a selected descendant renders them itself.
for child in node.children {
emitSelected(child, inScope: childInScope, rendered: rendered, including: predicate, output: &output)
}
return
return node.children.reversed().map { .open($0, inScope: childInScope, rendered: rendered) }
}
let attributes = Self.plainAttributes(element)
let toRender = selectedNamespaces(element, inScope: childInScope, attributes: attributes, rendered: rendered)
@ -339,10 +329,26 @@ public extension PureXML.Canonical.Canonicalizer {
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
}
output += ">"
for child in node.children {
emitSelected(child, inScope: childInScope, rendered: childRendered, including: predicate, output: &output)
var steps: [CanonicalSelectedStep] = [.close(element.name.description)]
steps.append(contentsOf: node.children.reversed().map { .open($0, inScope: childInScope, rendered: childRendered) })
return steps
}
/// Emits a selected non-element node (text, CDATA, comment, or PI).
private func emitSelectedLeaf(_ node: PureXML.Model.TreeNode, including predicate: (PureXML.Model.TreeNode) -> Bool, into output: inout String) {
guard predicate(node) else { return }
switch node.kind {
case .text, .cdata:
output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value)
case .comment:
if options.includeComments {
output += "<!--\(node.value)-->"
}
case .processingInstruction:
output += node.value.isEmpty ? "<?\(node.name?.description ?? "")?>" : "<?\(node.name?.description ?? "") \(node.value)?>"
case .element, .document, .doctype, .entityReference, .namespace:
break
}
output += "</\(element.name.description)>"
}
/// The namespaces a selected element renders: in inclusive mode every in-scope

View File

@ -27,7 +27,9 @@ private struct CatalogBuilder {
insert(node, key: "systemId", base: base, into: &systemMap)
case "public":
insert(node, key: "publicId", base: base, into: &publicMap)
if let id = attribute(node, "publicId") { publicPrefer[id] = prefer }
if let id = attribute(node, "publicId") {
publicPrefer[id] = prefer
}
case "uri":
insert(node, key: "name", base: base, into: &uriMap)
case "rewriteSystem":
@ -62,7 +64,9 @@ private struct CatalogBuilder {
case "delegateURI":
appendDelegate(node, start: "uriStartString", base: base, into: &delegateURI)
case "nextCatalog":
if let catalog = attribute(node, "catalog") { nextCatalogs.append(resolve(catalog, base)) }
if let catalog = attribute(node, "catalog") {
nextCatalogs.append(resolve(catalog, base))
}
default:
break
}

View File

@ -163,16 +163,22 @@ public extension PureXML.Catalog {
_ loadingCatalog: (String) -> String?,
_ visited: inout Set<String>,
) -> String? {
if let local = pick(self) { return local }
if let local = pick(self) {
return local
}
let matching = delegates
.filter { identifier.hasPrefix($0.startString) }
.sorted { $0.startString.count > $1.startString.count }
for rule in matching {
if let sub = load(rule.catalog, loadingCatalog, &visited), let resolved = pick(sub) { return resolved }
if let sub = load(rule.catalog, loadingCatalog, &visited), let resolved = pick(sub) {
return resolved
}
}
for next in nextCatalogs {
guard let sub = load(next, loadingCatalog, &visited) else { continue }
if let resolved = sub.chained(identifier, delegates: [], pick: pick, loadingCatalog, &visited) { return resolved }
if let resolved = sub.chained(identifier, delegates: [], pick: pick, loadingCatalog, &visited) {
return resolved
}
}
return nil
}

View File

@ -27,16 +27,22 @@ private struct SGMLBuilder {
mutating func apply(_ keyword: String, _ scanner: inout SGMLScanner) {
let upper = keyword.uppercased()
if applyPair(upper, &scanner) { return }
if applyPair(upper, &scanner) {
return
}
applySingle(upper, &scanner)
}
private mutating func applyPair(_ keyword: String, _ scanner: inout SGMLScanner) -> Bool {
switch keyword {
case "PUBLIC":
if let id = scanner.next(), let uri = scanner.next() { publicMap[id] = resolve(uri) }
if let id = scanner.next(), let uri = scanner.next() {
publicMap[id] = resolve(uri)
}
case "SYSTEM":
if let id = scanner.next(), let uri = scanner.next() { systemMap[id] = resolve(uri) }
if let id = scanner.next(), let uri = scanner.next() {
systemMap[id] = resolve(uri)
}
case "DELEGATE":
if let prefix = scanner.next(), let catalog = scanner.next() {
delegatePublic.append(PureXML.Catalog.DelegateRule(startString: prefix, catalog: resolve(catalog)))
@ -50,11 +56,17 @@ private struct SGMLBuilder {
private mutating func applySingle(_ keyword: String, _ scanner: inout SGMLScanner) {
switch keyword {
case "CATALOG":
if let catalog = scanner.next() { nextCatalogs.append(resolve(catalog)) }
if let catalog = scanner.next() {
nextCatalogs.append(resolve(catalog))
}
case "BASE":
if let declared = scanner.next() { base = resolve(declared) }
if let declared = scanner.next() {
base = resolve(declared)
}
case "OVERRIDE":
if let value = scanner.next() { preferPublic = value.uppercased() == "YES" }
if let value = scanner.next() {
preferPublic = value.uppercased() == "YES"
}
default:
break
}

View File

@ -6,19 +6,114 @@ extension PureXML.Emitting {
/// libxml2), so attributes survive attribute-value normalization.
enum Escaping {
static func text(_ value: String, asciiOnly: Bool = false, escapeCarriageReturn: Bool = false) -> String {
// Fast path: most content has no character that needs escaping, so it
// is returned unchanged rather than rebuilt character by character.
if !textNeedsEscaping(value, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn) {
return value
}
var result = ""
for character in value {
switch character {
case "&": result += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\r" where escapeCarriageReturn: result += "&#xD;"
default: result += plain(character, asciiOnly: asciiOnly)
var runStart = value.startIndex
// Find each character that needs escaping by scanning bytes (every
// trigger is an ASCII marker, or in ASCII-only mode a non-ASCII lead
// byte, both of which fall on a grapheme boundary), copy the verbatim
// run before it in one append, and rewrite only that character.
while let marker = value.utf8[runStart...].firstIndex(where: {
textEscapableByte($0, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn)
}) {
if runStart < marker {
result += value[runStart ..< marker]
}
let character = value[marker]
result += textEscape(character, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn) ?? String(character)
runStart = value.index(after: marker)
}
if runStart < value.endIndex {
result += value[runStart...]
}
return result
}
/// Whether a byte can begin a character ``text`` must escape: an ASCII
/// markup byte, a carriage return when asked, or any non-ASCII lead byte
/// in ASCII-only mode (its character is reference-escaped whole).
private static func textEscapableByte(_ byte: UInt8, asciiOnly: Bool, escapeCarriageReturn: Bool) -> Bool {
switch byte {
case 0x26, 0x3C, 0x3E: true
case 0x0D where escapeCarriageReturn: true
case 0x80... where asciiOnly: true
default: false
}
}
/// The escaped form of one content character, or nil when it is verbatim.
private static func textEscape(_ character: Character, asciiOnly: Bool, escapeCarriageReturn: Bool) -> String? {
switch character {
case "&": "&amp;"
case "<": "&lt;"
case ">": "&gt;"
case "\r" where escapeCarriageReturn: "&#xD;"
default: asciiOnly ? plainIfNonASCII(character) : nil
}
}
/// Whether ``text`` would change `value`: it escapes `&`, `<`, `>` always,
/// a carriage return when asked, and (in ASCII-only mode) any non-ASCII
/// byte. Scanned at the byte level, since every trigger is ASCII or, for
/// ASCII-only mode, a non-ASCII lead byte. Run on every text node in a
/// document, so it scans a raw contiguous buffer when the string
/// provides one: `UTF8View`'s default `Collection` iteration measured
/// markedly slower than a raw-buffer scan for this shape (~2x on a
/// dedicated micro-benchmark), and every string this library builds
/// (parsed from its own byte-backed reader) is natively contiguous, so
/// the fallback below only exists for a foreign/bridged string.
private static func textNeedsEscaping(_ value: String, asciiOnly: Bool, escapeCarriageReturn: Bool) -> Bool {
textNeedsEscapingContiguous(value, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn)
?? textNeedsEscapingCollection(value, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn)
}
/// The raw contiguous-buffer scan, split into its own function (rather
/// than inlined alongside the ``textNeedsEscapingCollection`` fallback in
/// one body) purely to keep each function's cyclomatic complexity low;
/// the switch itself must stay duplicated, not shared through a per-byte
/// helper call, since that measured erasing almost the entire win.
private static func textNeedsEscapingContiguous(_ value: String, asciiOnly: Bool, escapeCarriageReturn: Bool) -> Bool? {
value.utf8.withContiguousStorageIfAvailable { buffer -> Bool in
for byte in buffer {
switch byte {
case 0x26, 0x3C, 0x3E: return true
case 0x0D where escapeCarriageReturn: return true
case 0x80... where asciiOnly: return true
default: continue
}
}
return false
}
}
/// `Collection`-based fallback for a string with no contiguous storage
/// (a foreign/bridged string); every string this library builds is
/// natively contiguous, so this path is believed dead in practice but
/// kept for correctness.
private static func textNeedsEscapingCollection(_ value: String, asciiOnly: Bool, escapeCarriageReturn: Bool) -> Bool {
for byte in value.utf8 {
switch byte {
case 0x26, 0x3C, 0x3E: return true
case 0x0D where escapeCarriageReturn: return true
case 0x80... where asciiOnly: return true
default: continue
}
}
return false
}
/// In ASCII-only mode, the numeric-reference escaping of a character that
/// carries a non-ASCII scalar, or nil when the character is pure ASCII and
/// so is copied verbatim with its run.
private static func plainIfNonASCII(_ character: Character) -> String? {
guard character.unicodeScalars.contains(where: { $0.value > 0x7F }) else { return nil }
return plain(character, asciiOnly: true)
}
/// A character verbatim, or, in ASCII-only mode, each of its non-ASCII
/// scalars as a numeric character reference so the output is pure ASCII.
private static func plain(_ character: Character, asciiOnly: Bool) -> String {
@ -31,21 +126,181 @@ extension PureXML.Emitting {
}
static func attribute(_ value: String, quote: Character = "\"", asciiOnly: Bool = false) -> String {
// Fast path: a value with no character to escape is returned unchanged.
let quoteByte: UInt8 = quote == "'" ? 0x27 : 0x22
if !attributeNeedsEscaping(value, quoteByte: quoteByte, asciiOnly: asciiOnly) {
return value
}
var result = ""
for character in value {
if character == quote {
result += quote == "'" ? "&apos;" : "&quot;"
continue
var runStart = value.startIndex
while let marker = value.utf8[runStart...].firstIndex(where: {
attributeEscapableByte($0, quoteByte: quoteByte, asciiOnly: asciiOnly)
}) {
if runStart < marker {
result += value[runStart ..< marker]
}
switch character {
case "&": result += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\t": result += "&#9;"
case "\n": result += "&#10;"
case "\r": result += "&#13;"
default: result += plain(character, asciiOnly: asciiOnly)
let character = value[marker]
result += attributeEscape(character, quote: quote, asciiOnly: asciiOnly) ?? String(character)
runStart = value.index(after: marker)
}
if runStart < value.endIndex {
result += value[runStart...]
}
return result
}
/// Whether a byte can begin a character ``attribute`` must escape: the
/// quote, an ASCII markup or whitespace byte, or a non-ASCII lead byte in
/// ASCII-only mode.
private static func attributeEscapableByte(_ byte: UInt8, quoteByte: UInt8, asciiOnly: Bool) -> Bool {
switch byte {
case quoteByte, 0x26, 0x3C, 0x3E, 0x09, 0x0A, 0x0D: true
case 0x80... where asciiOnly: true
default: false
}
}
/// The escaped form of one attribute-value character, or nil when it is
/// copied verbatim. Split out so ``attribute`` stays a simple run loop.
private static func attributeEscape(_ character: Character, quote: Character, asciiOnly: Bool) -> String? {
if character == quote {
return quote == "'" ? "&apos;" : "&quot;"
}
switch character {
case "&": return "&amp;"
case "<": return "&lt;"
case ">": return "&gt;"
case "\t": return "&#9;"
case "\n": return "&#10;"
case "\r": return "&#13;"
default: return asciiOnly ? plainIfNonASCII(character) : nil
}
}
/// Whether ``attribute`` would change `value`: it escapes the quote, the
/// markup characters, the whitespace characters, and (in ASCII-only mode)
/// any non-ASCII byte. Every trigger is ASCII bar the non-ASCII lead. Run
/// on every attribute value in a document, so it prefers the same raw
/// contiguous-buffer scan as ``textNeedsEscaping``, for the same measured
/// reason.
private static func attributeNeedsEscaping(_ value: String, quoteByte: UInt8, asciiOnly: Bool) -> Bool {
attributeNeedsEscapingContiguous(value, quoteByte: quoteByte, asciiOnly: asciiOnly)
?? attributeNeedsEscapingCollection(value, quoteByte: quoteByte, asciiOnly: asciiOnly)
}
/// The raw contiguous-buffer scan; see ``textNeedsEscapingContiguous``
/// for why this is a separate function rather than sharing the switch
/// with the fallback through a per-byte helper call.
private static func attributeNeedsEscapingContiguous(_ value: String, quoteByte: UInt8, asciiOnly: Bool) -> Bool? {
value.utf8.withContiguousStorageIfAvailable { buffer -> Bool in
for byte in buffer {
switch byte {
case quoteByte, 0x26, 0x3C, 0x3E, 0x09, 0x0A, 0x0D: return true
case 0x80... where asciiOnly: return true
default: continue
}
}
return false
}
}
/// `Collection`-based fallback; see ``textNeedsEscapingCollection``.
private static func attributeNeedsEscapingCollection(_ value: String, quoteByte: UInt8, asciiOnly: Bool) -> Bool {
for byte in value.utf8 {
switch byte {
case quoteByte, 0x26, 0x3C, 0x3E, 0x09, 0x0A, 0x0D: return true
case 0x80... where asciiOnly: return true
default: continue
}
}
return false
}
/// Makes `value` safe inside a comment: a comment may not contain `--`
/// or end with `-`, so a space is inserted after each offending hyphen
/// (the XSLT 1.0 16 recovery, so `after-` becomes `after- `).
static func comment(_ value: String) -> String {
// Fast path: a comment with no hyphen has no `--` and no trailing `-`,
// so it is returned unchanged rather than rebuilt character by character.
// A raw contiguous-buffer scan for the same measured reason as
// textNeedsEscaping/attributeNeedsEscaping above.
let hasHyphen = value.utf8.withContiguousStorageIfAvailable { buffer -> Bool in
for byte in buffer where byte == 0x2D {
return true
}
return false
} ?? value.utf8.contains(0x2D)
if !hasHyphen {
return value
}
var result = ""
var previousHyphen = false
var runStart = value.startIndex
// Find each candidate hyphen byte, copy the verbatim run before it in
// one append, and resolve it to its full grapheme (a byte match inside
// a combining-mark cluster is not a bare "-" and is copied like any
// other character); only a genuine "-" can extend or start a run.
while let marker = value.utf8[runStart...].firstIndex(where: { $0 == 0x2D }) {
if runStart < marker {
result += value[runStart ..< marker]
previousHyphen = false
}
let character = value[marker]
if character == "-", previousHyphen {
result += " "
}
result.append(character)
previousHyphen = character == "-"
runStart = value.index(after: marker)
}
if runStart < value.endIndex {
result += value[runStart...]
previousHyphen = false
}
if previousHyphen {
result += " "
}
return result
}
/// Makes `data` safe inside a processing instruction: PI data may not
/// contain `?>`, so a space is inserted between an offending `?` and `>`
/// (the XSLT 1.0 7.3 recovery, so `a?>b` becomes `a? >b`).
static func processingInstruction(_ data: String) -> String {
// Fast path: data with no '?' cannot contain `?>`, so it is returned
// unchanged rather than rebuilt character by character. A raw
// contiguous-buffer scan for the same measured reason as
// textNeedsEscaping/attributeNeedsEscaping above.
let hasQuestion = data.utf8.withContiguousStorageIfAvailable { buffer -> Bool in
for byte in buffer where byte == 0x3F {
return true
}
return false
} ?? data.utf8.contains(0x3F)
if !hasQuestion {
return data
}
var result = ""
var previousQuestion = false
var runStart = data.startIndex
// Candidates are '?' (may start the offending pair) and '>' (may close
// it); every other character, including one that merely shares a byte
// with a combining-mark cluster, is bulk-copied and resets the flag.
while let marker = data.utf8[runStart...].firstIndex(where: { $0 == 0x3F || $0 == 0x3E }) {
if runStart < marker {
result += data[runStart ..< marker]
previousQuestion = false
}
let character = data[marker]
if character == ">", previousQuestion {
result += " "
}
result.append(character)
previousQuestion = character == "?"
runStart = data.index(after: marker)
}
if runStart < data.endIndex {
result += data[runStart...]
}
return result
}

View File

@ -85,9 +85,9 @@ public extension PureXML.Emitting {
? Escaping.text(value, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn)
: "<![CDATA[\(value)]]>"
case let .comment(value):
output += "<!--\(value)-->"
output += "<!--\(Escaping.comment(value))-->"
case let .processingInstruction(target, data):
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(Escaping.processingInstruction(data))?>"
}
}
@ -103,9 +103,10 @@ public extension PureXML.Emitting {
}
output += "<" + element.name.description
let quote = options.attributeQuote.character
let quoteString = String(quote)
for attribute in element.attributes {
let value = Escaping.attribute(attribute.value, quote: quote, asciiOnly: options.asciiOnly)
output += " \(attribute.name.description)=\(quote)\(value)\(quote)"
output += " \(attribute.name.description)=\(quoteString)\(value)\(quoteString)"
}
if element.children.isEmpty, options.selfCloseEmptyElements {

View File

@ -99,7 +99,7 @@ public extension PureXML.Emitting {
if options.prettyPrint, !open.isEmpty {
output += options.lineEnding + pad(open.count)
}
output += "<!--\(text)-->"
output += "<!--\(Escaping.comment(text))-->"
markParentHasElements()
}
@ -109,7 +109,7 @@ public extension PureXML.Emitting {
if options.prettyPrint, !open.isEmpty {
output += options.lineEnding + pad(open.count)
}
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(Escaping.processingInstruction(data))?>"
markParentHasElements()
}

View File

@ -37,8 +37,12 @@ extension HTMLDocument {
/// scope boundary.
private func hasInScope(_ target: PureXML.Model.TreeNode) -> Bool {
for node in openBody.reversed() {
if node === target { return true }
if Self.scopeBoundaries.contains(tagName(node)) { return false }
if node === target {
return true
}
if Self.scopeBoundaries.contains(tagName(node)) {
return false
}
}
return false
}
@ -48,7 +52,9 @@ extension HTMLDocument {
/// re-wrapped (the `<b><i></b>X</i>` -> `<b><i></i></b><i>X</i>` behavior).
func reconstructActiveFormatting() {
guard var index = activeFormatting.indices.last else { return }
if activeFormatting[index] == nil || isOpen(activeFormatting[index]) { return }
if activeFormatting[index] == nil || isOpen(activeFormatting[index]) {
return
}
while index > 0 {
index -= 1
if activeFormatting[index] == nil || isOpen(activeFormatting[index]) {
@ -91,7 +97,9 @@ extension HTMLDocument {
activeFormatting.remove(at: formattingIndexInList)
return
}
if !hasInScope(formattingElement) { return }
if !hasInScope(formattingElement) {
return
}
guard let furthestBlockIndex = openBody[(formattingOpenIndex + 1)...].firstIndex(where: { Self.special.contains(tagName($0)) }) else {
openBody.removeLast(openBody.count - formattingOpenIndex)
activeFormatting.remove(at: formattingIndexInList)
@ -104,7 +112,9 @@ extension HTMLDocument {
private func lastFormatting(tag: String) -> Int? {
for index in activeFormatting.indices.reversed() {
guard let entry = activeFormatting[index] else { return nil } // a marker bounds the search
if tagName(entry) == tag { return index }
if tagName(entry) == tag {
return index
}
}
return nil
}
@ -124,7 +134,9 @@ extension HTMLDocument {
inner += 1
nodeIndex -= 1
let node = openBody[nodeIndex]
if node === formattingElement { break }
if node === formattingElement {
break
}
guard let activeIndex = formattingIndex(node) else {
openBody.remove(at: nodeIndex)
continue
@ -132,7 +144,9 @@ extension HTMLDocument {
let clone = node.shallowCopy()
activeFormatting[activeIndex] = clone
openBody[nodeIndex] = clone
if lastNode === furthestBlock { bookmark = activeIndex + 1 }
if lastNode === furthestBlock {
bookmark = activeIndex + 1
}
clone.append(lastNode)
lastNode = clone
}
@ -144,13 +158,19 @@ extension HTMLDocument {
furthestBlock.append(formattingClone)
if let removeIndex = formattingIndex(formattingElement) {
activeFormatting.remove(at: removeIndex)
if removeIndex < bookmark { bookmark -= 1 }
if removeIndex < bookmark {
bookmark -= 1
}
}
activeFormatting.insert(formattingClone, at: min(bookmark, activeFormatting.count))
if let removeOpen = openIndex(formattingElement) { openBody.remove(at: removeOpen) }
if let removeOpen = openIndex(formattingElement) {
openBody.remove(at: removeOpen)
}
// The clone becomes a child of the furthest block, so it sits immediately
// above it on the open-elements stack; inserting below would re-find the
// same furthest block and loop.
if let blockIndex = openIndex(furthestBlock) { openBody.insert(formattingClone, at: blockIndex + 1) }
if let blockIndex = openIndex(furthestBlock) {
openBody.insert(formattingClone, at: blockIndex + 1)
}
}
}

View File

@ -1,9 +1,3 @@
/// The foreign-content namespaces HTML5 switches into. File-scope and private.
private enum ForeignNamespace {
static let svg = "http://www.w3.org/2000/svg"
static let mathml = "http://www.w3.org/1998/Math/MathML"
}
/// The HTML5 tree-construction insertion modes this builder implements: the
/// document-structure subset that establishes the implied `html`, `head`, and
/// `body`. File-scope and private.
@ -84,8 +78,12 @@ final class HTMLDocument {
}
private func processInitial(_ token: Token) {
if case .doctype = token { return }
if isIgnorableWhitespace(token) { return }
if case .doctype = token {
return
}
if isIgnorableWhitespace(token) {
return
}
mode = .beforeHtml
process(token)
}
@ -96,13 +94,17 @@ final class HTMLDocument {
mode = .beforeHead
return
}
if isIgnorableWhitespace(token) { return }
if isIgnorableWhitespace(token) {
return
}
mode = .beforeHead
process(token)
}
private func processBeforeHead(_ token: Token) {
if isIgnorableWhitespace(token) { return }
if isIgnorableWhitespace(token) {
return
}
if case let .startTag(name, attributes, _) = token, name == "head" {
headAttributes = modelAttributes(attributes)
mode = .inHead
@ -140,7 +142,9 @@ final class HTMLDocument {
}
let element = PureXML.Model.TreeNode.element(name, attributes: modelAttributes(attributes))
openHead.last?.append(element)
if !(PureXML.HTML.Elements.void.contains(name) || selfClosing) { openHead.append(element) }
if !(PureXML.HTML.Elements.void.contains(name) || selfClosing) {
openHead.append(element)
}
}
private func headClose(_ name: String) {
@ -168,7 +172,9 @@ final class HTMLDocument {
process(token)
return
}
if isIgnorableWhitespace(token) { return }
if isIgnorableWhitespace(token) {
return
}
mode = .inBody
process(token)
}
@ -210,7 +216,11 @@ final class HTMLDocument {
case let .startTag(name, attributes, _) where name == "noframes":
framesetStack.last?.append(PureXML.Model.TreeNode.element("noframes", attributes: modelAttributes(attributes)))
case let .endTag(name) where name == "frameset":
if framesetStack.count > 1 { framesetStack.removeLast() } else { mode = .afterFrameset }
if framesetStack.count > 1 {
framesetStack.removeLast()
} else {
mode = .afterFrameset
}
case let .comment(value):
framesetStack.last?.append(.comment(value))
default:
@ -223,27 +233,12 @@ final class HTMLDocument {
node.name?.localName.lowercased() ?? ""
}
/// Tags that close an open `<select>` (the "in select" rule): a nested select,
/// or an input/keygen/textarea.
private static let closesSelect: Set<String> = ["select", "input", "keygen", "textarea"]
/// Table-structural tags that close a select which is inside a table (the "in
/// select in table" rule).
private static let closesSelectInTable: Set<String> = ["caption", "table", "tbody", "tfoot", "thead", "tr", "td", "th"]
/// The open-stack index of a `<select>` that an opening table tag should close
/// (a select inside a table), or nil when there is none.
private func selectInTableToClose(for name: String) -> Int? {
guard Self.closesSelectInTable.contains(name),
let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1,
openBody[..<selectIndex].contains(where: { tagName($0) == "table" }) else { return nil }
return selectIndex
}
private func bodyOpen(_ name: String, _ attributes: [(String, String)], selfClosing: Bool) {
if Self.closesSelect.contains(name), let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1 {
openBody.removeLast(openBody.count - selectIndex)
if name == "select" { return } // a nested select just closes the open one
if name == "select" {
return
} // a nested select just closes the open one
}
if let selectIndex = selectInTableToClose(for: name) {
openBody.removeLast(openBody.count - selectIndex)
@ -270,51 +265,6 @@ final class HTMLDocument {
private func bodyInsert(_ node: PureXML.Model.TreeNode) {
openBody.last?.append(node)
}
private func bodyEnsureTableContext(for name: String) {
let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"]
guard let context = openBody.reversed().first(where: { structural.contains(tagName($0)) }).map(tagName) else { return }
switch name {
case "tr" where context == "table":
bodyPushImplied("tbody")
case "td", "th":
if context == "table" { bodyPushImplied("tbody") }
if context == "table" || ["tbody", "thead", "tfoot"].contains(context) { bodyPushImplied("tr") }
default:
break
}
}
private func bodyPushImplied(_ name: String) {
let element = PureXML.Model.TreeNode.element(name)
openBody.last?.append(element)
openBody.append(element)
}
/// Restores SVG attribute names to their canonical camel case.
private func adjustedAttributes(_ attributes: [Attribute], namespace: String?) -> [Attribute] {
guard namespace == ForeignNamespace.svg else { return attributes }
return attributes.map { attribute in
guard let adjusted = PureXML.HTML.ForeignNames.svgAttributes[attribute.name.localName.lowercased()] else { return attribute }
return Attribute(adjusted, attribute.value)
}
}
/// The foreign-content namespace for a body element: SVG/MathML on entry, the
/// nearest open foreign ancestor's, or HTML (nil) inside an integration point.
private func bodyForeignNamespace(for name: String) -> String? {
if name == "svg" { return ForeignNamespace.svg }
if name == "math" { return ForeignNamespace.mathml }
for node in openBody.reversed() {
guard let namespace = node.name?.namespaceURI else { continue }
if Self.svgIntegrationPoints.contains(tagName(node)) { return nil }
return namespace
}
return nil
}
/// The SVG HTML integration points: their content is parsed as HTML.
private static let svgIntegrationPoints: Set<String> = ["foreignobject", "desc", "title"]
}
extension HTMLDocument {
@ -330,7 +280,9 @@ extension HTMLDocument {
}
private func isIgnorableWhitespace(_ token: Token) -> Bool {
if case let .text(value) = token { return value.trimmingXMLWhitespace().isEmpty }
if case let .text(value) = token {
return value.trimmingXMLWhitespace().isEmpty
}
return false
}
}

View File

@ -0,0 +1,75 @@
/// Table-context and foreign-content (SVG/MathML) helpers, split out so
/// HTMLDocument's own body stays within the type-body length budget.
extension HTMLDocument {
/// Tags that close an open `<select>` (the "in select" rule): a nested select,
/// or an input/keygen/textarea.
static let closesSelect: Set<String> = ["select", "input", "keygen", "textarea"]
/// Table-structural tags that close a select which is inside a table (the "in
/// select in table" rule).
private static let closesSelectInTable: Set<String> = ["caption", "table", "tbody", "tfoot", "thead", "tr", "td", "th"]
/// The SVG HTML integration points: their content is parsed as HTML.
private static let svgIntegrationPoints: Set<String> = ["foreignobject", "desc", "title"]
/// The open-stack index of a `<select>` that an opening table tag should close
/// (a select inside a table), or nil when there is none.
func selectInTableToClose(for name: String) -> Int? {
guard Self.closesSelectInTable.contains(name),
let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1,
openBody[..<selectIndex].contains(where: { tagName($0) == "table" }) else { return nil }
return selectIndex
}
func bodyEnsureTableContext(for name: String) {
let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"]
guard let context = openBody.reversed().first(where: { structural.contains(tagName($0)) }).map(tagName) else { return }
switch name {
case "tr" where context == "table":
bodyPushImplied("tbody")
case "td", "th":
if context == "table" {
bodyPushImplied("tbody")
}
if context == "table" || ["tbody", "thead", "tfoot"].contains(context) {
bodyPushImplied("tr")
}
default:
break
}
}
private func bodyPushImplied(_ name: String) {
let element = PureXML.Model.TreeNode.element(name)
openBody.last?.append(element)
openBody.append(element)
}
/// Restores SVG attribute names to their canonical camel case.
func adjustedAttributes(_ attributes: [Attribute], namespace: String?) -> [Attribute] {
guard namespace == ForeignNamespace.svg else { return attributes }
return attributes.map { attribute in
guard let adjusted = PureXML.HTML.ForeignNames.svgAttributes[attribute.name.localName.lowercased()] else { return attribute }
return Attribute(adjusted, attribute.value)
}
}
/// The foreign-content namespace for a body element: SVG/MathML on entry, the
/// nearest open foreign ancestor's, or HTML (nil) inside an integration point.
func bodyForeignNamespace(for name: String) -> String? {
if name == "svg" {
return ForeignNamespace.svg
}
if name == "math" {
return ForeignNamespace.mathml
}
for node in openBody.reversed() {
guard let namespace = node.name?.namespaceURI else { continue }
if Self.svgIntegrationPoints.contains(tagName(node)) {
return nil
}
return namespace
}
return nil
}
}

View File

@ -59,13 +59,17 @@ extension PureXML.HTML.Tokenizer {
private static func decodeNumeric(_ characters: [Character], at start: Int) -> (String, Int)? {
var cursor = start + 2 // past "&#"
let isHex = cursor < characters.count && (characters[cursor] == "x" || characters[cursor] == "X")
if isHex { cursor += 1 }
if isHex {
cursor += 1
}
let digitsStart = cursor
while cursor < characters.count, isDigit(characters[cursor], hex: isHex) {
cursor += 1
}
guard cursor > digitsStart, let number = UInt32(String(characters[digitsStart ..< cursor]), radix: isHex ? 16 : 10) else { return nil }
if cursor < characters.count, characters[cursor] == ";" { cursor += 1 }
if cursor < characters.count, characters[cursor] == ";" {
cursor += 1
}
return (String(numericCharacter(number)), cursor - start)
}

View File

@ -0,0 +1,5 @@
/// The foreign-content namespaces HTML5 switches into.
enum ForeignNamespace {
static let svg = "http://www.w3.org/2000/svg"
static let mathml = "http://www.w3.org/1998/Math/MathML"
}

View File

@ -5,57 +5,103 @@ extension PureXML.HTML {
/// attribute values are escaped per HTML.
enum Serializer {
static func serialize(_ node: PureXML.Model.Node) -> String {
// Deferred-close work stack so a deeply-nested tree does not overflow
// the stack (the same shape as the XML serializer and Canonicalizer);
// children push reversed to emit in document order.
var output = ""
var stack: [HTMLSerializeStep] = [.node(node, rawTextParent: false)]
while let step = stack.popLast() {
switch step {
case let .close(name):
output += "</\(name)>"
case let .node(node, rawTextParent):
emit(node, rawTextParent: rawTextParent, into: &output, stack: &stack)
}
}
return output
}
private static func emit(
_ node: PureXML.Model.Node,
rawTextParent: Bool,
into output: inout String,
stack: inout [HTMLSerializeStep],
) {
switch node {
case let .document(children):
children.map(serialize).joined()
stack.append(contentsOf: children.reversed().map { HTMLSerializeStep.node($0, rawTextParent: false) })
case let .element(element):
serializeElement(element)
let name = element.name.description
output += "<" + name
for attribute in element.attributes {
output += attributeText(attribute)
}
output += ">"
// A void element has no end tag and no content (the html model);
// anything nested is dropped, as the recursive form did.
if Elements.void.contains(name.lowercased()) {
return
}
stack.append(HTMLSerializeStep.close(name))
// A raw-text element (script, style) writes its own text children
// verbatim; nested elements still serialize normally by their own
// name, so the flag governs only this element's direct text.
let childRawText = Elements.rawText.contains(name.lowercased())
stack.append(contentsOf: element.children.reversed().map { HTMLSerializeStep.node($0, rawTextParent: childRawText) })
case let .text(value), let .cdata(value):
escapeText(value)
output += rawTextParent ? value : escapeText(value)
case let .comment(value):
"<!--\(value)-->"
output += "<!--\(PureXML.Emitting.Escaping.comment(value))-->"
case let .processingInstruction(target, data):
data.isEmpty ? "<?\(target)>" : "<?\(target) \(data)>"
}
}
private static func serializeElement(_ element: PureXML.Model.Element) -> String {
let name = element.name.description
var output = "<" + name
for attribute in element.attributes {
output += attributeText(attribute)
}
output += ">"
if Elements.void.contains(name.lowercased()) {
return output
}
output += content(of: element, name: name)
return output + "</\(name)>"
}
private static func content(of element: PureXML.Model.Element, name: String) -> String {
if Elements.rawText.contains(name.lowercased()) {
return element.children.map(rawText).joined()
}
return element.children.map(serialize).joined()
}
private static func rawText(_ node: PureXML.Model.Node) -> String {
switch node {
case let .text(value), let .cdata(value): value
default: serialize(node)
output += data.isEmpty ? "<?\(target)>" : "<?\(target) \(data)>"
}
}
private static func attributeText(_ attribute: PureXML.Model.Attribute) -> String {
let name = attribute.name.description
if attribute.value.isEmpty { return " \(name)" }
if attribute.value.isEmpty {
return " \(name)"
}
// Boolean attributes minimize when the value repeats the name
// (CHECKED="CHECKED" serializes as CHECKED), the html form.
if Elements.booleanAttributes.contains(name.lowercased()), attribute.value.lowercased() == name.lowercased() {
return " \(name)"
}
return " \(name)=\"\(escapeAttribute(attribute.value))\""
let escaped = Elements.uriAttributes.contains(name.lowercased())
? escapeURI(attribute.value)
: escapeAttribute(attribute.value)
return " \(name)=\"\(escaped)\""
}
/// Escapes a URI-valued attribute value (XSLT 1.0 16.2, HTML 4.01 B.2.1,
/// the libxml2 HTML output model): each non-ASCII or control character, and
/// every space (leading ones included, where libxml2 skips them), is
/// percent-escaped as the uppercase hex of its UTF-8 bytes, while `"` and
/// `&` keep their entity form. A parse-then-serialize round-trip thus
/// normalizes a space or non-ASCII byte in these attributes to `%HH`; this
/// is idempotent, since a literal `%` is left as is.
private static func escapeURI(_ value: String) -> String {
var result = ""
for scalar in value.unicodeScalars {
if scalar.value > 0x20, scalar.value < 0x7F {
switch scalar {
case "\"": result += "&quot;"
case "&": result += "&amp;"
default: result.unicodeScalars.append(scalar)
}
} else {
for byte in String(scalar).utf8 {
result += "%" + hexByte(byte)
}
}
}
return result
}
/// A byte as two uppercase hex digits, without Foundation formatting.
private static func hexByte(_ byte: UInt8) -> String {
let digits = Array("0123456789ABCDEF")
return String([digits[Int(byte >> 4)], digits[Int(byte & 0x0F)]])
}
private static func escapeText(_ value: String) -> String {
@ -85,11 +131,14 @@ extension PureXML.HTML {
case "&":
previousWasAmpersand = true
case "\"": result += "&quot;"
case "<": result += "&lt;"
// The html output method does not escape `<` (or `>`) in an
// attribute value, unlike the xml method (XSLT 1.0 16.2).
default: result += latin1Entity(scalar) ?? String(Character(scalar))
}
}
if previousWasAmpersand { result += "&amp;" }
if previousWasAmpersand {
result += "&amp;"
}
return result
}
@ -141,3 +190,12 @@ public extension PureXML.HTML {
Serializer.serialize(node)
}
}
/// One unit of HTML serialization work: a node to emit (with whether its parent
/// is a raw-text element, so its own text is written unescaped) or a close tag
/// deferred until after the element's children. File-scoped so the serializer's
/// deferred-close work stack is not a third level of type nesting.
private enum HTMLSerializeStep {
case node(PureXML.Model.Node, rawTextParent: Bool)
case close(String)
}

View File

@ -43,6 +43,14 @@ extension PureXML.HTML {
"defer", "declare", "noresize", "nowrap", "noshade", "compact",
]
/// HTML 4 attributes whose value is a URI (type %URI). The html output
/// method percent-escapes non-ASCII and control characters in their
/// values (XSLT 1.0 16.2, HTML 4.01 appendix B.2.1).
static let uriAttributes: Set<String> = [
"action", "archive", "background", "cite", "classid", "codebase",
"data", "href", "longdesc", "profile", "src", "usemap",
]
/// RCDATA elements: like raw-text, but character references in their
/// content are decoded (`title`, `textarea`). The remaining raw-text
/// elements (`script`, `style`) take their content with no decoding.

View File

@ -27,10 +27,18 @@ extension PureXML.HTML {
return rawText(until: raw)
}
guard peek() == "<" else { return text() }
if matches("<!--") { return comment() }
if matches("<!") { return doctype() }
if matches("</") { return endTag() }
if peek(1)?.isLetter == true { return startTag() }
if matches("<!--") {
return comment()
}
if matches("<!") {
return doctype()
}
if matches("</") {
return endTag()
}
if peek(1)?.isLetter == true {
return startTag()
}
return text()
}
@ -38,7 +46,9 @@ extension PureXML.HTML {
private mutating func text() -> Token {
var value = ""
if let first = advance() { value.append(first) }
if let first = advance() {
value.append(first)
}
while let character = peek(), character != "<" {
value.append(character)
advance()
@ -53,16 +63,21 @@ extension PureXML.HTML {
var selfClosing = false
while !isAtEnd {
skipSpace()
if consume("/>") { selfClosing = true
if consume("/>") {
selfClosing = true
break
}
if consume(">") {
break
}
if consume(">") { break }
guard let attribute = readAttribute() else { advance()
continue
}
attributes.append(attribute)
}
if Elements.rawText.contains(name) { rawTextName = name }
if Elements.rawText.contains(name) {
rawTextName = name
}
return .startTag(name: name, attributes: attributes, selfClosing: selfClosing)
}
@ -133,9 +148,13 @@ extension PureXML.HTML {
}
value = Self.replacingNull(value)
// RCDATA decodes character references; raw text (script/style) does not.
if Elements.rcdata.contains(name) { value = Self.decodeEntities(value) }
if Elements.rcdata.contains(name) {
value = Self.decodeEntities(value)
}
// A single leading newline after <textarea> is stripped (the HTML rule).
if name == "textarea", value.first == "\n" { value.removeFirst() }
if name == "textarea", value.first == "\n" {
value.removeFirst()
}
return .text(value)
}

View File

@ -1,9 +1,41 @@
public extension PureXML.Model {
/// An XML element: a qualified name, ordered attributes, and ordered child nodes.
struct Element: Equatable, Hashable, Sendable {
///
/// A value type with full value semantics: copying an element copies its
/// children (lazily, copy-on-write). The children are held behind a reference
/// (``ElementStorage``) so a deeply-nested tree neither overflows the stack on
/// release nor on equality and hashing, which walk the tree iteratively.
struct Element: Sendable {
public var name: QualifiedName
public var attributes: [Attribute]
public var children: [Node]
/// Copy-on-write backing; `children` is the public, value-semantic view.
var storage: ElementStorage
/// The element's child nodes. Reads share the backing storage; a write
/// first copies the storage if it is shared, preserving value semantics.
///
/// The `_modify` accessor yields the backing array in place (after the
/// copy-on-write check), so an in-place mutation such as
/// `element.children.append(_:)` does not copy the whole array on every
/// call. Without it a computed property would read, mutate a temporary,
/// and write back, making repeated appends quadratic (which is exactly the
/// pattern the parser uses while building an element).
public var children: [Node] {
get { storage.children }
_modify {
if !isKnownUniquelyReferenced(&storage) {
storage = ElementStorage(storage.children)
}
yield &storage.children
}
set {
if isKnownUniquelyReferenced(&storage) {
storage.children = newValue
} else {
storage = ElementStorage(newValue)
}
}
}
public init(
name: QualifiedName,
@ -12,7 +44,7 @@ public extension PureXML.Model {
) {
self.name = name
self.attributes = attributes
self.children = children
storage = ElementStorage(children)
}
public init(
@ -40,3 +72,16 @@ public extension PureXML.Model {
}
}
}
extension PureXML.Model.Element: Equatable, Hashable {
/// Value equality over the whole subtree, compared iteratively (see
/// ``PureXML/Model/Node`` `==`) so a deeply-nested element does not recurse.
public static func == (lhs: Self, rhs: Self) -> Bool {
PureXML.Model.Node.treesEqual(.element(lhs), .element(rhs))
}
/// Hashes the whole subtree iteratively, consistent with `==`.
public func hash(into hasher: inout Hasher) {
PureXML.Model.Node.element(self).hash(into: &hasher)
}
}

View File

@ -0,0 +1,40 @@
extension PureXML.Model {
/// Copy-on-write backing for an ``Element``'s children.
///
/// `Element` is a value type, but its children must live behind a reference so
/// the tree's depth does not become part of any value's recursive layout: a
/// chain of value-nested children would otherwise release one native frame per
/// level and overflow the stack on a deeply-nested document. Holding children
/// in this class gives the tree exactly one place to release them, where a
/// custom `deinit` can flatten the subtree iteratively.
///
/// `@unchecked Sendable`: the class is mutable, but ``Element`` only ever
/// mutates `children` after copying a shared box (the copy-on-write check in
/// `Element.children`'s setter), so a box reachable from more than one value is
/// never written. That is the same discipline the standard library's own
/// copy-on-write containers rely on to be `Sendable`.
final class ElementStorage: @unchecked Sendable {
var children: [PureXML.Model.Node]
init(_ children: [PureXML.Model.Node]) {
self.children = children
}
/// Releases the subtree without recursing on depth. A naive release walks
/// one native frame per level (each box releasing its children's boxes);
/// instead each uniquely-owned descendant's children are moved into a flat
/// heap worklist and cleared before that node is released, so every box
/// frees with empty children and the teardown stays flat. A node whose
/// storage is still shared (a copy-on-write sibling holds it) is left
/// intact and tears down later through this same `deinit`.
deinit {
guard !children.isEmpty else { return }
var pending = children
children = []
while !pending.isEmpty {
var node = pending.removeLast()
node.drainUniquelyOwnedChildren(into: &pending)
}
}
}
}

View File

@ -2,7 +2,13 @@ public extension PureXML.Model {
/// A node in an XML tree. The model preserves document order and the
/// distinction between text, CDATA, comments, and processing instructions
/// so that emitting can round-trip a parsed document.
indirect enum Node: Equatable, Hashable, Sendable {
///
/// Not `indirect`: an element holds its children behind a reference
/// (``ElementStorage``), so a `Node` is a fixed-size value and the tree's
/// depth is not part of any value's layout. Equality, hashing, and release
/// all walk the tree iteratively, so an arbitrarily deep document never
/// overflows the call stack.
enum Node: Sendable {
/// The root of a parsed document, holding its prolog and root element.
case document([Node])
/// An element with a name, attributes, and children.
@ -23,3 +29,105 @@ public extension PureXML.Model {
}
}
}
extension PureXML.Model.Node: Equatable, Hashable {
public static func == (lhs: Self, rhs: Self) -> Bool {
treesEqual(lhs, rhs)
}
/// Whether two node trees are equal, compared iteratively through an explicit
/// pair stack so a deeply-nested tree does not recurse one frame per level.
static func treesEqual(_ lhs: Self, _ rhs: Self) -> Bool {
var stack: [(Self, Self)] = [(lhs, rhs)]
while let (left, right) = stack.popLast() {
switch (left, right) {
case let (.element(leftElement), .element(rightElement)):
guard leftElement.name == rightElement.name,
leftElement.attributes == rightElement.attributes,
leftElement.children.count == rightElement.children.count
else { return false }
for pair in zip(leftElement.children, rightElement.children) {
stack.append(pair)
}
case let (.document(leftChildren), .document(rightChildren)):
guard leftChildren.count == rightChildren.count else { return false }
for pair in zip(leftChildren, rightChildren) {
stack.append(pair)
}
default:
if !leavesEqual(left, right) {
return false
}
}
}
return true
}
/// Equality for the non-branch node kinds (and a kind mismatch); the branch
/// kinds are walked iteratively by ``treesEqual(_:_:)``.
private static func leavesEqual(_ left: Self, _ right: Self) -> Bool {
switch (left, right) {
case let (.text(leftValue), .text(rightValue)): leftValue == rightValue
case let (.cdata(leftValue), .cdata(rightValue)): leftValue == rightValue
case let (.comment(leftValue), .comment(rightValue)): leftValue == rightValue
case let (.processingInstruction(leftTarget, leftData), .processingInstruction(rightTarget, rightData)):
leftTarget == rightTarget && leftData == rightData
default: false
}
}
/// Hashes the whole subtree iteratively (pre-order), consistent with
/// ``treesEqual(_:_:)``: equal trees feed the hasher the same sequence.
public func hash(into hasher: inout Hasher) {
var stack: [Self] = [self]
while let node = stack.popLast() {
switch node {
case let .element(element):
hasher.combine(0)
hasher.combine(element.name)
hasher.combine(element.attributes)
hasher.combine(element.children.count)
for child in element.children {
stack.append(child)
}
case let .document(children):
hasher.combine(1)
hasher.combine(children.count)
for child in children {
stack.append(child)
}
case let .text(value):
hasher.combine(2)
hasher.combine(value)
case let .cdata(value):
hasher.combine(3)
hasher.combine(value)
case let .comment(value):
hasher.combine(4)
hasher.combine(value)
case let .processingInstruction(target, data):
hasher.combine(5)
hasher.combine(target)
hasher.combine(data)
}
}
}
}
extension PureXML.Model.Node {
/// Used by ``ElementStorage`` teardown: if this is an element whose backing
/// storage this node solely owns, move its children into `pending` and clear
/// them so the node releases flat; otherwise leave it untouched. Setting
/// `self` aside before the uniqueness check drops this node's own hold on the
/// storage, so the check counts only references held elsewhere (shared
/// copy-on-write siblings), which must keep their subtree.
mutating func drainUniquelyOwnedChildren(into pending: inout [Self]) {
guard case var .element(element) = self else { return }
self = .text("")
if isKnownUniquelyReferenced(&element.storage), !element.storage.children.isEmpty {
pending.append(contentsOf: element.storage.children)
element.storage.children = []
}
self = .element(element)
}
}

View File

@ -0,0 +1,64 @@
extension PureXML.Model.Node {
/// Rebuilds this tree bottom-up without recursing on the tree's depth, so a
/// transform over a deeply-nested document cannot overflow the stack. Each
/// node is passed to `transform` once its children have been rebuilt, together
/// with those rebuilt children (empty for a leaf); the transform returns the
/// replacement node. An explicit work stack drives a post-order walk, so a
/// parent is transformed only after all its descendants.
///
/// This is the shared spine for the tree-to-tree transforms (DTD default
/// application, canonical prefix rewriting, and the like) that previously
/// recursed one native frame per level.
func rebuildingBottomUp(
_ transform: (_ node: PureXML.Model.Node, _ rebuiltChildren: [PureXML.Model.Node]) -> PureXML.Model.Node,
) -> PureXML.Model.Node {
guard let rootChildren = Self.branchChildren(self) else {
return transform(self, [])
}
let rootFrame = RebuildFrame(self, rootChildren)
var stack: [RebuildFrame] = [rootFrame]
while let frame = stack.last {
guard frame.next < frame.children.count else {
stack.removeLast()
let rebuilt = transform(frame.node, frame.built)
if let parent = stack.last {
parent.built.append(rebuilt)
} else {
return rebuilt
}
continue
}
let child = frame.children[frame.next]
frame.next += 1
if let grandchildren = Self.branchChildren(child) {
stack.append(RebuildFrame(child, grandchildren))
} else {
frame.built.append(transform(child, []))
}
}
return transform(self, [])
}
/// The children of a branch node (document or element), or nil for a leaf.
private static func branchChildren(_ node: PureXML.Model.Node) -> [PureXML.Model.Node]? {
switch node {
case let .document(children): children
case let .element(element): element.children
default: nil
}
}
}
/// A node being rebuilt bottom-up: its source children, the cursor into them, and
/// the rebuilt children gathered so far.
private final class RebuildFrame {
let node: PureXML.Model.Node
let children: [PureXML.Model.Node]
var next = 0
var built: [PureXML.Model.Node] = []
init(_ node: PureXML.Model.Node, _ children: [PureXML.Model.Node]) {
self.node = node
self.children = children
}
}

View File

@ -45,6 +45,31 @@ public extension PureXML.Model {
}
}
/// Builds a name whose first colon is already known by byte offset (the
/// byte scanner found it while reading the name), so the prefix split
/// needs no second scan. `colonOffset` is nil for the common unprefixed
/// name. The split follows ``init(_:)`` exactly: a colon at the start or
/// end (`:local`, `prefix:`) does not split. The name is all-ASCII, so a
/// byte offset is a character offset.
init(ascii raw: String, colonOffset: Int?) {
namespaceURI = nil
guard let colonOffset else {
prefix = nil
localName = raw
return
}
let colon = raw.index(raw.startIndex, offsetBy: colonOffset)
let head = String(raw[raw.startIndex ..< colon])
let tail = String(raw[raw.index(after: colon)...])
if head.isEmpty || tail.isEmpty {
prefix = nil
localName = raw
} else {
prefix = head
localName = tail
}
}
/// The full `prefix:local` rendering used in serialized output.
public var description: String {
guard let prefix else { return localName }

View File

@ -86,6 +86,60 @@ public extension PureXML.Model {
}
}
/// Adopts an array of freshly built, parentless children directly,
/// wiring each child's parent without the detach, cycle check, and
/// one-at-a-time array growth that ``append(_:)`` needs for children
/// that may already live in a tree. The caller guarantees every child
/// is newly constructed and unshared (as the value-`Node` conversion
/// does), so those guards would all be no-ops; skipping them, and
/// adopting the prepared array rather than rebuilding it element by
/// element, is the bulk of the cost of materializing a parsed tree.
init(
adopting kind: PureXML.Model.TreeNodeKind,
name: PureXML.Model.QualifiedName? = nil,
attributes: [PureXML.Model.Attribute] = [],
value: String = "",
children: [TreeNode],
) {
self.kind = kind
self.name = name
self.attributes = attributes
self.value = value
self.children = children
for child in children {
child.parent = self
}
}
/// Tears the subtree down iteratively so releasing a deeply-nested tree
/// stays in bounded native stack. The implicit release walk would
/// otherwise recurse one frame per level (a chain of child releases), and
/// a naive `deinit` that let each child deallocate in turn would just nest
/// the same way. Instead, each node's children are moved into a flat heap
/// worklist and cleared before that node is released, so its own `deinit`
/// finds an empty `children` and returns at once: the teardown is flat.
///
/// Only a node this teardown uniquely owns is drained; a node still held
/// elsewhere (an external reference into the tree) keeps its subtree
/// intact and tears down later through this same `deinit`.
deinit {
guard !children.isEmpty else { return }
var pending = children
children = []
while !pending.isEmpty {
// `removeLast` moves the node into `node` as the sole binding, so
// `isKnownUniquelyReferenced` sees no spurious reference (a
// `while let`/`popLast` binding would itself count). A node still
// referenced elsewhere keeps its subtree intact; only a node this
// teardown solely owns is flattened, so its release stays shallow.
var node = pending.removeLast()
if isKnownUniquelyReferenced(&node), !node.children.isEmpty {
pending.append(contentsOf: node.children)
node.children = []
}
}
}
// MARK: Factories
/// Creates a document node holding the given top-level children.
@ -140,8 +194,12 @@ public extension PureXML.Model {
internalSubset: String = "",
) -> TreeNode {
var attributes: [PureXML.Model.Attribute] = []
if let publicID { attributes.append(PureXML.Model.Attribute("public", publicID)) }
if let systemID { attributes.append(PureXML.Model.Attribute("system", systemID)) }
if let publicID {
attributes.append(PureXML.Model.Attribute("public", publicID))
}
if let systemID {
attributes.append(PureXML.Model.Attribute("system", systemID))
}
return TreeNode(kind: .doctype, name: PureXML.Model.QualifiedName(name), attributes: attributes, value: internalSubset)
}
@ -149,7 +207,7 @@ public extension PureXML.Model {
/// replacement content. `value` mirrors the children's text for quick use.
public static func entityReference(_ name: String, children: [TreeNode] = []) -> TreeNode {
let node = TreeNode(kind: .entityReference, name: PureXML.Model.QualifiedName(name), children: children)
node.value = children.reduce(into: "") { $0 += $1.stringValue }
node.value = children.reduce(into: "") { $0 += $1.textContent }
return node
}

View File

@ -1,15 +1,19 @@
public extension PureXML.Model.TreeNode {
/// Builds a mutable tree from a parsed value ``Node``, wiring up parent links.
///
/// The conversion is iterative, not recursive on depth: a deeply-nested
/// document is built bottom-up through an explicit work stack so construction
/// stays in bounded native stack (see ``buildForest(_:)``).
convenience init(_ node: PureXML.Model.Node) {
switch node {
case let .document(children):
self.init(kind: .document, children: children.map(PureXML.Model.TreeNode.init))
self.init(adopting: .document, children: Self.buildForest(children))
case let .element(element):
self.init(
kind: .element,
adopting: .element,
name: element.name,
attributes: element.attributes,
children: element.children.map(PureXML.Model.TreeNode.init),
children: Self.buildForest(element.children),
)
case let .text(value):
self.init(kind: .text, value: value)
@ -22,17 +26,46 @@ public extension PureXML.Model.TreeNode {
}
}
/// Builds the `TreeNode` children for a forest of value ``Node``s without
/// recursing on tree depth. A frame holds a branch node (element or document)
/// being converted: the position of the next source child to process and the
/// `TreeNode` children built so far. Leaves convert directly; a branch is
/// assembled (adopting its built children, which sets their parent links) only
/// once all its source children are done, then handed to its own parent frame.
/// A synthetic document frame carries the roots; it is never assembled (that
/// would re-parent them), so its built children are the result.
private static func buildForest(_ roots: [PureXML.Model.Node]) -> [PureXML.Model.TreeNode] {
let rootFrame = BuildFrame(.document(roots), roots)
var stack: [BuildFrame] = [rootFrame]
while let frame = stack.last {
guard frame.next < frame.children.count else {
stack.removeLast()
guard let parent = stack.last else { break }
parent.built.append(buildBranch(frame))
continue
}
let child = frame.children[frame.next]
frame.next += 1
if let grandchildren = branchChildren(child) {
stack.append(BuildFrame(child, grandchildren))
} else {
frame.built.append(buildLeaf(child))
}
}
return rootFrame.built
}
/// Rebuilds an immutable value ``Node`` from this subtree, for serialization
/// or value comparison.
/// or value comparison. Iterative on depth (see ``projectedChildren(of:)``).
var node: PureXML.Model.Node {
switch kind {
case .document:
.document(projectedChildren)
.document(Self.projectedChildren(of: self))
case .element:
.element(PureXML.Model.Element(
name: name ?? PureXML.Model.QualifiedName(""),
attributes: attributes,
children: projectedChildren,
children: Self.projectedChildren(of: self),
))
case .text:
.text(value)
@ -52,19 +85,208 @@ public extension PureXML.Model.TreeNode {
}
}
/// The value-`Node` children of this subtree: doctype and namespace nodes are
/// The value-`Node` children of `root`'s subtree, projected into a pure
/// content tree without recursing on depth: doctype and namespace nodes are
/// dropped (they carry no content), and an entity-reference node is spliced
/// open into its replacement nodes, so the projection is a pure content tree.
private var projectedChildren: [PureXML.Model.Node] {
children.flatMap { child -> [PureXML.Model.Node] in
/// open into its replacement nodes. A frame accumulates a branch node's
/// projected children; on completion an element or document frame wraps them
/// in the corresponding value `Node`, while an entity-reference frame splices
/// them into its parent (no wrapper).
private static func projectedChildren(of root: PureXML.Model.TreeNode) -> [PureXML.Model.Node] {
let rootFrame = ProjectFrame(root, splice: false)
var stack: [ProjectFrame] = [rootFrame]
while let frame = stack.last {
guard frame.next < frame.tree.children.count else {
stack.removeLast()
guard let parent = stack.last else { break }
finishProjection(frame, into: parent)
continue
}
let child = frame.tree.children[frame.next]
frame.next += 1
switch child.kind {
case .doctype, .namespace:
[]
continue
case .entityReference:
child.projectedChildren
stack.append(ProjectFrame(child, splice: true))
case .element, .document:
stack.append(ProjectFrame(child, splice: false))
default:
[child.node]
frame.built.append(projectLeaf(child))
}
}
return rootFrame.built
}
// MARK: Build helpers
private static func branchChildren(_ node: PureXML.Model.Node) -> [PureXML.Model.Node]? {
switch node {
case let .document(children): children
case let .element(element): element.children
default: nil
}
}
private static func buildLeaf(_ node: PureXML.Model.Node) -> PureXML.Model.TreeNode {
switch node {
case let .text(value): PureXML.Model.TreeNode(kind: .text, value: value)
case let .cdata(value): PureXML.Model.TreeNode(kind: .cdata, value: value)
case let .comment(value): PureXML.Model.TreeNode(kind: .comment, value: value)
case let .processingInstruction(target, data):
PureXML.Model.TreeNode(kind: .processingInstruction, name: PureXML.Model.QualifiedName(target), value: data)
// Unreachable: `buildLeaf` is only called when `branchChildren` returned
// nil (a non-branch node). An empty text node keeps the conversion total.
case .document, .element: PureXML.Model.TreeNode(kind: .text, value: "")
}
}
private static func buildBranch(_ frame: BuildFrame) -> PureXML.Model.TreeNode {
switch frame.node {
case let .element(element):
PureXML.Model.TreeNode(adopting: .element, name: element.name, attributes: element.attributes, children: frame.built)
// Only branch nodes (document or element) get a frame; a non-branch frame
// is unreachable, so the document shape is a total fallback.
default:
PureXML.Model.TreeNode(adopting: .document, children: frame.built)
}
}
// MARK: Projection helpers
private static func projectLeaf(_ tree: PureXML.Model.TreeNode) -> PureXML.Model.Node {
switch tree.kind {
case .cdata: .cdata(tree.value)
case .comment: .comment(tree.value)
case .processingInstruction:
.processingInstruction(target: tree.name?.description ?? "", data: tree.value)
// doctype/namespace are dropped before reaching here; everything else
// projects as the text leaf.
default: .text(tree.value)
}
}
/// Resolves a completed frame into `parent`'s children: an entity-reference
/// frame splices its projected nodes in, an element wraps them, and any other
/// branch (a document) wraps them as a document.
private static func finishProjection(_ frame: ProjectFrame, into parent: ProjectFrame) {
if frame.splice {
parent.built.append(contentsOf: frame.built)
} else if case .element = frame.tree.kind {
parent.built.append(.element(PureXML.Model.Element(
name: frame.tree.name ?? PureXML.Model.QualifiedName(""),
attributes: frame.tree.attributes,
children: frame.built,
)))
} else {
parent.built.append(.document(frame.built))
}
}
/// Like ``node`` but reuses subtrees already projected during this pass through
/// `memo` (keyed by tree-node identity). Projecting several result nodes that
/// nest within one another then shares structure (copy-on-write) instead of
/// rebuilding each subtree, so a result set of nested nodes is linear rather
/// than quadratic. The projection is identical to ``node``.
func node(memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> PureXML.Model.Node {
if let cached = memo[ObjectIdentifier(self)] {
return cached
}
let result: PureXML.Model.Node = switch kind {
case .document:
.document(Self.projectedChildren(of: self, memo: &memo))
case .element:
.element(PureXML.Model.Element(
name: name ?? PureXML.Model.QualifiedName(""),
attributes: attributes,
children: Self.projectedChildren(of: self, memo: &memo),
))
case .text: .text(value)
case .entityReference: .text(stringValue)
case .cdata: .cdata(value)
case .comment: .comment(value)
case .processingInstruction: .processingInstruction(target: name?.description ?? "", data: value)
case .doctype, .namespace: .text("")
}
memo[ObjectIdentifier(self)] = result
return result
}
/// ``projectedChildren(of:)`` that caches each element/document subtree in
/// `memo` and reuses an already-projected child instead of walking it again.
private static func projectedChildren(of root: PureXML.Model.TreeNode, memo: inout [ObjectIdentifier: PureXML.Model.Node]) -> [PureXML.Model.Node] {
let rootFrame = ProjectFrame(root, splice: false)
var stack: [ProjectFrame] = [rootFrame]
while let frame = stack.last {
guard frame.next < frame.tree.children.count else {
stack.removeLast()
guard let parent = stack.last else { break }
finishProjection(frame, into: parent, memo: &memo)
continue
}
let child = frame.tree.children[frame.next]
frame.next += 1
switch child.kind {
case .doctype, .namespace:
continue
case .entityReference:
stack.append(ProjectFrame(child, splice: true))
case .element, .document:
if let cached = memo[ObjectIdentifier(child)] {
frame.built.append(cached)
} else {
stack.append(ProjectFrame(child, splice: false))
}
default:
frame.built.append(projectLeaf(child))
}
}
return rootFrame.built
}
/// ``finishProjection(_:into:)`` that also records an element or document
/// frame's projected node in `memo` for reuse.
private static func finishProjection(_ frame: ProjectFrame, into parent: ProjectFrame, memo: inout [ObjectIdentifier: PureXML.Model.Node]) {
if frame.splice {
parent.built.append(contentsOf: frame.built)
return
}
let node: PureXML.Model.Node = if case .element = frame.tree.kind {
.element(PureXML.Model.Element(
name: frame.tree.name ?? PureXML.Model.QualifiedName(""),
attributes: frame.tree.attributes,
children: frame.built,
))
} else {
.document(frame.built)
}
memo[ObjectIdentifier(frame.tree)] = node
parent.built.append(node)
}
}
/// A branch node being converted from a value ``Node`` to a ``TreeNode``: the
/// source children, the cursor into them, and the converted children so far.
private final class BuildFrame {
let node: PureXML.Model.Node
let children: [PureXML.Model.Node]
var next = 0
var built: [PureXML.Model.TreeNode] = []
init(_ node: PureXML.Model.Node, _ children: [PureXML.Model.Node]) {
self.node = node
self.children = children
}
}
/// A ``TreeNode`` being projected back to a value ``Node``: `splice` marks an
/// entity-reference frame whose projected children fold into its parent.
private final class ProjectFrame {
let tree: PureXML.Model.TreeNode
let splice: Bool
var next = 0
var built: [PureXML.Model.Node] = []
init(_ tree: PureXML.Model.TreeNode, splice: Bool) {
self.tree = tree
self.splice = splice
}
}

View File

@ -45,7 +45,9 @@ public extension PureXML.Model.TreeNode {
/// comparable only when they share a root; an unrelated node returns false. An
/// ancestor precedes its descendants.
func precedes(_ other: PureXML.Model.TreeNode) -> Bool {
if self === other { return false }
if self === other {
return false
}
let mine = Self.pathFromRoot(self)
let theirs = Self.pathFromRoot(other)
guard mine.first === theirs.first else { return false }
@ -53,8 +55,12 @@ public extension PureXML.Model.TreeNode {
while depth < mine.count, depth < theirs.count, mine[depth] === theirs[depth] {
depth += 1
}
if depth == mine.count { return true }
if depth == theirs.count { return false }
if depth == mine.count {
return true
}
if depth == theirs.count {
return false
}
let parent = mine[depth - 1]
let mineIndex = parent.children.firstIndex { $0 === mine[depth] } ?? 0
let theirsIndex = parent.children.firstIndex { $0 === theirs[depth] } ?? 0

View File

@ -75,7 +75,9 @@ public extension PureXML.Model.TreeNode {
private func isDescendant(of ancestor: PureXML.Model.TreeNode) -> Bool {
var current: PureXML.Model.TreeNode? = self
while let node = current {
if node === ancestor { return true }
if node === ancestor {
return true
}
current = node.parent
}
return false

View File

@ -49,21 +49,44 @@ public extension PureXML.Model.TreeNode {
return current
}
/// The concatenated text of every text and CDATA node in this subtree, in
/// document order (the libxml2 `xmlNodeGetContent` behavior).
/// The XPath string-value of this node. For an element, document, or root it
/// is the concatenated text of the subtree (`textContent`); for a comment or
/// processing instruction it is that node's own character data (XPath 1.0
/// sections 5.6 and 5.7), which `value-of select="comment()"` returns.
var stringValue: String {
switch kind {
case .text, .cdata:
case .text, .cdata, .namespace, .comment, .processingInstruction:
value
case .element, .document, .entityReference:
children.reduce(into: "") { $0 += $1.stringValue }
case .namespace:
value
case .comment, .processingInstruction, .doctype:
textContent
case .doctype:
""
}
}
/// The concatenated text of every text and CDATA node in this subtree, in
/// document order (the libxml2 `xmlNodeGetContent` behavior): an element's or
/// root's string-value, where comment and processing-instruction descendants
/// contribute nothing (XPath 1.0 sections 5.1 and 5.5). Distinct from
/// `stringValue`, which on a comment or PI node is that node's own data.
var textContent: String {
// Iterative pre-order walk so a deeply-nested subtree does not overflow
// the stack; children are pushed reversed to concatenate in document order.
var result = ""
var stack: [PureXML.Model.TreeNode] = [self]
while let node = stack.popLast() {
switch node.kind {
case .text, .cdata:
result += node.value
case .element, .document, .entityReference:
stack.append(contentsOf: node.children.reversed())
case .namespace, .comment, .processingInstruction, .doctype:
break
}
}
return result
}
/// This node's position among its parent's children, or nil when it has no
/// parent.
private var indexInParent: Int? {

View File

@ -9,7 +9,9 @@ public extension PureXML.Model.TreeNode {
var current: PureXML.Model.TreeNode? = self
for key in path {
guard let node = current else { return nil }
if key.stringValue.hasPrefix("@") { return node }
if key.stringValue.hasPrefix("@") {
return node
}
let matches = node.children.filter { $0.kind == .element && $0.name?.description == key.stringValue }
let index = (key.intValue ?? 1) - 1
current = matches.indices.contains(index) ? matches[index] : nil
@ -21,4 +23,25 @@ public extension PureXML.Model.TreeNode {
func sourceRange(at path: [PureXML.Validation.PathKey]) -> PureXML.Parsing.SourceRange? {
node(at: path)?.sourceRange
}
/// The validation coding path from the document root to this node (a sibling
/// index only when a name repeats), for locating schema compile findings.
func validationCodingPath() -> [PureXML.Validation.PathKey] {
var steps: [PureXML.Validation.PathKey] = []
var current: PureXML.Model.TreeNode? = self
while let element = current, element.kind == .element, let name = element.name?.description {
steps.append(validationPathStep(for: element, named: name))
current = element.parent
}
return steps.reversed()
}
private func validationPathStep(for element: PureXML.Model.TreeNode, named name: String) -> PureXML.Validation.PathKey {
guard let parent = element.parent else { return .element(name) }
let siblings = parent.children.filter { $0.kind == .element && $0.name?.description == name }
guard siblings.count > 1, let index = siblings.firstIndex(where: { $0 === element }) else {
return .element(name)
}
return .element(name, index: index + 1)
}
}

View File

@ -16,7 +16,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append(contentsOf: sequence)
} else {
scalars.append("\u{FFFD}")
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
}
}
} else if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
@ -26,7 +28,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append("\u{FFFD}")
}
}
if lead != 0 { scalars.append("\u{FFFD}") }
if lead != 0 {
scalars.append("\u{FFFD}")
}
return String(scalars)
}

View File

@ -166,7 +166,9 @@ extension PureXML.Parsing {
/// BOM is still a contradiction, only utf-8 itself agrees; the generic
/// utf-16/utf-32 names agree with either byte order.
private static func sameFamily(_ declared: InputEncoding, as sniffed: InputEncoding) -> Bool {
if declared == sniffed { return true }
if declared == sniffed {
return true
}
switch (sniffed, declared) {
case (.utf16BigEndian, .utf16LittleEndian), (.utf16LittleEndian, .utf16BigEndian),
(.utf32BigEndian, .utf32LittleEndian), (.utf32LittleEndian, .utf32BigEndian):
@ -191,7 +193,9 @@ extension PureXML.Parsing {
while index < body.endIndex, body.index(after: index) < body.endIndex {
let high = sniffed == .utf16BigEndian ? body[index] : body[body.index(after: index)]
let low = sniffed == .utf16BigEndian ? body[body.index(after: index)] : body[index]
if high == 0 { ascii.append(low) }
if high == 0 {
ascii.append(low)
}
index = body.index(index, offsetBy: 2)
}
return declaredEncoding(in: ascii)

View File

@ -90,8 +90,12 @@ public extension PureXML.Parsing {
var map: [UInt32: UInt8] = [:]
for byte in UInt8.min ... UInt8.max {
let value = forward(byte).value
if value == 0xFFFD { continue }
if map[value] == nil { map[value] = byte }
if value == 0xFFFD {
continue
}
if map[value] == nil {
map[value] = byte
}
}
return map
}

View File

@ -104,7 +104,9 @@ extension PureXML.Parsing.ByteEncoder {
func record(_ bytes: [UInt8]) {
let scalars = Array(decode(bytes[...]).unicodeScalars)
guard scalars.count == 1, scalars[0].value != 0xFFFD, scalars[0].value >= 0x80 else { return }
if map[scalars[0].value] == nil { map[scalars[0].value] = bytes }
if map[scalars[0].value] == nil {
map[scalars[0].value] = bytes
}
}
for byte in 0x80 ... 0xFF {
record([UInt8(byte)])

View File

@ -0,0 +1,35 @@
extension StringProtocol {
/// Trims leading and trailing XML whitespace without Foundation.
func trimmingXMLWhitespace() -> String {
var slice = self[...]
while let first = slice.first, first.isXMLWhitespace {
slice = slice.dropFirst()
}
while let last = slice.last, last.isXMLWhitespace {
slice = slice.dropLast()
}
return String(slice)
}
}
extension Character {
private typealias XML = PureXML.Parsing.XMLCharacter
/// XML S production: space, tab, carriage return, line feed.
var isXMLWhitespace: Bool {
unicodeScalars.count == 1 && unicodeScalars.first.map(XML.isWhitespace) == true
}
/// Whether this character may start an XML name. A grapheme qualifies when its
/// first scalar is a NameStartChar and any trailing scalars (combining marks)
/// are NameChar.
var isXMLNameStart: Bool {
guard let first = unicodeScalars.first, XML.isNameStart(first) else { return false }
return unicodeScalars.dropFirst().allSatisfy(XML.isNameChar)
}
/// Whether this character may continue an XML name (every scalar a NameChar).
var isXMLNameContinuation: Bool {
!unicodeScalars.isEmpty && unicodeScalars.allSatisfy(XML.isNameChar)
}
}

View File

@ -25,13 +25,17 @@ extension PureXML.Parsing {
// Each definition starts with required whitespace (the body begins
// right after the element name).
guard scanner.requiredSpace() else { return nil }
if scanner.isAtEnd { return defaults } // trailing whitespace
if scanner.isAtEnd {
return defaults
} // trailing whitespace
guard scanner.name() else { return nil }
guard scanner.requiredSpace() else { return nil }
guard attType(&scanner) else { return nil }
guard scanner.requiredSpace() else { return nil }
guard let value = defaultDecl(&scanner) else { return nil }
if let value { defaults.append(value) }
if let value {
defaults.append(value)
}
}
return defaults
}
@ -41,7 +45,9 @@ extension PureXML.Parsing {
return scanner.nameGroup(nmtokens: true)
}
guard let keyword = scanner.keyword() else { return false }
if tokenizedTypes.contains(keyword) { return true }
if tokenizedTypes.contains(keyword) {
return true
}
if keyword == "NOTATION" {
guard scanner.requiredSpace() else { return false }
return scanner.nameGroup(nmtokens: false)

View File

@ -8,7 +8,9 @@ extension PureXML.Parsing {
enum DTDContentModelGrammar {
static func isValid(_ model: String) -> Bool {
let text = model.trimmingXMLWhitespace()
if text == "EMPTY" || text == "ANY" { return true }
if text == "EMPTY" || text == "ANY" {
return true
}
var scanner = ModelScanner(text)
guard scanner.peek() == "(" else { return false }
// Mixed and children both start with '('; Mixed is identified by
@ -74,7 +76,9 @@ private struct ModelScanner {
var sawAlternative = false
while true {
skipSpace()
if consume(")") { break }
if consume(")") {
break
}
guard consume("|") else { return false }
sawAlternative = true
skipSpace()
@ -104,7 +108,9 @@ private struct ModelScanner {
var connector: Character?
while !consume(")") {
guard let next = advance(), next == "|" || next == "," else { return false }
if let connector, connector != next { return false }
if let connector, connector != next {
return false
}
connector = next
skipSpace()
guard particle() else { return false }

View File

@ -83,7 +83,9 @@ struct DTDScanner {
/// a VC (deferred finding), since the unread external declarations might
/// have declared the entity (production 68).
var entityDeclaredIsWellFormedness: Bool {
if standalone { return true }
if standalone {
return true
}
return doctype.externalSubset == nil && doctype.parameterEntities.isEmpty && unresolvedParameterEntities.isEmpty
}
@ -298,22 +300,6 @@ struct DTDScanner {
}
extension DTDScanner {
private func parseExternalID(_ reader: inout Reader) -> ExternalID? {
if reader.consume("SYSTEM") {
reader.skipSpace()
guard let systemID = scanLiteral(&reader) else { return nil }
return ExternalID(systemID: systemID)
}
if reader.consume("PUBLIC") {
reader.skipSpace()
guard let publicID = scanLiteral(&reader) else { return nil }
reader.skipSpace()
let systemID = scanLiteral(&reader) ?? ""
return ExternalID(publicID: publicID, systemID: systemID)
}
return nil
}
func scanLiteral(_ reader: inout Reader) -> String? {
guard let quote = reader.peek(), quote == "\"" || quote == "'" else { return nil }
reader.advance()

View File

@ -50,7 +50,9 @@ extension DTDScanner {
} else if reader.matches("]]>") {
reader.consume("]]>")
nesting -= 1
if nesting == 0 { return body }
if nesting == 0 {
return body
}
body += "]]>"
} else if let character = reader.peek() {
body.append(character)
@ -168,7 +170,9 @@ extension DTDScanner {
while index < text.endIndex {
let character = text[index]
if let open = quote {
if character == open { quote = nil }
if character == open {
quote = nil
}
index = text.index(after: index)
continue
}

View File

@ -95,7 +95,9 @@ extension DTDScanner {
if character == "&" || character == "%" {
var probe = index + 1
let isCharacterReference = character == "&" && probe < characters.count && characters[probe] == "#"
if isCharacterReference { probe += 1 }
if isCharacterReference {
probe += 1
}
let bodyStart = probe
let stops: Set<Character> = [";", "&", "%"]
while probe < characters.count, !stops.contains(characters[probe]), !characters[probe].isWhitespace {
@ -237,7 +239,9 @@ extension DTDScanner {
var systemID = ""
if reader.peek()?.isWhitespace == true {
reader.skipSpace()
if let literal = scanLiteral(&reader) { systemID = literal }
if let literal = scanLiteral(&reader) {
systemID = literal
}
}
return ExternalID(publicID: publicID, systemID: systemID, base: currentBase)
}

View File

@ -26,7 +26,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append(scalar)
} else {
scalars.append("\u{FFFD}")
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
}
}
} else if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
@ -36,7 +38,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append("\u{FFFD}")
}
}
if lead != 0 { scalars.append("\u{FFFD}") }
if lead != 0 {
scalars.append("\u{FFFD}")
}
return String(scalars)
}

View File

@ -17,7 +17,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append(scalar)
} else {
scalars.append("\u{FFFD}")
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
}
}
} else if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
@ -27,7 +29,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append("\u{FFFD}")
}
}
if lead != 0 { scalars.append("\u{FFFD}") }
if lead != 0 {
scalars.append("\u{FFFD}")
}
return String(scalars)
}

View File

@ -18,7 +18,14 @@ extension PureXML.Parsing {
budget: inout Int,
at mark: Mark,
) throws -> String {
guard raw.contains("&") else { return raw }
// A `Character` literal here resolves to `Sequence.contains(_:)`,
// walking extended grapheme clusters to find one ASCII byte; called
// once per ampersand-bearing text node or attribute value in the
// whole document. `utf8.contains(0x26)` finds the same `&` via the
// UTF-8 byte view, since 0x26 is never a lead or continuation byte
// of a multi-byte sequence (matches the reasoning already applied
// to `NamespaceContext.checkNameShape`'s colon check).
guard raw.utf8.contains(0x26) else { return raw }
var expander = EntityExpander(entities: entities, mark: mark, budget: budget)
try expander.expand(raw, visiting: [], counts: false)
budget = expander.budget
@ -37,7 +44,9 @@ extension PureXML.Parsing {
at mark: Mark,
undeclared: inout [String],
) throws -> String {
guard raw.contains("&") else { return raw }
// See `decode(_:entities:budget:at:)`'s matching guard: byte-level
// scan, not a grapheme walk, for the same reason.
guard raw.utf8.contains(0x26) else { return raw }
var expander = EntityExpander(entities: entities, mark: mark, budget: budget, undeclared: [])
try expander.expand(raw, visiting: [], counts: false)
budget = expander.budget
@ -69,11 +78,12 @@ extension PureXML.Parsing.EntityDecoder {
/// plain decoding suffices.
static func splitAtMarkupEntity(_ raw: String, entities: [String: String]) -> MarkupSplit? {
guard !entities.isEmpty else { return nil }
var markupCache: [String: Bool] = [:]
var index = raw.startIndex
while index < raw.endIndex, let amp = raw[index...].firstIndex(of: "&") {
guard let semicolon = raw[amp...].firstIndex(of: ";") else { return nil }
let name = String(raw[raw.index(after: amp) ..< semicolon])
if !name.hasPrefix("#"), !predefined.contains(name), producesMarkup(name, entities: entities, visiting: []) {
if !name.hasPrefix("#"), !predefined.contains(name), producesMarkup(name, entities: entities, cache: &markupCache) {
return MarkupSplit(
prefix: String(raw[..<amp]),
name: name,
@ -110,12 +120,24 @@ extension PureXML.Parsing.EntityDecoder {
}
/// Whether `name`'s replacement contains markup, directly or transitively.
private static func producesMarkup(_ name: String, entities: [String: String], visiting: Set<String>) -> Bool {
guard !visiting.contains(name), let replacement = entities[name] else { return false }
if replacement.contains("<") { return true }
return declaredReferences(in: replacement).contains {
producesMarkup($0, entities: entities, visiting: visiting.union([name]))
/// Memoized per entity name: the property depends only on the (fixed) entity
/// graph, so a deeply-nested reference chain resolves in O(entities) rather
/// than O(references^depth), which a billion-laughs document otherwise forces.
/// A tentative `false` recorded before the recursion breaks any cycle (a
/// cyclic entity is an error caught when the reference is expanded).
private static func producesMarkup(_ name: String, entities: [String: String], cache: inout [String: Bool]) -> Bool {
if let cached = cache[name] {
return cached
}
guard let replacement = entities[name] else {
cache[name] = false
return false
}
cache[name] = false
let result = replacement.contains("<")
|| declaredReferences(in: replacement).contains { producesMarkup($0, entities: entities, cache: &cache) }
cache[name] = result
return result
}
private static func checkRecursion(
@ -123,13 +145,32 @@ extension PureXML.Parsing.EntityDecoder {
entities: [String: String],
visiting: Set<String>,
at mark: PureXML.Parsing.Mark,
) throws {
var verified: Set<String> = []
try checkRecursion(name, entities: entities, visiting: visiting, verified: &verified, at: mark)
}
/// Three-colour DFS for the No-Recursion WFC. `visiting` is the grey set
/// (entities on the current reference chain; revisiting one is the cycle).
/// `verified` is the black set: an entity is added only after its whole
/// reachable subgraph returns acyclic, so it can never reach a chain node
/// that reaches it. Skipping a black entity is therefore sound and collapses
/// the otherwise O(references^depth) revisits of a billion-laughs graph to
/// one pass over the distinct edges.
private static func checkRecursion(
_ name: String,
entities: [String: String],
visiting: Set<String>,
verified: inout Set<String>,
at mark: PureXML.Parsing.Mark,
) throws {
guard let replacement = entities[name] else { return }
for reference in declaredReferences(in: replacement) {
for reference in declaredReferences(in: replacement) where !verified.contains(reference) {
guard !visiting.contains(reference) else {
throw PureXML.Parsing.ParseError.recursiveEntity(name: reference, mark)
}
try checkRecursion(reference, entities: entities, visiting: visiting.union([reference]), at: mark)
try checkRecursion(reference, entities: entities, visiting: visiting.union([reference]), verified: &verified, at: mark)
verified.insert(reference)
}
}
@ -182,32 +223,46 @@ private struct EntityExpander {
mutating func expand(_ raw: String, visiting: Set<String>, counts: Bool) throws {
var iterator = raw.startIndex
while iterator < raw.endIndex {
let character = raw[iterator]
// A CDATA section inside replacement text shields its content from
// reference recognition: copy it verbatim through the terminator.
if character == "<", raw[iterator...].hasPrefix("<![CDATA[") {
let openEnd = raw.index(iterator, offsetBy: 9)
var end = raw.endIndex
var cursor = openEnd
while cursor < raw.endIndex {
if raw[cursor...].hasPrefix("]]>") {
end = raw.index(cursor, offsetBy: 3)
break
}
cursor = raw.index(after: cursor)
}
for verbatim in raw[iterator ..< end] {
try append(verbatim, counts: counts)
}
iterator = end
continue
// Copy the literal run up to the next reference or markup character
// in bulk rather than one character at a time: only '&' (a
// reference) and '<' (a possible CDATA section) need per-character
// handling, and the text between them is the bulk of any run. The
// scan is at the byte level (both are ASCII, so a UTF-8 index is a
// valid string index), which avoids decoding every literal byte into
// a grapheme just to look for two markers.
guard let special = raw.utf8[iterator...].firstIndex(where: { $0 == 0x26 || $0 == 0x3C }) else {
try appendRun(raw[iterator...], counts: counts)
return
}
guard character == "&" else {
if special > iterator {
try appendRun(raw[iterator ..< special], counts: counts)
iterator = special
}
let character = raw[iterator]
if character == "<" {
// A CDATA section inside replacement text shields its content
// from reference recognition: copy it verbatim through the
// terminator. A '<' that does not open CDATA is literal content.
if raw[iterator...].hasPrefix("<![CDATA[") {
let openEnd = raw.index(iterator, offsetBy: 9)
var end = raw.endIndex
var cursor = openEnd
while cursor < raw.endIndex {
if raw[cursor...].hasPrefix("]]>") {
end = raw.index(cursor, offsetBy: 3)
break
}
cursor = raw.index(after: cursor)
}
try appendRun(raw[iterator ..< end], counts: counts)
iterator = end
continue
}
try append(character, counts: counts)
iterator = raw.index(after: iterator)
continue
}
guard let semicolon = raw[iterator...].firstIndex(of: ";") else {
guard let semicolon = raw.utf8[iterator...].firstIndex(of: 0x3B) else {
throw PureXML.Parsing.ParseError.invalidReference(String(raw[iterator...]), mark)
}
let body = String(raw[raw.index(after: iterator) ..< semicolon])
@ -216,6 +271,22 @@ private struct EntityExpander {
}
}
/// Appends a verbatim run, charging its whole length against the
/// amplification budget at once when counting (the per-character
/// ``append(_:counts:)`` charges one at a time; for a run the effect is
/// identical, the budget reaching the same value and the same overrun
/// throwing, only without the per-character work).
private mutating func appendRun(_ run: Substring, counts: Bool) throws {
if counts {
let length = run.count
guard budget >= length else {
throw PureXML.Parsing.ParseError.amplificationLimitExceeded(mark)
}
budget -= length
}
result += run
}
private mutating func resolve(_ body: String, visiting: Set<String>, counts: Bool) throws {
if let predefined = Self.predefinedValue(body) {
try append(predefined, counts: counts)

View File

@ -57,9 +57,13 @@ extension PureXML.Parsing {
while let character = scanner.advance() {
switch character {
case "<":
if let reason = scanner.markup(depth: &depth) { return reason }
if let reason = scanner.markup(depth: &depth) {
return reason
}
case "&":
if !scanner.completeReference() { return "incomplete reference" }
if !scanner.completeReference() {
return "incomplete reference"
}
default:
break
}
@ -100,7 +104,9 @@ private struct ReplacementScanner {
private mutating func skip(past terminator: String) -> Bool {
while index < characters.count {
if consume(terminator) { return true }
if consume(terminator) {
return true
}
index += 1
}
return false
@ -126,8 +132,12 @@ private struct ReplacementScanner {
depth -= 1
return depth < 0 ? "an end tag closes an element opened outside the entity" : nil
}
if let reason = attributes() { return reason }
if consume("/>") { return nil }
if let reason = attributes() {
return reason
}
if consume("/>") {
return nil
}
guard consume(">") else { return "malformed start tag" }
depth += 1
return nil
@ -146,12 +156,16 @@ private struct ReplacementScanner {
while true {
skipSpace()
guard let next = peek() else { return "unterminated start tag" }
if next == ">" || next == "/" { return nil }
if next == ">" || next == "/" {
return nil
}
guard name() else { return "malformed attribute name" }
skipSpace()
guard consume("=") else { return "attribute without a value" }
skipSpace()
if let reason = attributeValue() { return reason }
if let reason = attributeValue() {
return reason
}
}
}
@ -160,10 +174,14 @@ private struct ReplacementScanner {
guard let quote = peek(), quote == "\"" || quote == "'" else { return "unquoted attribute value" }
index += 1
while let character = peek(), character != quote {
if character == "<" { return "'<' in an attribute value" }
if character == "<" {
return "'<' in an attribute value"
}
if character == "&" {
index += 1
if !completeReference() { return "incomplete reference in an attribute value" }
if !completeReference() {
return "incomplete reference in an attribute value"
}
continue
}
index += 1
@ -174,7 +192,9 @@ private struct ReplacementScanner {
/// After a consumed `&`: a complete `name;` or `#digits;`.
mutating func completeReference() -> Bool {
var sawBody = false
if peek() == "#" { index += 1 }
if peek() == "#" {
index += 1
}
while let character = peek(), character != ";" {
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameChar) else { return false }
sawBody = true

View File

@ -26,6 +26,13 @@ public extension PureXML.Parsing {
var entityBudget: Int
var pending: [Event] = []
/// Scratch storage for `scanAttributes`' duplicate-name check, hoisted
/// to a stored property so one element's check reuses the previous
/// element's `Set` storage (`removeAll(keepingCapacity: true)`) instead
/// of allocating fresh native storage per element with attributes.
/// Safe to share because `scanAttributes` is not reentrant: nothing
/// inside an attribute scan can start another start-tag scan.
var attributeNamesSeen: Set<String> = []
private var sawRoot = false
private var primed = false
/// When true, ``next()`` repairs malformed input in place instead of
@ -81,12 +88,16 @@ public extension PureXML.Parsing {
guard recovering else {
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
}
if reachedEnd { return nil }
if reachedEnd {
return nil
}
let start = offset
do {
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
} catch let error as ParseError {
if let event = recoveredEvent(from: error, startingAt: start) { return event }
if let event = recoveredEvent(from: error, startingAt: start) {
return event
}
}
}
}
@ -145,34 +156,36 @@ public extension PureXML.Parsing {
// replacement into the reader with no text before it: the next
// construct is then read from the spliced replacement.
while true {
// Fast byte dispatch: when the next input is a raw byte (an
// empty lookahead buffer over owned storage), route on it
// without buffering a Character, so every matches()/consume()/
// scan call below stays on its byte fast path. The Character
// path is the identical fallback for a streaming source,
// spliced entity text, or a non-ASCII lead.
if let byte = reader.peekByte() {
eventStart = reader.mark
if byte == 0x3C {
return try scanContentMarkup()
} // '<'
if let text = try scanText() {
return text
}
continue
}
guard let lead = reader.peek() else {
throw ParseError.unexpectedEndOfInput(reader.mark)
}
eventStart = reader.mark
if reader.matches("</") { return try scanEndTag() }
if reader.matches("<!--") {
recordEmptyElementContent("a comment")
return try scanComment()
if lead == "<" {
return try scanContentMarkup()
}
if reader.matches("<![CDATA[") { return try scanCDATA() }
if reader.matches("<?") {
let mark = reader.mark
recordEmptyElementContent("a processing instruction")
let instruction = try scanProcessingInstruction()
if instruction.target.lowercased() == "xml" {
throw ParseError.reservedProcessingInstructionTarget(mark)
}
return .processingInstruction(target: instruction.target, data: instruction.data)
if let text = try scanText() {
return text
}
if reader.matches("<!DOCTYPE") {
throw ParseError.unsupportedDoctype(reader.mark)
}
if lead == "<" { return try scanStartTag() }
if let text = try scanText() { return text }
}
}
private mutating func scanStartTag() throws -> Event {
mutating func scanStartTag() throws -> Event {
guard open.count < limits.maxDepth else {
throw ParseError.nestingTooDeep(limit: limits.maxDepth, reader.mark)
}
@ -198,88 +211,14 @@ public extension PureXML.Parsing {
return .startElement(name: resolved.name, attributes: resolved.attributes)
}
/// Returns the next text event, or nil after splicing: a reference to
/// an entity whose replacement (directly or transitively) contains
/// markup is validated, budgeted, and pushed back into the reader so
/// it is reparsed as content (4.4.2 Included), elements in replacement
/// text become elements in the stream, not character data.
private mutating func scanText() throws -> Event? {
let mark = reader.mark
var raw = ""
var length = 0
while true {
// Bulk byte runs first: plain ASCII content (the bulk of any
// document) skips the per-character machinery entirely; the
// run never spans '<', CR, ']', or invalid bytes, so the
// character path below keeps exact error marks.
if let run = reader.contentRunBytes() {
length += run.utf8.count
try checkContent(length, mark)
raw += run
continue
}
guard let character = reader.peek(), character != "<" else { break }
length += 1
try checkContent(length, mark)
if character == ">", raw.hasSuffix("]]") {
throw ParseError.cdataCloseInContent(reader.mark)
}
guard character.unicodeScalars.allSatisfy(XMLCharacter.isChar) else {
throw ParseError.invalidCharacter(reader.mark)
}
raw.append(character)
reader.advance()
}
let split = raw.contains("&") ? EntityDecoder.splitAtMarkupEntity(raw, entities: referencableEntities) : nil
if let split {
let replacement = try EntityDecoder.includeForContent(
split.name,
entities: referencableEntities,
budget: &entityBudget,
at: mark,
)
reader.inject(replacement + split.remainder)
let prefix = try decodeReferences(split.prefix, at: mark)
return prefix.isEmpty ? nil : .characters(prefix)
}
let decoded = try decodeReferences(raw, at: mark)
recordReferenceContentFindings(raw: raw, decoded: decoded)
return .characters(decoded)
}
private mutating func scanProcessingInstruction() throws -> (target: String, data: String) {
let mark = reader.mark
reader.consume("<?")
let target = try scanName()
// A PI target is an NCName under namespaces (no colon).
if target.description.contains(":") {
throw ParseError.namespaceConstraint(reason: "PI target '\(target.description)' is not a legal NCName", mark)
}
// Production 16: the target and the data must be separated by
// whitespace, so `<?target+++?>` is not well-formed.
if let next = reader.peek(), !next.isWhitespace, !reader.matches("?>") {
throw ParseError.unexpectedCharacter(next, reader.mark)
}
reader.skipSpace()
var data = ""
var length = 0
while !reader.matches("?>") {
guard let character = reader.advance() else {
throw ParseError.unterminatedTag(mark)
}
length += 1
try checkContent(length, mark)
guard character.unicodeScalars.allSatisfy(XMLCharacter.isChar) else {
throw ParseError.invalidCharacter(reader.mark)
}
data.append(character)
}
reader.consume("?>")
return (target.description, data)
}
mutating func scanName() throws -> PureXML.Model.QualifiedName {
let mark = reader.mark
// Fast path: an all-ASCII name scanned directly from the bytes, with
// no per-character buffering. The Character loop below handles a
// non-ASCII name, the non-fast-path, and the too-long error.
if let fast = reader.takeASCIIName(maxLength: limits.maxNameLength) {
return PureXML.Model.QualifiedName(ascii: fast.name, colonOffset: fast.colon)
}
guard let first = reader.peek(), first.isXMLNameStart else {
throw ParseError.expectedName(reader.mark)
}

View File

@ -3,11 +3,24 @@
extension PureXML.Parsing.EventReader {
mutating func scanAttributes() throws -> [PureXML.Model.Attribute] {
var attributes: [PureXML.Model.Attribute] = []
var seen: Set<String> = []
// Reuses the stored scratch set (see its declaration) so an element
// does not allocate fresh Set storage just to check its attribute
// names; clearing keeps the capacity. `scanAttributes` cannot re-enter
// itself (nothing inside an attribute scan starts another start tag),
// so the shared storage never sees two interleaved scans.
attributeNamesSeen.removeAll(keepingCapacity: true)
while true {
reader.skipSpace()
guard let next = reader.peek(), next != ">", next != "/" else {
return attributes
// Byte dispatch keeps the buffer empty so scanName() reaches its
// byte fast path; the Character peek is the off-fast-path fallback.
if let byte = reader.peekByte() {
if byte == 0x3E || byte == 0x2F {
return attributes
} // '>' or '/'
} else {
guard let next = reader.peek(), next != ">", next != "/" else {
return attributes
}
}
let mark = reader.mark
let name = try scanName()
@ -17,7 +30,7 @@ extension PureXML.Parsing.EventReader {
}
reader.skipSpace()
let value = try scanAttributeValue()
guard seen.insert(name.description).inserted else {
guard attributeNamesSeen.insert(name.description).inserted else {
if recovering {
// Keep the element: record the duplicate and drop the later
// occurrence rather than failing the whole start tag.
@ -27,8 +40,17 @@ extension PureXML.Parsing.EventReader {
throw PureXML.Parsing.ParseError.duplicateAttribute(name: name.description, mark)
}
attributes.append(PureXML.Model.Attribute(name: name, value: value))
// A following attribute needs whitespace before it.
if let next = reader.peek(), next != ">", next != "/", next != "?", !next.isWhitespace {
// A following attribute needs whitespace before it. Check the raw
// byte when on the fast path to avoid buffering the next name char.
if let byte = reader.peekByte() {
// '>', '/', '?', or whitespace may legally follow a value; any
// other byte means a missing separator before the next attribute.
let isSeparator = byte == 0x3E || byte == 0x2F || byte == 0x3F
|| byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D
if !isSeparator {
throw PureXML.Parsing.ParseError.missingSpaceBeforeAttribute(reader.mark)
}
} else if let next = reader.peek(), next != ">", next != "/", next != "?", !next.isWhitespace {
throw PureXML.Parsing.ParseError.missingSpaceBeforeAttribute(reader.mark)
}
}
@ -36,13 +58,33 @@ extension PureXML.Parsing.EventReader {
mutating func scanAttributeValue() throws -> String {
let mark = reader.mark
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
throw PureXML.Parsing.ParseError.unquotedAttributeValue(reader.mark)
}
reader.advance()
let (quoteByte, quoteCharacter) = try consumeAttributeValueOpenQuote()
var raw = ""
var length = 0
while let character = reader.peek(), character != quote {
// Whether any '&' was seen: without one the value has no references, so
// the decode pass (and its full re-scan for '&') is skipped. The
// scanners visit every byte already, so this costs nothing extra.
var sawAmpersand = false
while true {
// Bulk byte runs first: plain ASCII value text (the common case)
// skips the per-character machinery; the run stops before the
// quote, '<', CR, or any byte the Character path must inspect.
if let run = reader.attributeRunBytes(quote: quoteByte, sawAmpersand: &sawAmpersand) {
length += run.utf8.count
try checkContent(length, mark)
raw += run
continue
}
// Byte-level check before the Character peek: whenever
// `attributeRunBytes` stopped because the next byte is the closing
// quote (its only non-empty-input stopping reason besides '<', CR,
// or a control/non-ASCII byte the Character path must inspect),
// this avoids buffering that quote as a Character only to compare
// and discard it a moment later.
if reader.peekByte() == quoteByte {
break
}
guard let character = reader.peek(), character != quoteCharacter else { break }
length += 1
try checkContent(length, mark)
if character == "<" {
@ -51,15 +93,60 @@ extension PureXML.Parsing.EventReader {
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else {
throw PureXML.Parsing.ParseError.invalidCharacter(reader.mark)
}
if character == "&" {
sawAmpersand = true
}
// 3.3.3: a literal whitespace character in an attribute value
// normalizes to a space; this happens before reference decoding,
// so whitespace that arrives via a character reference survives.
raw.append(character.isXMLWhitespace ? " " : character)
reader.advance()
}
guard reader.consume(String(quote)) else {
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
}
// The loop above only exits (breaks) when the next position is nil (end
// of input) or the closing quote: there is no third case.
try consumeAttributeValueCloseQuote(byte: quoteByte, character: quoteCharacter)
// No '&' means no references to decode: the normalized run is the value.
guard sawAmpersand else { return raw }
return try decodeReferences(raw, at: mark)
}
/// Detects and consumes the attribute value's opening quote, returning
/// both its byte and `Character` forms: `quoteByte` drives
/// `attributeRunBytes`'s bulk scan and the closing-quote byte check;
/// `quoteCharacter` is the fallback for wherever the byte fast path does
/// not apply (a streaming source with no owned byte storage, or a
/// lookahead buffer some earlier scan already populated). Byte fast path:
/// `peekByte()` only returns non-nil when the lookahead buffer is empty,
/// so `reader.consume(_:)` below reaches its own byte fast path
/// (`matchesBytes`) directly, with no Character buffering at all.
private mutating func consumeAttributeValueOpenQuote() throws -> (byte: UInt8, character: Character) {
if let byte = reader.peekByte(), byte == 0x22 || byte == 0x27 {
reader.consume(byte == 0x22 ? "\"" : "'")
return (byte, byte == 0x22 ? "\"" : "'")
}
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
throw PureXML.Parsing.ParseError.unquotedAttributeValue(reader.mark)
}
reader.advance()
return (quote == "\"" ? 0x22 : 0x27, quote)
}
/// Consumes the attribute value's closing quote. Called only once the
/// value-scanning loop has already confirmed (via a peek that consumed
/// nothing) the next position is either the quote or end of input, so
/// whichever check fired that break already buffered nothing new;
/// re-checking the byte first, then falling back to the already-buffered
/// Character peek, reaches the same verdict `reader.consume(String(quote))`
/// would, without that function's fresh `String` allocation and double
/// `Character` iteration on every single attribute value in the document.
private mutating func consumeAttributeValueCloseQuote(byte quoteByte: UInt8, character quoteCharacter: Character) throws {
if reader.peekByte() == quoteByte {
reader.advance()
return
}
guard reader.peek() == quoteCharacter else {
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
}
reader.advance()
}
}

View File

@ -0,0 +1,75 @@
/// Content markup dispatch, in its own file to keep the reader's primary type
/// body under the length cap. Behavior is part of EventReader proper.
extension PureXML.Parsing.EventReader {
/// At a '<' in element content, dispatches to the matching markup scanner.
/// The caller has confirmed the lead is '<' and set the event start. Shared
/// by the byte and Character dispatch paths so the two cannot diverge.
///
/// On the byte fast path the single byte after '<' selects the branch ('/'
/// end tag, '?' PI, '!' a declaration, otherwise a start tag), so an
/// element no longer runs up to five literal string comparisons before the
/// start-tag branch wins. The Character path below is the exact fallback for
/// a streaming source or spliced entity text, and a '<!' that matches none
/// of comment/CDATA/DOCTYPE still falls through to the start-tag scanner,
/// which reports the same `expectedName` error as before.
mutating func scanContentMarkup() throws -> Event {
switch reader.peekByte(1) {
case 0x2F: // '/'
try scanEndTag()
case 0x3F: // '?'
try scanContentProcessingInstruction()
case 0x21: // '!'
try scanContentDeclaration()
case .some:
try scanStartTag()
case nil:
try scanContentMarkupViaCharacters()
}
}
/// The Character-path markup dispatch, used off the byte fast path (a
/// streaming source or spliced entity text). Equivalent to the byte switch
/// in `scanContentMarkup`.
private mutating func scanContentMarkupViaCharacters() throws -> Event {
if reader.matches("</") {
return try scanEndTag()
}
if reader.matches("<?") {
return try scanContentProcessingInstruction()
}
if reader.matches("<!") {
return try scanContentDeclaration()
}
return try scanStartTag()
}
/// Scans a `<!...` construct in element content: a comment, a CDATA
/// section, or a rejected DOCTYPE. A `<!` matching none of these falls
/// through to the start-tag scanner, which reports the same `expectedName`
/// error as before.
private mutating func scanContentDeclaration() throws -> Event {
if reader.matches("<!--") {
recordEmptyElementContent("a comment")
return try scanComment()
}
if reader.matches("<![CDATA[") {
return try scanCDATA()
}
if reader.matches("<!DOCTYPE") {
throw PureXML.Parsing.ParseError.unsupportedDoctype(reader.mark)
}
return try scanStartTag()
}
/// Scans a processing instruction in element content, rejecting the
/// reserved `xml` target (legal only as the document's leading declaration).
private mutating func scanContentProcessingInstruction() throws -> Event {
let mark = reader.mark
recordEmptyElementContent("a processing instruction")
let instruction = try scanProcessingInstruction()
if instruction.target.lowercased() == "xml" {
throw PureXML.Parsing.ParseError.reservedProcessingInstructionTarget(mark)
}
return .processingInstruction(target: instruction.target, data: instruction.data)
}
}

View File

@ -7,7 +7,9 @@ extension PureXML.Parsing.EventReader {
throw PureXML.Parsing.ParseError.malformedDeclaration(mark)
}
xmlDeclaration = declaration
if declaration.version == "1.1" { reader.xml11 = true }
if declaration.version == "1.1" {
reader.xml11 = true
}
}
/// The current source offset, used to tell whether a failing ``next()`` made
@ -31,7 +33,9 @@ extension PureXML.Parsing.EventReader {
mutating func recover(from offset: Int) -> Bool {
pending.removeAll()
guard reader.peek() != nil else { return false }
if reader.mark.offset > offset { return true }
if reader.mark.offset > offset {
return true
}
reader.advance()
while let character = reader.peek(), character != "<" {
reader.advance()
@ -102,7 +106,9 @@ extension PureXML.Parsing.EventReader {
case let .mismatchedEndTag(_, found, _), let .unexpectedEndTag(found, _):
return closeTo(found)
default:
if !recover(from: start) { reachedEnd = true }
if !recover(from: start) {
reachedEnd = true
}
return nil
}
}

View File

@ -0,0 +1,98 @@
/// Text and processing-instruction scanning, split from the reader body to
/// keep it under the length caps. Behavior is part of EventReader proper.
extension PureXML.Parsing.EventReader {
/// Returns the next text event, or nil after splicing: a reference to
/// an entity whose replacement (directly or transitively) contains
/// markup is validated, budgeted, and pushed back into the reader so
/// it is reparsed as content (4.4.2 Included), elements in replacement
/// text become elements in the stream, not character data.
mutating func scanText() throws -> Event? {
let mark = reader.mark
var raw = ""
var length = 0
// Whether any '&' was seen: without one the text has no references
// and no markup-entity boundary, so the whole decode/split/findings
// pass is skipped. The scanners visit every byte already, so this
// costs nothing beyond a compare they were doing anyway.
var sawAmpersand = false
while true {
// Bulk byte runs first: plain ASCII content (the bulk of any
// document) skips the per-character machinery entirely; the
// run never spans '<', CR, ']', or invalid bytes, so the
// character path below keeps exact error marks.
if let run = reader.contentRunBytes(sawAmpersand: &sawAmpersand) {
length += run.utf8.count
try checkContent(length, mark)
raw += run
continue
}
// Detect the closing '<' on the byte path so the buffer stays
// empty: a buffered '<' would force the next element's dispatch,
// matches(), and name scan onto the slow Character path.
if reader.peekByte() == 0x3C {
break
}
guard let character = reader.peek(), character != "<" else { break }
length += 1
try checkContent(length, mark)
if character == ">", raw.hasSuffix("]]") {
throw ParseError.cdataCloseInContent(reader.mark)
}
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else {
throw ParseError.invalidCharacter(reader.mark)
}
if character == "&" {
sawAmpersand = true
}
raw.append(character)
reader.advance()
}
guard sawAmpersand else { return .characters(raw) }
let split = EntityDecoder.splitAtMarkupEntity(raw, entities: referencableEntities)
if let split {
let replacement = try EntityDecoder.includeForContent(
split.name,
entities: referencableEntities,
budget: &entityBudget,
at: mark,
)
reader.inject(replacement + split.remainder)
let prefix = try decodeReferences(split.prefix, at: mark)
return prefix.isEmpty ? nil : .characters(prefix)
}
let decoded = try decodeReferences(raw, at: mark)
recordReferenceContentFindings(raw: raw, decoded: decoded)
return .characters(decoded)
}
mutating func scanProcessingInstruction() throws -> (target: String, data: String) {
let mark = reader.mark
reader.consume("<?")
let target = try scanName()
// A PI target is an NCName under namespaces (no colon).
if target.description.contains(":") {
throw ParseError.namespaceConstraint(reason: "PI target '\(target.description)' is not a legal NCName", mark)
}
// Production 16: the target and the data must be separated by
// whitespace, so `<?target+++?>` is not well-formed.
if let next = reader.peek(), !next.isWhitespace, !reader.matches("?>") {
throw ParseError.unexpectedCharacter(next, reader.mark)
}
reader.skipSpace()
var data = ""
var length = 0
while !reader.matches("?>") {
guard let character = reader.advance() else {
throw ParseError.unterminatedTag(mark)
}
length += 1
try checkContent(length, mark)
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else {
throw ParseError.invalidCharacter(reader.mark)
}
data.append(character)
}
reader.consume("?>")
return (target.description, data)
}
}

View File

@ -19,7 +19,9 @@ extension PureXML.Parsing.EventReader {
/// it is a WFC when the document is standalone or could not have
/// external declarations, and a validity finding otherwise.
var undeclaredEntityIsFatal: Bool {
if xmlDeclaration?.standalone == true { return true }
if xmlDeclaration?.standalone == true {
return true
}
return documentType.externalSubset == nil && documentType.parameterEntities.isEmpty
}
@ -47,7 +49,9 @@ extension PureXML.Parsing.EventReader {
/// an entity whose stored replacement does (the errata E15h shape: the
/// double-escaped reference surfaces when the replacement is reparsed).
mutating func referencesCharacter(_ raw: String) -> Bool {
if containsCharacterReference(raw) { return true }
if containsCharacterReference(raw) {
return true
}
let entities = referencableEntities
var index = raw.startIndex
while index < raw.endIndex, let amp = raw[index...].firstIndex(of: "&") {
@ -66,7 +70,9 @@ extension PureXML.Parsing.EventReader {
func containsCharacterReference(_ raw: String) -> Bool {
var sawAmp = false
for character in raw {
if sawAmp, character == "#" { return true }
if sawAmp, character == "#" {
return true
}
sawAmp = character == "&"
}
return false
@ -90,6 +96,11 @@ extension PureXML.Parsing.EventReader {
/// (which is character data, not ignorable whitespace) inside element
/// content.
mutating func recordReferenceContentFindings(raw: String, decoded: String) {
// Element-content reference findings exist only against a declared
// content model. With no DTD (the common case) there is nothing to
// check, so return before scanning `raw` for '&' or rendering the
// element's qualified name.
guard !documentType.elementModels.isEmpty else { return }
guard raw.contains("&"), let current = open.last,
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description]
else { return }
@ -131,7 +142,9 @@ extension PureXML.Parsing.EventReader {
guard isBinding,
let declaration = declarations.first(where: { $0.name == attribute.name.description })
else { return attribute }
if case .cdata = declaration.type { return attribute }
if case .cdata = declaration.type {
return attribute
}
let collapsed = attribute.value.split(separator: " ").joined(separator: " ")
return PureXML.Model.Attribute(name: attribute.name, value: collapsed)
}

View File

@ -71,8 +71,12 @@ extension PureXML.Parsing.ByteDecoder {
/// The scalar for a four-byte pointer through the linear ranges, or nil for
/// the unmapped gap and the out-of-range tail.
static func rangeScalar(_ pointer: Int) -> Unicode.Scalar? {
if pointer > 39419, pointer < 189_000 { return nil }
if pointer > 1_237_575 { return nil }
if pointer > 39419, pointer < 189_000 {
return nil
}
if pointer > 1_237_575 {
return nil
}
let target = UInt32(pointer)
var low = 0
var high = gb18030RangePointers.count - 1

View File

@ -18,7 +18,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append(scalar)
} else {
scalars.append("\u{FFFD}")
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
}
}
} else if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
@ -30,7 +32,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append("\u{FFFD}")
}
}
if lead != 0 { scalars.append("\u{FFFD}") }
if lead != 0 {
scalars.append("\u{FFFD}")
}
return String(scalars)
}

View File

@ -19,7 +19,9 @@ extension PureXML.Parsing.ByteDecoder {
}
decodeByte(byte, mode: mode, lead: &lead, into: &scalars)
}
if lead != nil { scalars.append("\u{FFFD}") }
if lead != nil {
scalars.append("\u{FFFD}")
}
return String(scalars)
}

View File

@ -1,7 +1,8 @@
public extension PureXML.Parsing {
/// A character encoding PureXML can decode from raw bytes. Covers the
/// Unicode transformation formats plus the single-byte encodings (ISO-8859-1,
/// ISO-8859-5/9/15, and Windows-1252), toward libxml2 encoding parity.
/// A character encoding PureXML can decode from raw bytes. Covers the Unicode
/// transformation formats, 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), for libxml2 encoding parity.
enum InputEncoding: String, Equatable, Sendable {
case utf8
case utf16BigEndian

View File

@ -13,41 +13,70 @@ extension PureXML.Parsing {
/// bound by a declaration.
static let xmlnsNamespaceURI = "http://www.w3.org/2000/xmlns/"
/// A stack of scopes; each maps a prefix ("" = default namespace) to a URI.
private var scopes: [[String: String]] = []
/// The current in-scope bindings, prefix ("" = default namespace) to URI,
/// maintained incrementally so a lookup is O(1) rather than a walk over a
/// stack as deep as the document: a per-element walk would make parsing a
/// deeply-nested document quadratic in depth.
private var current: [String: String] = [:]
/// Opens an element: pushes a scope built from its `xmlns` declarations,
/// then resolves the element name and its attribute names against the
/// now-in-scope bindings.
/// One restore list per open element: the bindings it overwrote, so
/// closing the element can return `current` to its enclosing state. Each
/// entry is a prefix and the value it had before this element (nil when it
/// was unbound), applied in reverse on exit.
private var undo: [[(key: String, previous: String?)]] = []
/// Opens an element: applies its `xmlns` declarations to the current
/// bindings, then resolves the element name and its attribute names
/// against the now-in-scope bindings.
mutating func enterElement(
name: PureXML.Model.QualifiedName,
attributes: [PureXML.Model.Attribute],
at mark: Mark,
) throws -> (name: PureXML.Model.QualifiedName, attributes: [PureXML.Model.Attribute]) {
try checkNameShape(name, at: mark)
var scope: [String: String] = [:]
var changes: [(key: String, previous: String?)] = []
for attribute in attributes {
try checkNameShape(attribute.name, at: mark)
if attribute.name.prefix == nil, attribute.name.localName == "xmlns" {
try checkDefaultBinding(attribute.value, at: mark)
scope[""] = attribute.value
changes.append((key: "", previous: current[""]))
current[""] = attribute.value
} else if attribute.name.prefix == "xmlns" {
try checkPrefixBinding(attribute.name.localName, uri: attribute.value, at: mark)
scope[attribute.name.localName] = attribute.value
let key = attribute.name.localName
changes.append((key: key, previous: current[key]))
current[key] = attribute.value
}
}
scopes.append(scope)
undo.append(changes)
let resolvedName = try resolveElement(name, at: mark)
let resolvedAttributes = try attributes.map { try resolveAttribute($0, at: mark) }
// `resolveAttribute` returns its argument unchanged whenever the
// attribute has no prefix or the `xmlns` prefix (the common case:
// most attributes in most documents are unprefixed), so `.map`
// over an all-unprefixed attribute list allocates a fresh backing
// array that is element-wise identical to `attributes`, on every
// single element in the document. Checking first, with no
// allocation, and reusing `attributes` directly when nothing would
// change is the same result for far less work in that common case.
let needsAttributeResolution = attributes.contains { attribute in
let prefix = attribute.name.prefix
return prefix != nil && prefix != "xmlns"
}
let resolvedAttributes = try needsAttributeResolution
? attributes.map { try resolveAttribute($0, at: mark) }
: attributes
try checkExpandedNames(resolvedAttributes, at: mark)
return (resolvedName, resolvedAttributes)
}
/// Closes an element, popping its scope.
/// Closes an element, restoring the bindings it overwrote.
mutating func leaveElement() {
if !scopes.isEmpty {
scopes.removeLast()
guard let changes = undo.popLast() else { return }
// Restore in reverse so repeated bindings of one prefix on a single
// element unwind to the value that preceded the element.
for change in changes.reversed() {
current[change.key] = change.previous
}
}
@ -88,7 +117,16 @@ extension PureXML.Parsing {
/// `:foo`, `a:b:c`, `xmlns:`) in its local name, where the colon
/// betrays it.
private func checkNameShape(_ name: PureXML.Model.QualifiedName, at mark: Mark) throws {
guard name.localName.contains(":") else { return }
// `":"` as a `Character` literal here resolves to `Sequence.contains(_:)`,
// which walks the string's extended grapheme clusters one at a time
// to compare each against `:`; called once per element and once per
// attribute name in the whole document, that grapheme decoding cost
// is pure overhead for finding one ASCII byte. `utf8.contains(_:)`
// walks the UTF-8 view's `UInt8` elements directly: 0x3A (`:`) is
// never a lead or continuation byte of a multi-byte UTF-8 sequence,
// so a byte-level scan finds every literal colon exactly the
// grapheme scan would, without decoding any of them.
guard name.localName.utf8.contains(0x3A) else { return }
throw ParseError.namespaceConstraint(reason: "'\(name.description)' is not a legal qualified name", mark)
}
@ -123,6 +161,22 @@ extension PureXML.Parsing {
/// Attributes must be distinct by expanded name: two prefixes bound to
/// the same URI cannot carry the same local name on one element.
private func checkExpandedNames(_ attributes: [PureXML.Model.Attribute], at mark: Mark) throws {
// The only observable effect below is throwing `duplicateAttribute`,
// which needs two attributes with an equal (URI, localName) pair; an
// unprefixed attribute never carries a URI (the default namespace
// does not apply to attributes) and neither does an `xmlns`/
// `xmlns:*` declaration, so with fewer than two URI-bearing
// attributes no collision is possible. Counting first, with no
// allocation, skips the `Set<String>` storage allocation and the
// per-attribute key string (which, for the built-in `xml`
// namespace URI, is long enough to need its own heap allocation)
// on every element that has at most one namespaced attribute, the
// overwhelming majority in most real documents.
var namespacedCount = 0
for attribute in attributes where attribute.name.namespaceURI != nil {
namespacedCount += 1
}
guard namespacedCount > 1 else { return }
var seen: Set<String> = []
for attribute in attributes where attribute.name.namespaceURI != nil {
let key = (attribute.name.namespaceURI ?? "") + "\u{0}" + attribute.name.localName
@ -133,12 +187,8 @@ extension PureXML.Parsing {
}
private func lookup(_ key: String) -> String? {
for scope in scopes.reversed() {
if let uri = scope[key] {
return uri.isEmpty ? nil : uri
}
}
return nil
guard let uri = current[key], !uri.isEmpty else { return nil }
return uri
}
}
}

View File

@ -0,0 +1,84 @@
/// A partially-built ``TreeNode`` element while its children stream in, the
/// direct-tree counterpart to `ElementFrame`. File-scope and private.
private struct TreeNodeFrame {
let name: PureXML.Model.QualifiedName
let attributes: [PureXML.Model.Attribute]
var children: [PureXML.Model.TreeNode] = []
}
public extension PureXML.Parsing.Parser {
/// Parses a string straight into the mutable, parent-aware ``TreeNode`` tree,
/// assembling it directly from the event stream instead of building the value
/// ``Node`` tree and converting it. The result is identical to
/// ``Model/TreeNode/init(_:)`` applied to ``parse(_:limits:resolver:)``, the
/// same nodes with the same parent links, but the tree is allocated once
/// rather than twice.
func parseTree(
_ xml: String,
limits: PureXML.Parsing.Limits = .default,
resolver: PureXML.Parsing.EntityResolver = .refusing,
) throws -> PureXML.Model.TreeNode {
try buildTree(PureXML.Parsing.EventReader(xml, limits: limits, resolver: resolver))
}
/// The direct-``TreeNode`` counterpart to `build(_:)`: same event loop, same
/// stack discipline, same well-formedness guards, but it accumulates
/// ``TreeNode`` children per frame and adopts them when an element closes, so
/// it never materializes the intermediate value ``Node`` tree. Each produced
/// tree is identical to converting `build(_:)`'s node.
internal func buildTree(_ source: PureXML.Parsing.EventReader) throws -> PureXML.Model.TreeNode {
var reader = source
var roots: [PureXML.Model.TreeNode] = []
var stack: [TreeNodeFrame] = []
var produced = false
while let event = try reader.next() {
produced = true
switch event {
case let .startElement(name, attributes):
stack.append(TreeNodeFrame(name: name, attributes: attributes))
case .endElement:
guard let frame = stack.popLast() else {
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(.start)
}
let element = PureXML.Model.TreeNode(
adopting: .element,
name: frame.name,
attributes: frame.attributes,
children: frame.children,
)
attachTree(element, to: &stack, roots: &roots)
case let .characters(text):
attachTree(PureXML.Model.TreeNode(kind: .text, value: text), to: &stack, roots: &roots)
case let .cdata(text):
attachTree(PureXML.Model.TreeNode(kind: .cdata, value: text), to: &stack, roots: &roots)
case let .comment(text):
attachTree(PureXML.Model.TreeNode(kind: .comment, value: text), to: &stack, roots: &roots)
case let .processingInstruction(target, data):
let instruction = PureXML.Model.TreeNode(
kind: .processingInstruction,
name: PureXML.Model.QualifiedName(target),
value: data,
)
attachTree(instruction, to: &stack, roots: &roots)
}
}
guard produced, roots.contains(where: { $0.kind == .element }) else {
throw PureXML.Parsing.ParseError.emptyDocument
}
return PureXML.Model.TreeNode(adopting: .document, children: roots)
}
private func attachTree(
_ node: PureXML.Model.TreeNode,
to stack: inout [TreeNodeFrame],
roots: inout [PureXML.Model.TreeNode],
) {
if stack.isEmpty {
roots.append(node)
} else {
stack[stack.count - 1].children.append(node)
}
}
}

View File

@ -30,7 +30,9 @@ extension PureXML.Parsing.XMLDeclaration {
// Pseudo-attributes must be separated by whitespace.
let before = index
skipSpace()
if index == before, index < chars.count { return nil }
if index == before, index < chars.count {
return nil
}
}
return pairs
}

View File

@ -52,13 +52,17 @@ public extension PureXML.Parsing {
case let .close(name):
close(name)
case let .text(value):
if !value.isEmpty { handler.characters?(value) }
if !value.isEmpty {
handler.characters?(value)
}
case let .comment(value):
handler.comment?(value)
case let .cdata(value):
handler.cdata?(value)
case let .processingInstruction(target, data):
if target.lowercased() != "xml" { handler.processingInstruction?(target, data) }
if target.lowercased() != "xml" {
handler.processingInstruction?(target, data)
}
case .ignorable:
break
}

View File

@ -64,7 +64,9 @@ private struct TagCursor {
}
private mutating func advance() {
if index < chars.count { index += 1 }
if index < chars.count {
index += 1
}
}
private mutating func skipSpace() {
@ -131,11 +133,21 @@ extension PureXML.Parsing {
if isPrefix(buffer, ofAny: ["<!--", "<![CDATA[", "<!", "</", "<?"], decidedAt: 2), !final {
return .needMore
}
if startsWith(buffer, "<!--") { return delimited(buffer, open: 4, close: "-->") { .comment(String($0)) } }
if startsWith(buffer, "<![CDATA[") { return delimited(buffer, open: 9, close: "]]>") { .cdata(String($0)) } }
if startsWith(buffer, "<!") { return until(buffer, terminator: ">") { _ in .ignorable } }
if startsWith(buffer, "</") { return scanEndTag(buffer) }
if startsWith(buffer, "<?") { return scanProcessingInstruction(buffer) }
if startsWith(buffer, "<!--") {
return delimited(buffer, open: 4, close: "-->") { .comment(String($0)) }
}
if startsWith(buffer, "<![CDATA[") {
return delimited(buffer, open: 9, close: "]]>") { .cdata(String($0)) }
}
if startsWith(buffer, "<!") {
return until(buffer, terminator: ">") { _ in .ignorable }
}
if startsWith(buffer, "</") {
return scanEndTag(buffer)
}
if startsWith(buffer, "<?") {
return scanProcessingInstruction(buffer)
}
return scanStartTag(buffer, final: final)
}
@ -162,7 +174,9 @@ extension PureXML.Parsing {
guard let end = tagEnd(buffer) else { return final ? .token(.text("<"), consumed: 1) : .needMore }
var inner = Array(buffer[1 ..< end])
let selfClosing = inner.last == "/"
if selfClosing { inner.removeLast() }
if selfClosing {
inner.removeLast()
}
let (name, attributes) = parseTag(inner)
return .token(.open(name: name, attributes: attributes, selfClosing: selfClosing), consumed: end + 1)
}
@ -201,7 +215,9 @@ extension PureXML.Parsing {
for index in 1 ..< buffer.count {
let character = buffer[index]
if let active = quote {
if character == active { quote = nil }
if character == active {
quote = nil
}
} else if character == "\"" || character == "'" {
quote = character
} else if character == ">" {

View File

@ -12,7 +12,22 @@ extension PureXML.Parsing {
/// `String`.
struct Reader {
private var pull: () -> Character?
private var buffer: [Character] = []
/// `private(set)`/`let` rather than `private` so the bulk byte-run
/// scanners (the same-module extension in ReaderByteRuns.swift) can READ
/// the cursor state; all writes stay file-private, routed through
/// `advanceByteRun` so position mutation is encapsulated here.
private(set) var buffer: [Character] = []
/// Index of the first unconsumed character; the logical buffer is
/// `buffer[head...]`. Consuming advances this instead of shifting the
/// array, and re-injection reuses the freed prefix, so front-consume and
/// entity re-injection are amortized O(1) rather than O(buffer). The array
/// is reset to empty once fully consumed, so `buffer.isEmpty` stays
/// equivalent to "no buffered characters" for the byte fast paths.
private var head = 0
private var available: Int {
buffer.count - head
}
private var exhausted = false
private(set) var offset = 0
private(set) var line = 1
@ -23,20 +38,38 @@ extension PureXML.Parsing {
var xml11 = false
/// A raw character read past a carriage return that was not part of the
/// line ending, held back to be returned next.
private var pendingRaw: Character?
private(set) var pendingRaw: Character?
/// The contiguous fast path: when the input is a String, its UTF-8
/// bytes are copied once into owned storage and scalars decode
/// straight off the pointer, bypassing the per-character closure
/// chain entirely.
private let storage: ByteStorage?
private var byteIndex = 0
let storage: ByteStorage?
private(set) var byteIndex = 0
/// Commits a bulk byte run scanned by the byte-runs extension: moves the
/// cursor to `end` and applies the run's position deltas in one place, so
/// the run scanners need no write access to the position fields. A run
/// with `newlines` line feeds ends `end - lastLineStart` columns into its
/// final line; otherwise the column advances by the run length.
mutating func advanceByteRun(to end: Int, length: Int, newlines: Int, lastLineStart: Int) {
byteIndex = end
offset += length
if newlines > 0 {
line += newlines
column = end - lastLineStart
} else {
column += length
}
}
init(pulling pull: @escaping () -> Character?) {
storage = nil
// Split multi-scalar graphemes so the buffer is scalar-level.
var queued: [Character] = []
self.pull = {
if !queued.isEmpty { return queued.removeFirst() }
if !queued.isEmpty {
return queued.removeFirst()
}
guard let next = pull() else { return nil }
let scalars = next.unicodeScalars
guard scalars.count > 1 else { return next }
@ -86,7 +119,7 @@ extension PureXML.Parsing {
/// source is exhausted). The buffer stays bounded by the largest lookahead
/// any single scan needs, never by the document size.
private mutating func ensure(_ count: Int) {
while buffer.count < count, !exhausted {
while available < count, !exhausted {
if let character = normalizedPull() {
buffer.append(character)
} else {
@ -102,10 +135,14 @@ extension PureXML.Parsing {
private mutating func normalizedPull() -> Character? {
guard let character = rawNext() else { return nil }
// Swift coalesces a CR+LF pair into one grapheme-cluster character.
if character == "\r\n" { return "\n" }
if character == "\r\n" {
return "\n"
}
if character == "\r" {
let next = rawNext()
if next != "\n", !(xml11 && next == "\u{85}") { pendingRaw = next }
if next != "\n", !(xml11 && next == "\u{85}") {
pendingRaw = next
}
return "\n"
}
if xml11, character == "\u{85}" || character == "\u{2028}" {
@ -127,7 +164,20 @@ extension PureXML.Parsing {
mutating func peek(_ ahead: Int = 0) -> Character? {
ensure(ahead + 1)
return ahead < buffer.count ? buffer[ahead] : nil
return ahead < available ? buffer[head + ahead] : nil
}
/// The raw byte `ahead` positions past the cursor, on the byte fast path
/// only (an empty lookahead buffer, no pending raw character, owned
/// storage). Returns nil off the fast path or at end of input. It does
/// not normalize line endings and does not advance: callers use it to
/// dispatch on fixed ASCII bytes ('<', '/', '>', quotes) while leaving
/// the buffer empty, so the byte scanners below stay engaged, and fall
/// back to the Character path for any byte it does not recognize.
func peekByte(_ ahead: Int = 0) -> UInt8? {
guard buffer.isEmpty, pendingRaw == nil, let storage else { return nil }
let index = byteIndex + ahead
return index < storage.count ? storage.pointer[index] : nil
}
var mark: Mark {
@ -137,8 +187,13 @@ extension PureXML.Parsing {
@discardableResult
mutating func advance() -> Character? {
ensure(1)
guard !buffer.isEmpty else { return nil }
let character = buffer.removeFirst()
guard available > 0 else { return nil }
let character = buffer[head]
head += 1
if head == buffer.count {
buffer.removeAll(keepingCapacity: true)
head = 0
}
offset += 1
if character == "\n" {
line += 1
@ -152,17 +207,21 @@ extension PureXML.Parsing {
/// True if the upcoming characters equal `literal`, without consuming.
mutating func matches(_ literal: String) -> Bool {
if buffer.isEmpty, pendingRaw == nil, let storage {
if let byteResult = matchesBytes(literal, storage) { return byteResult }
if let byteResult = matchesBytes(literal, storage) {
return byteResult
}
}
// No per-call Array materialization: literals are short constants
// and this runs on every scan decision (the hottest comparison in
// the parse profile).
let count = literal.count
ensure(count)
guard buffer.count >= count else { return false }
guard available >= count else { return false }
var index = 0
for character in literal {
if buffer[index] != character { return false }
if buffer[head + index] != character {
return false
}
index += 1
}
return true
@ -182,6 +241,51 @@ extension PureXML.Parsing {
return true
}
/// On the byte fast path (an empty lookahead buffer over owned bytes),
/// consumes an all-ASCII XML name and returns it, advancing past it.
/// Returns nil when not on the fast path, when the first byte is not an
/// ASCII name-start, when the name reaches a non-ASCII byte, or when it
/// exceeds `maxLength`, so the caller falls back to the Character scanner
/// (which handles the full Unicode name grammar and the length error).
/// A name has no newlines, so position tracking is plain arithmetic.
/// Scans an all-ASCII name from the byte storage, also reporting the
/// offset of the name's first colon (or nil when it has none). The
/// scanner already visits every byte, so noting the colon costs one
/// compare per byte and lets ``Model/QualifiedName`` split the prefix
/// without a second pass over the name (the common unprefixed name then
/// splits without scanning at all).
mutating func takeASCIIName(maxLength: Int) -> (name: String, colon: Int?)? {
guard buffer.isEmpty, pendingRaw == nil, let storage, byteIndex < storage.count else { return nil }
let pointer = storage.pointer
let startByte = pointer[byteIndex]
guard PureXML.Parsing.XMLCharacter.isASCIINameStart(startByte) else { return nil }
var colon: Int? = startByte == 0x3A ? 0 : nil
var index = byteIndex + 1
let limit = byteIndex + maxLength
while index < storage.count {
let byte = pointer[index]
if byte >= 0x80 {
return nil
}
if !PureXML.Parsing.XMLCharacter.isASCIINameChar(byte) {
break
}
if index >= limit {
return nil
}
if colon == nil, byte == 0x3A {
colon = index - byteIndex
}
index += 1
}
let length = index - byteIndex
let run = String(decoding: UnsafeBufferPointer(start: pointer + byteIndex, count: length), as: UTF8.self)
byteIndex = index
offset += length
column += length
return (run, colon)
}
/// If the upcoming characters equal `literal`, consume them and return true.
@discardableResult
mutating func consume(_ literal: String) -> Bool {
@ -203,58 +307,37 @@ extension PureXML.Parsing {
}
mutating func skipSpace() {
if skipSpaceBytes() {
return
}
while let character = peek(), character.isXMLWhitespace {
advance()
}
}
/// Bulk-scans character data in byte mode: consumes and returns the
/// longest upcoming run of plain ASCII content bytes (no '<', no
/// carriage return, no "]]" pair, every byte a valid XML character),
/// or nil when the fast path does not apply. Anything subtle, an
/// entity boundary aside ('&' is plain content here), is left for
/// the character path so error marks stay exact. Returns nil rather
/// than an empty run so callers can alternate with the slow loop.
mutating func contentRunBytes() -> String? {
guard buffer.isEmpty, pendingRaw == nil, let storage else { return nil }
/// Byte-mode whitespace skip. Returns true when it stopped at a clean
/// non-whitespace byte (so the buffer is empty and the caller needs no
/// Character loop); false when off the fast path or stopped at a
/// carriage return that the Character path must fold per 2.11.
private mutating func skipSpaceBytes() -> Bool {
guard buffer.isEmpty, pendingRaw == nil, let storage else { return false }
let pointer = storage.pointer
let count = storage.count
// A run never contains ']', so "]]>" can only straddle the
// boundary where the slow path consumed "]]" and the run would
// begin with '>': leave a leading '>' to the slow path and its
// cdataCloseInContent check (W3C ibm14n01).
if byteIndex < count, pointer[byteIndex] == 0x3E { return nil }
var index = byteIndex
var newlines = 0
var lastLineStart = -1
while index < count {
let byte = pointer[index]
if byte == 0x3C { break } // '<'
// Valid plain ASCII content only; CR, ']' and non-ASCII go
// to the character path.
guard byte < 0x80, byte != 0x0D, byte != 0x5D,
byte >= 0x20 || byte == 0x09 || byte == 0x0A
else {
if index == byteIndex { return nil }
break
while byteIndex < storage.count {
switch pointer[byteIndex] {
case 0x20, 0x09:
column += 1
case 0x0A:
line += 1
column = 1
case 0x0D:
return false
default:
return true
}
if byte == 0x0A {
newlines += 1
lastLineStart = index
}
index += 1
byteIndex += 1
offset += 1
}
guard index > byteIndex else { return nil }
let run = String(decoding: UnsafeBufferPointer(start: pointer + byteIndex, count: index - byteIndex), as: UTF8.self)
offset += index - byteIndex
if newlines > 0 {
line += newlines
column = index - lastLineStart
} else {
column += index - byteIndex
}
byteIndex = index
return run
return true
}
/// Prepends `text` to the unread stream: used to splice a general
@ -262,43 +345,19 @@ extension PureXML.Parsing {
/// (4.4.2 Included). Position tracking keeps reporting the source
/// document's marks, the libxml2 model for entity boundaries.
mutating func inject(_ text: String) {
buffer.insert(contentsOf: text.unicodeScalars.map(Character.init), at: 0)
let characters = text.unicodeScalars.map(Character.init)
if head >= characters.count {
// Reuse the space the consumed prefix freed, so repeated entity
// re-injection is O(injected text), not O(buffer).
buffer.replaceSubrange(head - characters.count ..< head, with: characters)
head -= characters.count
} else {
if head > 0 {
buffer.removeFirst(head)
head = 0
}
buffer.insert(contentsOf: characters, at: 0)
}
}
}
}
extension StringProtocol {
/// Trims leading and trailing XML whitespace without Foundation.
func trimmingXMLWhitespace() -> String {
var slice = self[...]
while let first = slice.first, first.isXMLWhitespace {
slice = slice.dropFirst()
}
while let last = slice.last, last.isXMLWhitespace {
slice = slice.dropLast()
}
return String(slice)
}
}
extension Character {
private typealias XML = PureXML.Parsing.XMLCharacter
/// XML S production: space, tab, carriage return, line feed.
var isXMLWhitespace: Bool {
unicodeScalars.count == 1 && unicodeScalars.first.map(XML.isWhitespace) == true
}
/// Whether this character may start an XML name. A grapheme qualifies when its
/// first scalar is a NameStartChar and any trailing scalars (combining marks)
/// are NameChar.
var isXMLNameStart: Bool {
guard let first = unicodeScalars.first, XML.isNameStart(first) else { return false }
return unicodeScalars.dropFirst().allSatisfy(XML.isNameChar)
}
/// Whether this character may continue an XML name (every scalar a NameChar).
var isXMLNameContinuation: Bool {
!unicodeScalars.isEmpty && unicodeScalars.allSatisfy(XML.isNameChar)
}
}

View File

@ -0,0 +1,109 @@
/// The bulk byte-run scanners, split into their own file so `Reader`'s primary
/// type body and `Reader.swift` both stay under the length caps. They consume
/// plain ASCII runs of content or attribute-value text directly from the owned
/// storage (the common case in any real document) and hand subtle bytes back to
/// the Character path. Both engage only when the lookahead buffer is empty, read
/// the cursor through `Reader`'s `private(set)` accessors, and commit their
/// position deltas through `advanceByteRun` so all cursor mutation stays in
/// `Reader.swift`.
extension PureXML.Parsing.Reader {
/// Bulk-scans character data in byte mode: consumes and returns the longest
/// upcoming run of plain ASCII content bytes (no '<', no carriage return, no
/// "]]" pair, every byte a valid XML character), or nil when the fast path
/// does not apply. Anything subtle, an entity boundary aside ('&' is plain
/// content here, noted via `sawAmpersand`), is left for the Character path so
/// error marks stay exact. Returns nil rather than an empty run so callers
/// can alternate with the slow loop.
mutating func contentRunBytes(sawAmpersand: inout Bool) -> String? {
guard buffer.isEmpty, pendingRaw == nil, let storage else { return nil }
let pointer = storage.pointer
let count = storage.count
// A run never contains ']', so "]]>" can only straddle the boundary where
// the slow path consumed "]]" and the run would begin with '>': leave a
// leading '>' to the slow path and its cdataCloseInContent check (W3C
// ibm14n01).
if byteIndex < count, pointer[byteIndex] == 0x3E {
return nil
}
var index = byteIndex
var newlines = 0
var lastLineStart = -1
while index < count {
let byte = pointer[index]
if byte == 0x3C {
break
} // '<'
// Valid plain ASCII content only; CR, ']' and non-ASCII go to the
// character path.
guard byte < 0x80, byte != 0x0D, byte != 0x5D,
byte >= 0x20 || byte == 0x09 || byte == 0x0A
else {
if index == byteIndex {
return nil
}
break
}
if byte == 0x0A {
newlines += 1
lastLineStart = index
} else if byte == 0x26 {
sawAmpersand = true
}
index += 1
}
guard index > byteIndex else { return nil }
let run = String(decoding: UnsafeBufferPointer(start: pointer + byteIndex, count: index - byteIndex), as: UTF8.self)
advanceByteRun(to: index, length: index - byteIndex, newlines: newlines, lastLineStart: lastLineStart)
return run
}
/// Bulk-scans an attribute value in byte mode: consumes and returns the
/// longest upcoming run of plain ASCII value bytes, stopping at the closing
/// `quote`, a raw '<', a carriage return (2.11 folding then 3.3.3
/// normalization must run on the Character path), a control character, or a
/// non-ASCII byte. Tab and line feed inside the run normalize to a single
/// space per 3.3.3; '&' stays raw for later reference decoding and is noted
/// via `sawAmpersand`. Returns nil when the fast path does not apply or the
/// run would be empty, so callers alternate with the slow loop.
mutating func attributeRunBytes(quote: UInt8, sawAmpersand: inout Bool) -> String? {
guard buffer.isEmpty, pendingRaw == nil, let storage else { return nil }
let pointer = storage.pointer
let count = storage.count
var index = byteIndex
var newlines = 0
var lastLineStart = -1
var needsTransform = false
scan: while index < count {
switch pointer[index] {
case quote, 0x3C, 0x80...: break scan
case 0x0A: needsTransform = true
newlines += 1
lastLineStart = index
case 0x09: needsTransform = true
case 0x26: sawAmpersand = true
case ..<0x20: break scan
default: break
}
index += 1
}
guard index > byteIndex else { return nil }
let length = index - byteIndex
let run = attributeRunString(pointer, from: byteIndex, length: length, transform: needsTransform)
advanceByteRun(to: index, length: length, newlines: newlines, lastLineStart: lastLineStart)
return run
}
/// Builds the value string for an attribute byte run, applying the 3.3.3
/// tab/line-feed-to-space normalization only when the run contained one.
private func attributeRunString(_ pointer: UnsafePointer<UInt8>, from start: Int, length: Int, transform: Bool) -> String {
guard transform else {
return String(decoding: UnsafeBufferPointer(start: pointer + start, count: length), as: UTF8.self)
}
var bytes = [UInt8](repeating: 0, count: length)
for position in 0 ..< length {
let byte = pointer[start + position]
bytes[position] = (byte == 0x09 || byte == 0x0A) ? 0x20 : byte
}
return String(decoding: bytes, as: UTF8.self)
}
}

View File

@ -19,7 +19,9 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append(scalar)
} else {
scalars.append("\u{FFFD}")
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) } // reprocess ASCII trail
if byte <= 0x7F {
scalars.append(Unicode.Scalar(byte))
} // reprocess ASCII trail
}
} else if let single = singleByteScalar(byte) {
scalars.append(single)
@ -29,15 +31,21 @@ extension PureXML.Parsing.ByteDecoder {
scalars.append("\u{FFFD}")
}
}
if lead != 0 { scalars.append("\u{FFFD}") }
if lead != 0 {
scalars.append("\u{FFFD}")
}
return String(scalars)
}
/// The scalar for a byte that is not part of a two-byte sequence: ASCII
/// (`0x00`-`0x80`) or half-width katakana (`0xA1`-`0xDF`), else nil.
static func singleByteScalar(_ byte: UInt8) -> Unicode.Scalar? {
if byte <= 0x80 { return Unicode.Scalar(byte) }
if (0xA1 ... 0xDF).contains(byte) { return Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) }
if byte <= 0x80 {
return Unicode.Scalar(byte)
}
if (0xA1 ... 0xDF).contains(byte) {
return Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1)
}
return nil
}
@ -53,7 +61,9 @@ extension PureXML.Parsing.ByteDecoder {
let offset = lead < 0xA0 ? 0x81 : 0xC1
let trailOffset = trail < 0x7F ? 0x40 : 0x41
let pointer = (Int(lead) - offset) * 188 + Int(trail) - trailOffset
if (8836 ... 10715).contains(pointer) { return Unicode.Scalar(0xE000 + pointer - 8836) } // EUDC private use
if (8836 ... 10715).contains(pointer) {
return Unicode.Scalar(0xE000 + pointer - 8836)
} // EUDC private use
guard pointer >= 0, pointer < jis0208.count, jis0208[pointer] != 0xFFFF else { return nil }
return Unicode.Scalar(UInt32(jis0208[pointer]))
}

View File

@ -47,136 +47,6 @@ extension PureXML.Parsing {
}
}
/// Streams one character from a multi-byte CJK encoding.
private mutating func nextCJK(_ encoding: InputEncoding) -> Character? {
switch encoding {
case .shiftJIS: nextShiftJIS()
case .eucJP: nextEUCJP()
case .eucKR: nextEUCKR()
case .gbk: nextGBK()
case .gb18030: nextGB18030()
case .big5: nextBig5()
case .iso2022jp: nextISO2022JP()
case .eucTW: nextEUCTW()
default: nextUTF8()
}
}
/// Streams one GBK character: ASCII, `0x80` (euro), or a lead+trail.
private mutating func nextGBK() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
if lead == 0x80 { return "\u{20AC}" }
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
}
/// Streams one GB18030 character: ASCII, `0x80` (euro), a four-byte
/// sequence when the lead is followed by a digit, or a two-byte GBK pair.
private mutating func nextGB18030() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
if lead == 0x80 { return "\u{20AC}" }
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let second = nextByte() else { finished = true
return Self.replacement
}
if (0x30 ... 0x39).contains(second) {
guard let third = nextByte(), let fourth = nextByte() else { finished = true
return Self.replacement
}
guard (0x81 ... 0xFE).contains(third), (0x30 ... 0x39).contains(fourth) else { return Self.replacement }
let pointer = PureXML.Parsing.ByteDecoder.GB18030.fourBytePointer(lead, second, third, fourth)
return PureXML.Parsing.ByteDecoder.GB18030.rangeScalar(pointer).map(Character.init) ?? Self.replacement
}
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: second).map(Character.init) ?? Self.replacement
}
/// Streams one Big5 character: ASCII, or a lead+trail through the index
/// (a two-scalar combining sequence stays one grapheme, one `Character`).
private mutating func nextBig5() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let trail = nextByte() else { finished = true
return Self.replacement
}
guard let sequence = PureXML.Parsing.ByteDecoder.Big5.mapping(lead: lead, trail: trail) else {
return Self.replacement
}
return Character(String(String.UnicodeScalarView(sequence)))
}
/// Streams one EUC-KR character: ASCII, or a lead+trail through CP949.
private mutating func nextEUCKR() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.EUCKR.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
}
/// Streams one EUC-JP character: ASCII, `0x8E`+byte (half-width katakana),
/// `0x8F`+two bytes (JIS X 0212), or a lead+byte through JIS X 0208.
private mutating func nextEUCJP() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
if lead == 0x8E {
guard let byte = nextByte() else { finished = true
return Self.replacement
}
guard (0xA1 ... 0xDF).contains(byte) else { return Self.replacement }
return Character(Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) ?? "\u{FFFD}")
}
if lead == 0x8F {
guard let mid = nextByte(), let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: mid, trail: trail, useJis0212: true).map(Character.init) ?? Self.replacement
}
guard let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: lead, trail: trail, useJis0212: false).map(Character.init) ?? Self.replacement
}
/// Streams one Shift-JIS character: a single byte (ASCII or half-width
/// katakana) or a lead+trail two-byte sequence resolved through JIS X 0208.
private mutating func nextShiftJIS() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if let single = PureXML.Parsing.ByteDecoder.ShiftJIS.singleByteScalar(lead) { return Character(single) }
guard PureXML.Parsing.ByteDecoder.ShiftJIS.isLead(lead) else { return Self.replacement }
guard let trail = nextByte() else {
finished = true
return Self.replacement
}
guard let scalar = PureXML.Parsing.ByteDecoder.ShiftJIS.twoByteScalar(lead: lead, trail: trail) else { return Self.replacement }
return Character(scalar)
}
private mutating func nextUTF32(bigEndian: Bool) -> Character? {
var ordered: [UInt32] = []
for _ in 0 ..< 4 {
@ -186,7 +56,9 @@ extension PureXML.Parsing {
}
ordered.append(UInt32(byte))
}
if !bigEndian { ordered.reverse() }
if !bigEndian {
ordered.reverse()
}
let value = ordered.reduce(UInt32(0)) { ($0 << 8) | $1 }
return Character(Unicode.Scalar(value) ?? "\u{FFFD}")
}
@ -229,7 +101,7 @@ extension PureXML.Parsing {
return pull()
}
private mutating func nextUTF8() -> Character? {
mutating func nextUTF8() -> Character? {
guard let first = nextByte() else {
finished = true
return nil

View File

@ -0,0 +1,147 @@
extension PureXML.Parsing.StreamingDecoder {
/// Streams one character from a multi-byte CJK encoding.
mutating func nextCJK(_ encoding: PureXML.Parsing.InputEncoding) -> Character? {
switch encoding {
case .shiftJIS: nextShiftJIS()
case .eucJP: nextEUCJP()
case .eucKR: nextEUCKR()
case .gbk: nextGBK()
case .gb18030: nextGB18030()
case .big5: nextBig5()
case .iso2022jp: nextISO2022JP()
case .eucTW: nextEUCTW()
default: nextUTF8()
}
}
/// Streams one GBK character: ASCII, `0x80` (euro), or a lead+trail.
private mutating func nextGBK() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F {
return Character(Unicode.Scalar(lead))
}
if lead == 0x80 {
return "\u{20AC}"
}
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
}
/// Streams one GB18030 character: ASCII, `0x80` (euro), a four-byte
/// sequence when the lead is followed by a digit, or a two-byte GBK pair.
private mutating func nextGB18030() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F {
return Character(Unicode.Scalar(lead))
}
if lead == 0x80 {
return "\u{20AC}"
}
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let second = nextByte() else { finished = true
return Self.replacement
}
if (0x30 ... 0x39).contains(second) {
guard let third = nextByte(), let fourth = nextByte() else { finished = true
return Self.replacement
}
guard (0x81 ... 0xFE).contains(third), (0x30 ... 0x39).contains(fourth) else { return Self.replacement }
let pointer = PureXML.Parsing.ByteDecoder.GB18030.fourBytePointer(lead, second, third, fourth)
return PureXML.Parsing.ByteDecoder.GB18030.rangeScalar(pointer).map(Character.init) ?? Self.replacement
}
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: second).map(Character.init) ?? Self.replacement
}
/// Streams one Big5 character: ASCII, or a lead+trail through the index
/// (a two-scalar combining sequence stays one grapheme, one `Character`).
private mutating func nextBig5() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F {
return Character(Unicode.Scalar(lead))
}
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let trail = nextByte() else { finished = true
return Self.replacement
}
guard let sequence = PureXML.Parsing.ByteDecoder.Big5.mapping(lead: lead, trail: trail) else {
return Self.replacement
}
return Character(String(String.UnicodeScalarView(sequence)))
}
/// Streams one EUC-KR character: ASCII, or a lead+trail through CP949.
private mutating func nextEUCKR() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F {
return Character(Unicode.Scalar(lead))
}
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
guard let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.EUCKR.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
}
/// Streams one EUC-JP character: ASCII, `0x8E`+byte (half-width katakana),
/// `0x8F`+two bytes (JIS X 0212), or a lead+byte through JIS X 0208.
private mutating func nextEUCJP() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if lead <= 0x7F {
return Character(Unicode.Scalar(lead))
}
if lead == 0x8E {
guard let byte = nextByte() else { finished = true
return Self.replacement
}
guard (0xA1 ... 0xDF).contains(byte) else { return Self.replacement }
return Character(Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) ?? "\u{FFFD}")
}
if lead == 0x8F {
guard let mid = nextByte(), let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: mid, trail: trail, useJis0212: true).map(Character.init) ?? Self.replacement
}
guard let trail = nextByte() else { finished = true
return Self.replacement
}
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: lead, trail: trail, useJis0212: false).map(Character.init) ?? Self.replacement
}
/// Streams one Shift-JIS character: a single byte (ASCII or half-width
/// katakana) or a lead+trail two-byte sequence resolved through JIS X 0208.
private mutating func nextShiftJIS() -> Character? {
guard let lead = nextByte() else {
finished = true
return nil
}
if let single = PureXML.Parsing.ByteDecoder.ShiftJIS.singleByteScalar(lead) {
return Character(single)
}
guard PureXML.Parsing.ByteDecoder.ShiftJIS.isLead(lead) else { return Self.replacement }
guard let trail = nextByte() else {
finished = true
return Self.replacement
}
guard let scalar = PureXML.Parsing.ByteDecoder.ShiftJIS.twoByteScalar(lead: lead, trail: trail) else { return Self.replacement }
return Character(scalar)
}
}

View File

@ -6,7 +6,9 @@ extension PureXML.Parsing.StreamingDecoder {
finished = true
return nil
}
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
if lead <= 0x7F {
return Character(Unicode.Scalar(lead))
}
if lead == 0x8E {
guard let plane = nextByte(), let byte1 = nextByte(), let byte2 = nextByte() else {
finished = true

View File

@ -186,7 +186,9 @@ public extension PureXML.Parsing {
applyStartElement(name: name, attributes: attributes)
case let .endElement(name):
currentDepth -= 1
if !langStack.isEmpty { langStack.removeLast() }
if !langStack.isEmpty {
langStack.removeLast()
}
currentName = name
xmlLang = langStack.last ?? nil
set(.endElement, name: name.description, depth: currentDepth)

View File

@ -50,9 +50,26 @@ public extension PureXML.Parsing {
}
}
/// The ASCII subset of `NameStartChar`, tested on a raw byte for the
/// parser's byte-level fast path. A non-ASCII name-start (byte >= 0x80)
/// is left to the scalar predicate via the Character scanner.
static func isASCIINameStart(_ byte: UInt8) -> Bool {
switch byte {
case 0x3A, 0x41 ... 0x5A, 0x5F, 0x61 ... 0x7A: true
default: false
}
}
/// The ASCII subset of `NameChar` (after the first), on a raw byte.
static func isASCIINameChar(_ byte: UInt8) -> Bool {
isASCIINameStart(byte) || (byte >= 0x30 && byte <= 0x39) || byte == 0x2D || byte == 0x2E
}
/// `NameChar`: a name character after the first.
public static func isNameChar(_ scalar: Unicode.Scalar) -> Bool {
if isNameStart(scalar) { return true }
if isNameStart(scalar) {
return true
}
switch scalar.value {
case 0x2D, // "-"
0x2E, // "."
@ -71,7 +88,9 @@ public extension PureXML.Parsing {
var scalars = name.unicodeScalars.makeIterator()
guard let first = scalars.next(), isNameStart(first) else { return false }
while let scalar = scalars.next() {
if !isNameChar(scalar) { return false }
if !isNameChar(scalar) {
return false
}
}
return true
}

View File

@ -22,7 +22,9 @@ extension PureXML.Pattern {
case let .prefixWildcard(prefix):
return name.prefix == prefix
case let .name(text):
if text.contains(":") { return name.description == text }
if text.contains(":") {
return name.description == text
}
return name.description == text || name.localName == text
}
}
@ -105,7 +107,9 @@ public extension PureXML.Pattern {
private func ancestorMatches(_ stepIndex: Int, below pathIndex: Int, _ path: [PureXML.Model.QualifiedName]) -> Bool {
var candidate = pathIndex - 1
while candidate >= 0 {
if matchUpTo(stepIndex, candidate, path) { return true }
if matchUpTo(stepIndex, candidate, path) {
return true
}
candidate -= 1
}
return false

View File

@ -42,8 +42,12 @@ extension PureXML.Pattern {
}
private mutating func leadingGap() -> Gap {
if consume("//") { return .descendant }
if consume("/") { return .root }
if consume("//") {
return .descendant
}
if consume("/") {
return .root
}
return .descendant
}
@ -60,11 +64,17 @@ extension PureXML.Pattern {
private mutating func parseStep(gap: Gap) throws -> RawStep {
guard let character = peek() else { throw PatternError.expectedStep }
if character == "[" { throw PatternError.unsupported("predicate") }
if character == "." { throw PatternError.unsupported(".") }
if character == "[" {
throw PatternError.unsupported("predicate")
}
if character == "." {
throw PatternError.unsupported(".")
}
let isAttribute = consume("@")
let test = try parseTest()
if peek() == "[" { throw PatternError.unsupported("predicate") }
if peek() == "[" {
throw PatternError.unsupported("predicate")
}
return RawStep(gap: gap, isAttribute: isAttribute, test: test)
}
@ -116,7 +126,9 @@ extension PureXML.Pattern {
}
private mutating func advance() {
if index < chars.count { index += 1 }
if index < chars.count {
index += 1
}
}
}
}

View File

@ -29,7 +29,9 @@ public extension PureXML.Pattern.Matcher {
stack.append(name)
collect(stack: stack, attributes: attributes, into: &results)
case .endElement:
if !stack.isEmpty { stack.removeLast() }
if !stack.isEmpty {
stack.removeLast()
}
default:
break
}

View File

@ -19,17 +19,20 @@ extension PureXML.Regex {
var accept: Int
/// Whether the automaton matches the whole string (XSD patterns are
/// implicitly anchored at both ends).
/// implicitly anchored at both ends). XSD regular expressions match a
/// sequence of Unicode code points, not Swift extended grapheme clusters.
func matchesWhole(_ string: String) -> Bool {
var current = closure([start])
for character in string {
for scalar in string.unicodeScalars {
var next: Set<Int> = []
for state in current {
if let transition = states[state].transition, transition.charClass.matches(character) {
if let transition = states[state].transition, transition.charClass.matches(scalar) {
next.insert(transition.target)
}
}
if next.isEmpty { return false }
if next.isEmpty {
return false
}
current = closure(next)
}
return current.contains(accept)
@ -103,7 +106,11 @@ extension PureXML.Regex {
var last: Int?
for node in nodes {
let part = fragment(for: node)
if let previous = last { states[previous].epsilon.append(part.start) } else { first = part.start }
if let previous = last {
states[previous].epsilon.append(part.start)
} else {
first = part.start
}
last = part.accept
}
return (first ?? addState(), last ?? addState())
@ -126,7 +133,9 @@ extension PureXML.Regex {
let boundedMin = Swift.min(minimum, Self.repeatUnrollCap)
var unrolled = 0
for _ in 0 ..< boundedMin {
if stateBudgetExhausted { break }
if stateBudgetExhausted {
break
}
let part = fragment(for: node)
states[current].epsilon.append(part.start)
current = part.accept

View File

@ -23,16 +23,18 @@ extension PureXML.Regex {
/// a two-letter category (`Lu`, `Nd`, ...), or an `Is<Block>` block name.
enum CategoryMatcher {
static func isKnown(_ name: String) -> Bool {
if name.hasPrefix("Is") { return blocks[String(name.dropFirst(2))] != nil }
if name.hasPrefix("Is") {
return blocks[String(name.dropFirst(2))] != nil
}
return groups.contains(name) || codeValues.contains(name)
}
static func matches(_ name: String, _ scalar: Unicode.Scalar) -> Bool {
let code = XSDCategory.generalCategoryCode(scalar)
if name.hasPrefix("Is") {
guard let range = blocks[String(name.dropFirst(2))] else { return false }
return range.contains(scalar.value)
}
let code = codes[scalar.properties.generalCategory] ?? "Cn"
return name.count == 1 ? code.hasPrefix(name) : code == name
}
@ -52,13 +54,21 @@ extension PureXML.Regex {
.control: "Cc", .format: "Cf", .surrogate: "Cs", .privateUse: "Co", .unassigned: "Cn",
]
/// The Unicode block names XSD 1.0 admits in `\p{Is<Block>}` (the
/// Unicode 3.1 blocks the regular-expression appendix fixes), each mapped
/// to its code-point range. An `Is<Block>` name outside this set is
/// rejected rather than guessed, so a pattern never matches against a
/// fabricated range. Surrogate blocks are omitted: surrogates are not
/// scalar values. `Specials` uses the main `FFF0..FFFD` range (the spec's
/// separate `FEFF` half is the byte-order mark, not matched here).
/// The complete set of Unicode block names XSD 1.0 admits in
/// `\p{Is<Block>}` (the Unicode 3.1 blocks the regular-expression appendix
/// fixes), each mapped to its code-point range. The set is exhaustive: an
/// `Is<Block>` name outside it is no XSD block and is rejected at compile
/// time (`isMalformedProperty` is gone; the predicate's presence here is the
/// sole authority), so a pattern never matches against a fabricated range.
/// XSD 1.0 freezes this list at Unicode 3.1, so a block introduced later
/// (e.g. `CyrillicSupplement`) is by design not an XSD 1.0 block and is
/// rejected, even though some processors accept any `Is[A-Za-z0-9-]+`
/// leniently. This is a deliberate spec-strict choice, not an oversight.
/// The three surrogate blocks are listed so a pattern naming them still
/// compiles, but their ranges hold no scalar value, so they match nothing.
/// `Specials` uses the main `FFF0..FFFD` range (the spec's separate `FEFF`
/// half is the byte-order mark, not matched here). `GreekandCoptic` is the
/// later Unicode rename of `Greek`, kept as a lenient alias.
private static let blocks: [String: ClosedRange<UInt32>] = [
"BasicLatin": 0x0000 ... 0x007F,
"Latin-1Supplement": 0x0080 ... 0x00FF,
@ -145,6 +155,21 @@ extension PureXML.Regex {
"ArabicPresentationForms-B": 0xFE70 ... 0xFEFF,
"HalfwidthandFullwidthForms": 0xFF00 ... 0xFFEF,
"Specials": 0xFFF0 ... 0xFFFD,
// Surrogate blocks: valid XSD block names whose ranges hold no scalar
// value (lone surrogates are not Unicode scalars), so they match nothing.
"HighSurrogates": 0xD800 ... 0xDB7F,
"HighPrivateUseSurrogates": 0xDB80 ... 0xDBFF,
"LowSurrogates": 0xDC00 ... 0xDFFF,
// Supplementary-plane blocks (Unicode 3.1, beyond the BMP).
"OldItalic": 0x10300 ... 0x1032F,
"Gothic": 0x10330 ... 0x1034F,
"Deseret": 0x10400 ... 0x1044F,
"ByzantineMusicalSymbols": 0x1D000 ... 0x1D0FF,
"MusicalSymbols": 0x1D100 ... 0x1D1FF,
"MathematicalAlphanumericSymbols": 0x1D400 ... 0x1D7FF,
"CJKUnifiedIdeographsExtensionB": 0x20000 ... 0x2A6D6,
"CJKCompatibilityIdeographsSupplement": 0x2F800 ... 0x2FA1F,
"Tags": 0xE0000 ... 0xE007F,
]
}
}

View File

@ -20,7 +20,9 @@ extension PureXML.Regex {
}
static func parse(_ pattern: String) throws -> Node {
guard !pattern.isEmpty else { throw RegexError.empty }
if pattern.isEmpty {
return .empty
}
var parser = RegexParser(pattern)
let node = try parser.parseAlternation()
guard parser.isAtEnd else { throw RegexError.unbalanced }
@ -41,7 +43,9 @@ extension PureXML.Regex {
while let character = peek(), character != "|", character != ")" {
try pieces.append(parsePiece())
}
if pieces.isEmpty { return .empty }
if pieces.isEmpty {
return .empty
}
return pieces.count == 1 ? pieces[0] : .concat(pieces)
}
@ -75,6 +79,9 @@ extension PureXML.Regex {
maximum = peek() == "}" ? nil : parseInteger()
}
guard peek() == "}", let minimum else { throw RegexError.badQuantifier }
if let maximum, minimum > maximum {
throw RegexError.reversedQuantifier
}
advance()
return .repeated(atom, min: minimum, max: maximum)
}
@ -105,7 +112,7 @@ extension PureXML.Regex {
// MARK: Escapes
private mutating func parseEscape() throws -> CharClass {
guard let character = peek() else { throw RegexError.badEscape("") }
guard let character = peek() else { throw RegexError.incompleteEscape }
advance()
if let single = Self.singleEscape(character) {
return .single(single)
@ -132,7 +139,13 @@ extension PureXML.Regex {
guard peek() == "}" else { throw RegexError.badClass }
advance()
guard let predicate = CategoryPredicate(name: name, negated: negated) else {
throw RegexError.unsupported("\\p{\(name)}")
// The recognised set (every Unicode general category plus the
// complete XSD 1.0 `Is<Block>` list, see CategoryMatcher) is the
// exhaustive set of XSD `charProp` names, so any name outside it is
// no XSD category or block and is a genuine syntax error, not an
// engine limitation. Rejecting it (XSTS reK88, `\p{IsaA0-a9}`)
// cannot reject a valid pattern because the set is complete.
throw RegexError.invalidProperty
}
return predicate
}
@ -178,6 +191,10 @@ extension PureXML.Regex {
advance()
cls.negated = true
}
// A class must have at least one member; `[]` (or `[^]`) is invalid.
if peek() == "]" {
throw RegexError.emptyClass
}
while let character = peek(), character != "]" {
if character == "-", peek(1) == "[" {
advance()
@ -186,7 +203,7 @@ extension PureXML.Regex {
}
try parseClassMember(into: &cls)
}
guard peek() == "]" else { throw RegexError.badClass }
guard peek() == "]" else { throw RegexError.unterminatedClass }
advance()
return cls
}
@ -204,7 +221,7 @@ extension PureXML.Regex {
if peek() == "-", peek(1) != "]", peek(1) != "[" {
advance()
guard case let .scalar(high) = try nextClassMember() else { throw RegexError.badClass }
guard low <= high else { throw RegexError.badClass }
guard low <= high else { throw RegexError.reversedRange }
cls.ranges.append(low ... high)
} else {
cls.ranges.append(low ... low)
@ -221,13 +238,25 @@ extension PureXML.Regex {
}
private mutating func nextClassMember() throws -> ClassMember {
guard let character = peek() else { throw RegexError.badClass }
guard let character = peek() else { throw RegexError.unterminatedClass }
advance()
guard character == "\\" else { return .scalar(scalar(of: character)) }
guard let escaped = peek() else { throw RegexError.badClass }
guard character == "\\" else {
// A nested `[` is legal only as a `-[...]` subtraction, routed by
// parseClass/parseClassMember before reaching here; any other `[`
// is an unescaped class bracket, invalid per Appendix F.
if character == "[" {
throw RegexError.unescapedClassBracket
}
return .scalar(scalar(of: character))
}
guard let escaped = peek() else { throw RegexError.incompleteEscape }
advance()
if let single = Self.singleEscape(escaped) { return .scalar(single) }
if let member = Self.classEscape(escaped) { return member }
if let single = Self.singleEscape(escaped) {
return .scalar(single)
}
if let member = Self.classEscape(escaped) {
return member
}
if escaped == "p" || escaped == "P" {
return try .category(parseCategory(negated: escaped == "P"))
}
@ -259,7 +288,9 @@ extension PureXML.Regex {
}
private mutating func advance() {
if index < chars.count { index += 1 }
if index < chars.count {
index += 1
}
}
}
}

View File

@ -1,23 +1,63 @@
extension PureXML.Regex {
/// An error compiling a regular expression.
enum RegexError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
case empty
case unbalanced
case danglingQuantifier
case badEscape(String)
case badClass
case badQuantifier
case unsupported(String)
/// A character-class range whose first endpoint exceeds its second
/// (`[b-a]`): unambiguously invalid, never an engine limitation.
case reversedRange
/// An empty character class (`[]`): a class requires at least one member.
case emptyClass
/// A bounded quantifier whose minimum exceeds its maximum (`{37,17}`).
case reversedQuantifier
/// A `\` with no following character (`a\`): an escape was opened but the
/// expression ended. Unambiguously invalid, never an engine limitation.
case incompleteEscape
/// A character class opened with `[` that the expression ends before
/// closing with `]` (`a[`). Unambiguously invalid.
case unterminatedClass
/// An unescaped `[` inside a character class (`[a[bc]`, `[a[:xyz:]`): XSD
/// Appendix F `SingleCharNoEsc` excludes `[`, and a nested class is legal
/// only as a `-[...]` subtraction, so a `[` not introduced by `-` is a
/// syntax error. Unambiguously invalid per the grammar, never an engine
/// limitation.
///
/// Contested-corpus note: this rejects not only the W3C-*settled* invalid
/// cases (XSTS RegexTest_993/1477, `[a[:xyz:]`) but also siblings the MS
/// suite marks *valid* (`([[:]+)`, `([[=]+)`, `([[.]+)`, RegexTest_989/990/
/// 991/1473-1475, and the space-bearing subtraction `[a - c - [ b ] ]+`,
/// 479/480). Those entries are W3C-*queried* (status="queried", Bugzilla
/// #4118) precisely because "valid" contradicts the Appendix F grammar; the
/// suite's settled position (993/1477) agrees with rejecting. PureXML
/// follows the grammar, which is also the W3C's settled direction. The
/// queried siblings are excluded from the XSTS conformance gate, so this
/// is not a counted false positive. A deliberate spec-strict choice, not an
/// oversight.
case unescapedClassBracket
/// The `{...}` of a `\p`/`\P` escape whose content can be no XSD charProp:
/// empty, the bare prefix `Is`, a non-block name carrying a character that
/// is not a letter, or `Is` followed by a non-block-name character
/// (`\p{\L}`, `\p{Is}`). Unambiguously invalid, distinct from an unknown but
/// well-formed category or block name (which stays an `unsupported` limit).
case invalidProperty
var description: String {
switch self {
case .empty: "the pattern is empty"
case .unbalanced: "unbalanced parentheses"
case .danglingQuantifier: "a quantifier has nothing to repeat"
case let .badEscape(detail): "invalid escape '\\\(detail)'"
case .badClass: "malformed character class"
case .badQuantifier: "malformed quantifier"
case let .unsupported(detail): "unsupported construct: \(detail)"
case .reversedRange: "character-class range is reversed"
case .emptyClass: "empty character class"
case .reversedQuantifier: "quantifier minimum exceeds maximum"
case .incompleteEscape: "an escape '\\' has no following character"
case .unterminatedClass: "a character class is not closed with ']'"
case .unescapedClassBracket: "an unescaped '[' inside a character class"
case .invalidProperty: "a \\p{...} property name is malformed"
}
}
}
@ -33,15 +73,20 @@ extension PureXML.Regex {
func contains(_ scalar: Unicode.Scalar) -> Bool {
switch self {
case .digit: scalar.properties.numericType == .decimal
case .space: scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
// XSD \w is [#x0000-#x10FFFF]-[\p{P}\p{Z}\p{C}]: everything except
// punctuation, separators, and other. So symbols and marks are word
// characters, and `_` (Pc) is not, unlike the Perl definition.
case .word: !(CategoryMatcher.matches("P", scalar) || CategoryMatcher.matches("Z", scalar) || CategoryMatcher.matches("C", scalar))
case .nameStart: PureXML.Parsing.XMLCharacter.isNameStart(scalar)
case .nameChar: PureXML.Parsing.XMLCharacter.isNameChar(scalar)
case .anyButLineBreak: scalar != "\n" && scalar != "\r"
case .digit: return XSDCategory.generalCategoryCode(scalar) == "Nd"
case .space: return scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
// XSD \w is [#x0000-#x10FFFF]-[\p{P}\p{Z}\p{C}] (Datatypes Appendix F).
// Symbols and marks are word characters; `_` (Pc) is not, unlike the
// Perl definition.
case .word:
let code = XSDCategory.generalCategoryCode(scalar)
return !(code.hasPrefix("P") || code.hasPrefix("Z") || code.hasPrefix("C"))
// \i and \c are the XML 1.0 Second Edition name classes (Appendix B),
// a fixed Unicode-2.0 repertoire, NOT the parser's Fifth Edition Name
// production: the two genuinely differ (XSTS reZ005/reZ006).
case .nameStart: return XSDNameCharacter.isNameStart(scalar)
case .nameChar: return XSDNameCharacter.isNameChar(scalar)
case .anyButLineBreak: return scalar != "\n" && scalar != "\r"
}
}
}
@ -62,12 +107,24 @@ extension PureXML.Regex {
guard character.unicodeScalars.count == 1, let scalar = character.unicodeScalars.first else {
return false
}
var hit = ranges.contains { $0.contains(scalar) }
return matches(scalar)
}
func matches(_ scalar: Unicode.Scalar) -> Bool {
let inBase = ranges.contains { $0.contains(scalar) }
|| named.contains { $0.contains(scalar) }
|| negatedNamed.contains { !$0.contains(scalar) }
|| categories.contains { $0.contains(scalar) }
if hit, subtraction.contains(where: { $0.matches(character) }) { hit = false }
return negated ? !hit : hit
// XSD `charClassSub ::= (posCharGroup | negCharGroup) '-' charClassExpr`
// is the set difference of the (possibly NEGATED) group and the
// subtrahend, so negation applies to the base FIRST, then subtraction
// removes from that result: `[^cde-[ag]]` is (not c/d/e) minus a/g,
// i.e. excludes {a,c,d,e,g}, not just {c,d,e} (XSTS RegexTest_430).
var hit = negated ? !inBase : inBase
if hit, subtraction.contains(where: { $0.matches(scalar) }) {
hit = false
}
return hit
}
static func single(_ scalar: Unicode.Scalar) -> CharClass {

View File

@ -0,0 +1,97 @@
extension PureXML.Regex.XSDCategory {
/// Inclusive `[lo, hi]` code-point bounds of every range assigned by Unicode
/// 3.2. XSD 1.0 regular expressions are normatively defined against Unicode
/// 3.1, but the W3C conformance corpus is internally consistent only at 3.2:
/// it expects code points added in 3.2 (Tagalog, Cyrillic Komi, and others)
/// to be word characters, yet code points added in 4.0 (such as the Tamil
/// digit zero U+0BE6) to be unassigned and so match `\D`. Derived clean-room
/// from the Unicode Character Database `DerivedAge` property (ages <= 3.2),
/// stored sorted and non-overlapping so membership is a binary search.
private static let assignedRanges: [UInt32] = [
0x0, 0x220, 0x222, 0x233, 0x250, 0x2AD, 0x2B0, 0x2EE, 0x300, 0x34F, 0x360, 0x36F,
0x374, 0x375, 0x37A, 0x37A, 0x37E, 0x37E, 0x384, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1,
0x3A3, 0x3CE, 0x3D0, 0x3F6, 0x400, 0x486, 0x488, 0x4CE, 0x4D0, 0x4F5, 0x4F8, 0x4F9,
0x500, 0x50F, 0x531, 0x556, 0x559, 0x55F, 0x561, 0x587, 0x589, 0x58A, 0x591, 0x5A1,
0x5A3, 0x5B9, 0x5BB, 0x5C4, 0x5D0, 0x5EA, 0x5F0, 0x5F4, 0x60C, 0x60C, 0x61B, 0x61B,
0x61F, 0x61F, 0x621, 0x63A, 0x640, 0x655, 0x660, 0x6ED, 0x6F0, 0x6FE, 0x700, 0x70D,
0x70F, 0x72C, 0x730, 0x74A, 0x780, 0x7B1, 0x901, 0x903, 0x905, 0x939, 0x93C, 0x94D,
0x950, 0x954, 0x958, 0x970, 0x981, 0x983, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8,
0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9BC, 0x9BC, 0x9BE, 0x9C4, 0x9C7, 0x9C8,
0x9CB, 0x9CD, 0x9D7, 0x9D7, 0x9DC, 0x9DD, 0x9DF, 0x9E3, 0x9E6, 0x9FA, 0xA02, 0xA02,
0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36,
0xA38, 0xA39, 0xA3C, 0xA3C, 0xA3E, 0xA42, 0xA47, 0xA48, 0xA4B, 0xA4D, 0xA59, 0xA5C,
0xA5E, 0xA5E, 0xA66, 0xA74, 0xA81, 0xA83, 0xA85, 0xA8B, 0xA8D, 0xA8D, 0xA8F, 0xA91,
0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, 0xAB5, 0xAB9, 0xABC, 0xAC5, 0xAC7, 0xAC9,
0xACB, 0xACD, 0xAD0, 0xAD0, 0xAE0, 0xAE0, 0xAE6, 0xAEF, 0xB01, 0xB03, 0xB05, 0xB0C,
0xB0F, 0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB36, 0xB39, 0xB3C, 0xB43,
0xB47, 0xB48, 0xB4B, 0xB4D, 0xB56, 0xB57, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66, 0xB70,
0xB82, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C,
0xB9E, 0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB5, 0xBB7, 0xBB9, 0xBBE, 0xBC2,
0xBC6, 0xBC8, 0xBCA, 0xBCD, 0xBD7, 0xBD7, 0xBE7, 0xBF2, 0xC01, 0xC03, 0xC05, 0xC0C,
0xC0E, 0xC10, 0xC12, 0xC28, 0xC2A, 0xC33, 0xC35, 0xC39, 0xC3E, 0xC44, 0xC46, 0xC48,
0xC4A, 0xC4D, 0xC55, 0xC56, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC82, 0xC83, 0xC85, 0xC8C,
0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBE, 0xCC4, 0xCC6, 0xCC8,
0xCCA, 0xCCD, 0xCD5, 0xCD6, 0xCDE, 0xCDE, 0xCE0, 0xCE1, 0xCE6, 0xCEF, 0xD02, 0xD03,
0xD05, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD28, 0xD2A, 0xD39, 0xD3E, 0xD43, 0xD46, 0xD48,
0xD4A, 0xD4D, 0xD57, 0xD57, 0xD60, 0xD61, 0xD66, 0xD6F, 0xD82, 0xD83, 0xD85, 0xD96,
0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0, 0xDC6, 0xDCA, 0xDCA, 0xDCF, 0xDD4,
0xDD6, 0xDD6, 0xDD8, 0xDDF, 0xDF2, 0xDF4, 0xE01, 0xE3A, 0xE3F, 0xE5B, 0xE81, 0xE82,
0xE84, 0xE84, 0xE87, 0xE88, 0xE8A, 0xE8A, 0xE8D, 0xE8D, 0xE94, 0xE97, 0xE99, 0xE9F,
0xEA1, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEA7, 0xEAA, 0xEAB, 0xEAD, 0xEB9, 0xEBB, 0xEBD,
0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xEC8, 0xECD, 0xED0, 0xED9, 0xEDC, 0xEDD, 0xF00, 0xF47,
0xF49, 0xF6A, 0xF71, 0xF8B, 0xF90, 0xF97, 0xF99, 0xFBC, 0xFBE, 0xFCC, 0xFCF, 0xFCF,
0x1000, 0x1021, 0x1023, 0x1027, 0x1029, 0x102A, 0x102C, 0x1032, 0x1036, 0x1039, 0x1040, 0x1059,
0x10A0, 0x10C5, 0x10D0, 0x10F8, 0x10FB, 0x10FB, 0x1100, 0x1159, 0x115F, 0x11A2, 0x11A8, 0x11F9,
0x1200, 0x1206, 0x1208, 0x1246, 0x1248, 0x1248, 0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258,
0x125A, 0x125D, 0x1260, 0x1286, 0x1288, 0x1288, 0x128A, 0x128D, 0x1290, 0x12AE, 0x12B0, 0x12B0,
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12CE, 0x12D0, 0x12D6,
0x12D8, 0x12EE, 0x12F0, 0x130E, 0x1310, 0x1310, 0x1312, 0x1315, 0x1318, 0x131E, 0x1320, 0x1346,
0x1348, 0x135A, 0x1361, 0x137C, 0x13A0, 0x13F4, 0x1401, 0x1676, 0x1680, 0x169C, 0x16A0, 0x16F0,
0x1700, 0x170C, 0x170E, 0x1714, 0x1720, 0x1736, 0x1740, 0x1753, 0x1760, 0x176C, 0x176E, 0x1770,
0x1772, 0x1773, 0x1780, 0x17DC, 0x17E0, 0x17E9, 0x1800, 0x180E, 0x1810, 0x1819, 0x1820, 0x1877,
0x1880, 0x18A9, 0x1E00, 0x1E9B, 0x1EA0, 0x1EF9, 0x1F00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D,
0x1F80, 0x1FB4, 0x1FB6, 0x1FC4, 0x1FC6, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FDD, 0x1FEF, 0x1FF2, 0x1FF4,
0x1FF6, 0x1FFE, 0x2000, 0x2052, 0x2057, 0x2057, 0x205F, 0x2063, 0x206A, 0x2071, 0x2074, 0x208E,
0x20A0, 0x20B1, 0x20D0, 0x20EA, 0x2100, 0x213A, 0x213D, 0x214B, 0x2153, 0x2183, 0x2190, 0x23CE,
0x2400, 0x2426, 0x2440, 0x244A, 0x2460, 0x24FE, 0x2500, 0x2613, 0x2616, 0x2617, 0x2619, 0x267D,
0x2680, 0x2689, 0x2701, 0x2704, 0x2706, 0x2709, 0x270C, 0x2727, 0x2729, 0x274B, 0x274D, 0x274D,
0x274F, 0x2752, 0x2756, 0x2756, 0x2758, 0x275E, 0x2761, 0x2794, 0x2798, 0x27AF, 0x27B1, 0x27BE,
0x27D0, 0x27EB, 0x27F0, 0x2AFF, 0x2E80, 0x2E99, 0x2E9B, 0x2EF3, 0x2F00, 0x2FD5, 0x2FF0, 0x2FFB,
0x3000, 0x303F, 0x3041, 0x3096, 0x3099, 0x30FF, 0x3105, 0x312C, 0x3131, 0x318E, 0x3190, 0x31B7,
0x31F0, 0x321C, 0x3220, 0x3243, 0x3251, 0x327B, 0x327F, 0x32CB, 0x32D0, 0x32FE, 0x3300, 0x3376,
0x337B, 0x33DD, 0x33E0, 0x33FE, 0x3400, 0x4DB5, 0x4E00, 0x9FA5, 0xA000, 0xA48C, 0xA490, 0xA4C6,
0xAC00, 0xD7A3, 0xD800, 0xFA2D, 0xFA30, 0xFA6A, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB36,
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3F,
0xFD50, 0xFD8F, 0xFD92, 0xFDC7, 0xFDD0, 0xFDFC, 0xFE00, 0xFE0F, 0xFE20, 0xFE23, 0xFE30, 0xFE46,
0xFE49, 0xFE52, 0xFE54, 0xFE66, 0xFE68, 0xFE6B, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFEFF, 0xFEFF,
0xFF01, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0xFFE0, 0xFFE6,
0xFFE8, 0xFFEE, 0xFFF9, 0xFFFF, 0x10300, 0x1031E, 0x10320, 0x10323, 0x10330, 0x1034A, 0x10400, 0x10425,
0x10428, 0x1044D, 0x1D000, 0x1D0F5, 0x1D100, 0x1D126, 0x1D12A, 0x1D1DD, 0x1D400, 0x1D454, 0x1D456, 0x1D49C,
0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE, 0x1D4B9, 0x1D4BB, 0x1D4BB,
0x1D4BD, 0x1D4C0, 0x1D4C2, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516, 0x1D51C,
0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552, 0x1D6A3,
0x1D6A8, 0x1D7C9, 0x1D7CE, 0x1D7FF, 0x1FFFE, 0x2A6D6, 0x2F800, 0x2FA1D, 0x2FFFE, 0x2FFFF, 0x3FFFE, 0x3FFFF,
0x4FFFE, 0x4FFFF, 0x5FFFE, 0x5FFFF, 0x6FFFE, 0x6FFFF, 0x7FFFE, 0x7FFFF, 0x8FFFE, 0x8FFFF, 0x9FFFE, 0x9FFFF,
0xAFFFE, 0xAFFFF, 0xBFFFE, 0xBFFFF, 0xCFFFE, 0xCFFFF, 0xDFFFE, 0xDFFFF, 0xE0001, 0xE0001, 0xE0020, 0xE007F,
0xEFFFE, 0x10FFFF,
]
/// Whether `value` was an assigned code point as of Unicode 3.2. Binary
/// search over ``assignedRanges``; each step compares one range's bounds.
static func assignedInUnicode32(_ value: UInt32) -> Bool {
var low = 0
var high = assignedRanges.count / 2
while low < high {
let mid = (low + high) / 2
if value < assignedRanges[mid * 2] {
high = mid
} else if value > assignedRanges[mid * 2 + 1] {
low = mid + 1
} else {
return true
}
}
return false
}
}

View File

@ -0,0 +1,57 @@
extension PureXML.Regex {
/// Unicode general categories as XSD 1.0 regular expressions see them
/// (Datatypes Appendix F). Category and block escapes (`\p{...}`) and the
/// multi-character escapes `\d`, `\w`, and friends are defined over the
/// Unicode database. The W3C conformance corpus (XSTS) is internally
/// consistent only at Unicode 3.2, so the repertoire is pinned there (see
/// ``assignedInUnicode32``): a code point unassigned in 3.2 reports `Cn` and
/// so matches `\D`/`\W`, even where the running platform now assigns it.
///
/// For code points whose 3.2-era category differs from the running platform's
/// (for example the ceiling and floor brackets, `Sm` in 3.2 but `Ps`/`Pe`
/// today), ``categoryOverrides`` pins the 3.2 classification; every other
/// assigned code point uses the platform category.
enum XSDCategory {
/// Code points whose XSD-era general category differs from the running
/// platform's. The value is the XSD-era two-letter category.
private static let categoryOverrides: [UInt32: String] = [
0x00A7: "So", 0x00B6: "So",
0x06DD: "Me",
0x0F14: "So",
0x166D: "Po", 0x17D7: "Po", 0x17DC: "Po",
0x180B: "Cf", 0x180C: "Cf", 0x180D: "Cf",
// Ethiopic digits ONE..NINE were Nd at Unicode 3.2 (each carried a
// decimal value); a later Unicode reclassified them to No. XSD `\d`
// is `\p{Nd}` over the pinned 3.2 repertoire, so they must match `\d`
// (and not `\D`). This is the complete set of code points that were
// Nd at 3.2 and are not Nd today (XSTS reS17/reS38/reT17/reT38/reZ004).
0x1369: "Nd", 0x136A: "Nd", 0x136B: "Nd", 0x136C: "Nd", 0x136D: "Nd",
0x136E: "Nd", 0x136F: "Nd", 0x1370: "Nd", 0x1371: "Nd",
0x2308: "Sm", 0x2309: "Sm", 0x230A: "Sm", 0x230B: "Sm",
]
/// The two-letter XSD category abbreviation for each Unicode general
/// category. A flat translation table, not branching logic.
private static let categoryCodes: [Unicode.GeneralCategory: String] = [
.uppercaseLetter: "Lu", .lowercaseLetter: "Ll", .titlecaseLetter: "Lt",
.modifierLetter: "Lm", .otherLetter: "Lo",
.nonspacingMark: "Mn", .spacingMark: "Mc", .enclosingMark: "Me",
.decimalNumber: "Nd", .letterNumber: "Nl", .otherNumber: "No",
.connectorPunctuation: "Pc", .dashPunctuation: "Pd", .openPunctuation: "Ps",
.closePunctuation: "Pe", .initialPunctuation: "Pi", .finalPunctuation: "Pf",
.otherPunctuation: "Po",
.mathSymbol: "Sm", .currencySymbol: "Sc", .modifierSymbol: "Sk", .otherSymbol: "So",
.spaceSeparator: "Zs", .lineSeparator: "Zl", .paragraphSeparator: "Zp",
.control: "Cc", .format: "Cf", .surrogate: "Cs", .privateUse: "Co",
.unassigned: "Cn",
]
static func generalCategoryCode(_ scalar: Unicode.Scalar) -> String {
if let override = categoryOverrides[scalar.value] {
return override
}
guard assignedInUnicode32(scalar.value) else { return "Cn" }
return categoryCodes[scalar.properties.generalCategory] ?? "Cn"
}
}
}

View File

@ -0,0 +1,147 @@
extension PureXML.Regex {
/// The XML 1.0 name-character classes that back the XSD regular-expression
/// escapes `\i` and `\c` (XSD 1.0 Datatypes Appendix F). XSD 1.0 defines
/// `\i` as the characters that may begin an XML Name (`Letter | '_' | ':'`)
/// and `\c` as the characters that may appear in one (`NameChar`), citing the
/// productions of XML 1.0 Second Edition. Those are the enumerated character
/// classes of XML 1.0 Appendix B (BaseChar, Ideographic, CombiningChar, Digit,
/// Extender), a fixed Unicode-2.0-era repertoire, not the broad ranges XML 1.0
/// Fifth Edition later substituted.
///
/// This is deliberately distinct from ``PureXML/Parsing/XMLCharacter``, which
/// implements the Fifth Edition Name production used to parse live XML: the two
/// definitions genuinely differ (Fifth Edition admits whole blocks where Second
/// Edition has gaps, e.g. the combining range is `[#x0300-#x0345]` here but the
/// entire `0x300...0x36F` block in the parser). Using the parser's check for
/// `\c` over-accepted, e.g. the combining mark U+0346 (XSTS reZ006i).
///
/// The ranges are transcribed clean-room from the XML 1.0 Appendix B grammar
/// (identical across editions one through four; only the fifth replaced it) and
/// stored sorted and non-overlapping so membership is a binary search. They are
/// verified to reproduce the W3C XSTS `\i`/`\c` enumerations (reZ005, reZ006)
/// exactly, in both directions, by ``XSDNameCharacterTests``.
enum XSDNameCharacter {
/// Whether `scalar` may begin an XML Name per XSD `\i` (`Letter | '_' | ':'`).
static func isNameStart(_ scalar: Unicode.Scalar) -> Bool {
contains(scalar.value, in: nameStartRanges)
}
/// Whether `scalar` may appear in an XML Name per XSD `\c` (`NameChar`).
static func isNameChar(_ scalar: Unicode.Scalar) -> Bool {
contains(scalar.value, in: nameCharRanges)
}
/// Binary search over a sorted, non-overlapping `[lo, hi, lo, hi, ...]`
/// table; each step compares one range's inclusive bounds.
private static func contains(_ value: UInt32, in ranges: [UInt32]) -> Bool {
var low = 0
var high = ranges.count / 2
while low < high {
let mid = (low + high) / 2
if value < ranges[mid * 2] {
high = mid
} else if value > ranges[mid * 2 + 1] {
low = mid + 1
} else {
return true
}
}
return false
}
}
}
private extension PureXML.Regex.XSDNameCharacter {
/// Inclusive `[lo, hi]` bounds of the XSD `\i` set: XML 1.0 Appendix B
/// `Letter` (`BaseChar | Ideographic`) plus `'_'` and `':'`.
static let nameStartRanges: [UInt32] = [
0x3A, 0x3A, 0x41, 0x5A, 0x5F, 0x5F, 0x61, 0x7A, 0xC0, 0xD6, 0xD8, 0xF6,
0xF8, 0x131, 0x134, 0x13E, 0x141, 0x148, 0x14A, 0x17E, 0x180, 0x1C3, 0x1CD, 0x1F0,
0x1F4, 0x1F5, 0x1FA, 0x217, 0x250, 0x2A8, 0x2BB, 0x2C1, 0x386, 0x386, 0x388, 0x38A,
0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3CE, 0x3D0, 0x3D6, 0x3DA, 0x3DA, 0x3DC, 0x3DC,
0x3DE, 0x3DE, 0x3E0, 0x3E0, 0x3E2, 0x3F3, 0x401, 0x40C, 0x40E, 0x44F, 0x451, 0x45C,
0x45E, 0x481, 0x490, 0x4C4, 0x4C7, 0x4C8, 0x4CB, 0x4CC, 0x4D0, 0x4EB, 0x4EE, 0x4F5,
0x4F8, 0x4F9, 0x531, 0x556, 0x559, 0x559, 0x561, 0x586, 0x5D0, 0x5EA, 0x5F0, 0x5F2,
0x621, 0x63A, 0x641, 0x64A, 0x671, 0x6B7, 0x6BA, 0x6BE, 0x6C0, 0x6CE, 0x6D0, 0x6D3,
0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x905, 0x939, 0x93D, 0x93D, 0x958, 0x961, 0x985, 0x98C,
0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9DC, 0x9DD,
0x9DF, 0x9E1, 0x9F0, 0x9F1, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30,
0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA72, 0xA74,
0xA85, 0xA8B, 0xA8D, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3,
0xAB5, 0xAB9, 0xABD, 0xABD, 0xAE0, 0xAE0, 0xB05, 0xB0C, 0xB0F, 0xB10, 0xB13, 0xB28,
0xB2A, 0xB30, 0xB32, 0xB33, 0xB36, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61,
0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E, 0xB9F,
0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB5, 0xBB7, 0xBB9, 0xC05, 0xC0C, 0xC0E, 0xC10,
0xC12, 0xC28, 0xC2A, 0xC33, 0xC35, 0xC39, 0xC60, 0xC61, 0xC85, 0xC8C, 0xC8E, 0xC90,
0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCDE, 0xCDE, 0xCE0, 0xCE1, 0xD05, 0xD0C,
0xD0E, 0xD10, 0xD12, 0xD28, 0xD2A, 0xD39, 0xD60, 0xD61, 0xE01, 0xE2E, 0xE30, 0xE30,
0xE32, 0xE33, 0xE40, 0xE45, 0xE81, 0xE82, 0xE84, 0xE84, 0xE87, 0xE88, 0xE8A, 0xE8A,
0xE8D, 0xE8D, 0xE94, 0xE97, 0xE99, 0xE9F, 0xEA1, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEA7,
0xEAA, 0xEAB, 0xEAD, 0xEAE, 0xEB0, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4,
0xF40, 0xF47, 0xF49, 0xF69, 0x10A0, 0x10C5, 0x10D0, 0x10F6, 0x1100, 0x1100, 0x1102, 0x1103,
0x1105, 0x1107, 0x1109, 0x1109, 0x110B, 0x110C, 0x110E, 0x1112, 0x113C, 0x113C, 0x113E, 0x113E,
0x1140, 0x1140, 0x114C, 0x114C, 0x114E, 0x114E, 0x1150, 0x1150, 0x1154, 0x1155, 0x1159, 0x1159,
0x115F, 0x1161, 0x1163, 0x1163, 0x1165, 0x1165, 0x1167, 0x1167, 0x1169, 0x1169, 0x116D, 0x116E,
0x1172, 0x1173, 0x1175, 0x1175, 0x119E, 0x119E, 0x11A8, 0x11A8, 0x11AB, 0x11AB, 0x11AE, 0x11AF,
0x11B7, 0x11B8, 0x11BA, 0x11BA, 0x11BC, 0x11C2, 0x11EB, 0x11EB, 0x11F0, 0x11F0, 0x11F9, 0x11F9,
0x1E00, 0x1E9B, 0x1EA0, 0x1EF9, 0x1F00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48, 0x1F4D,
0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB,
0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2126, 0x2126, 0x212A, 0x212B, 0x212E, 0x212E,
0x2180, 0x2182, 0x3007, 0x3007, 0x3021, 0x3029, 0x3041, 0x3094, 0x30A1, 0x30FA, 0x3105, 0x312C,
0x4E00, 0x9FA5, 0xAC00, 0xD7A3,
]
/// Inclusive `[lo, hi]` bounds of the XSD `\c` set: XML 1.0 Appendix B
/// `NameChar` (`Letter | Digit | '.' | '-' | '_' | ':' | CombiningChar | Extender`).
static let nameCharRanges: [UInt32] = [
0x2D, 0x2E, 0x30, 0x3A, 0x41, 0x5A, 0x5F, 0x5F, 0x61, 0x7A, 0xB7, 0xB7,
0xC0, 0xD6, 0xD8, 0xF6, 0xF8, 0x131, 0x134, 0x13E, 0x141, 0x148, 0x14A, 0x17E,
0x180, 0x1C3, 0x1CD, 0x1F0, 0x1F4, 0x1F5, 0x1FA, 0x217, 0x250, 0x2A8, 0x2BB, 0x2C1,
0x2D0, 0x2D1, 0x300, 0x345, 0x360, 0x361, 0x386, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1,
0x3A3, 0x3CE, 0x3D0, 0x3D6, 0x3DA, 0x3DA, 0x3DC, 0x3DC, 0x3DE, 0x3DE, 0x3E0, 0x3E0,
0x3E2, 0x3F3, 0x401, 0x40C, 0x40E, 0x44F, 0x451, 0x45C, 0x45E, 0x481, 0x483, 0x486,
0x490, 0x4C4, 0x4C7, 0x4C8, 0x4CB, 0x4CC, 0x4D0, 0x4EB, 0x4EE, 0x4F5, 0x4F8, 0x4F9,
0x531, 0x556, 0x559, 0x559, 0x561, 0x586, 0x591, 0x5A1, 0x5A3, 0x5B9, 0x5BB, 0x5BD,
0x5BF, 0x5BF, 0x5C1, 0x5C2, 0x5C4, 0x5C4, 0x5D0, 0x5EA, 0x5F0, 0x5F2, 0x621, 0x63A,
0x640, 0x652, 0x660, 0x669, 0x670, 0x6B7, 0x6BA, 0x6BE, 0x6C0, 0x6CE, 0x6D0, 0x6D3,
0x6D5, 0x6E8, 0x6EA, 0x6ED, 0x6F0, 0x6F9, 0x901, 0x903, 0x905, 0x939, 0x93C, 0x94D,
0x951, 0x954, 0x958, 0x963, 0x966, 0x96F, 0x981, 0x983, 0x985, 0x98C, 0x98F, 0x990,
0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9BC, 0x9BC, 0x9BE, 0x9C4,
0x9C7, 0x9C8, 0x9CB, 0x9CD, 0x9D7, 0x9D7, 0x9DC, 0x9DD, 0x9DF, 0x9E3, 0x9E6, 0x9F1,
0xA02, 0xA02, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33,
0xA35, 0xA36, 0xA38, 0xA39, 0xA3C, 0xA3C, 0xA3E, 0xA42, 0xA47, 0xA48, 0xA4B, 0xA4D,
0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA74, 0xA81, 0xA83, 0xA85, 0xA8B, 0xA8D, 0xA8D,
0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, 0xAB5, 0xAB9, 0xABC, 0xAC5,
0xAC7, 0xAC9, 0xACB, 0xACD, 0xAE0, 0xAE0, 0xAE6, 0xAEF, 0xB01, 0xB03, 0xB05, 0xB0C,
0xB0F, 0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB36, 0xB39, 0xB3C, 0xB43,
0xB47, 0xB48, 0xB4B, 0xB4D, 0xB56, 0xB57, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66, 0xB6F,
0xB82, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C,
0xB9E, 0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB5, 0xBB7, 0xBB9, 0xBBE, 0xBC2,
0xBC6, 0xBC8, 0xBCA, 0xBCD, 0xBD7, 0xBD7, 0xBE7, 0xBEF, 0xC01, 0xC03, 0xC05, 0xC0C,
0xC0E, 0xC10, 0xC12, 0xC28, 0xC2A, 0xC33, 0xC35, 0xC39, 0xC3E, 0xC44, 0xC46, 0xC48,
0xC4A, 0xC4D, 0xC55, 0xC56, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC82, 0xC83, 0xC85, 0xC8C,
0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBE, 0xCC4, 0xCC6, 0xCC8,
0xCCA, 0xCCD, 0xCD5, 0xCD6, 0xCDE, 0xCDE, 0xCE0, 0xCE1, 0xCE6, 0xCEF, 0xD02, 0xD03,
0xD05, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD28, 0xD2A, 0xD39, 0xD3E, 0xD43, 0xD46, 0xD48,
0xD4A, 0xD4D, 0xD57, 0xD57, 0xD60, 0xD61, 0xD66, 0xD6F, 0xE01, 0xE2E, 0xE30, 0xE3A,
0xE40, 0xE4E, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE87, 0xE88, 0xE8A, 0xE8A,
0xE8D, 0xE8D, 0xE94, 0xE97, 0xE99, 0xE9F, 0xEA1, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEA7,
0xEAA, 0xEAB, 0xEAD, 0xEAE, 0xEB0, 0xEB9, 0xEBB, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6,
0xEC8, 0xECD, 0xED0, 0xED9, 0xF18, 0xF19, 0xF20, 0xF29, 0xF35, 0xF35, 0xF37, 0xF37,
0xF39, 0xF39, 0xF3E, 0xF47, 0xF49, 0xF69, 0xF71, 0xF84, 0xF86, 0xF8B, 0xF90, 0xF95,
0xF97, 0xF97, 0xF99, 0xFAD, 0xFB1, 0xFB7, 0xFB9, 0xFB9, 0x10A0, 0x10C5, 0x10D0, 0x10F6,
0x1100, 0x1100, 0x1102, 0x1103, 0x1105, 0x1107, 0x1109, 0x1109, 0x110B, 0x110C, 0x110E, 0x1112,
0x113C, 0x113C, 0x113E, 0x113E, 0x1140, 0x1140, 0x114C, 0x114C, 0x114E, 0x114E, 0x1150, 0x1150,
0x1154, 0x1155, 0x1159, 0x1159, 0x115F, 0x1161, 0x1163, 0x1163, 0x1165, 0x1165, 0x1167, 0x1167,
0x1169, 0x1169, 0x116D, 0x116E, 0x1172, 0x1173, 0x1175, 0x1175, 0x119E, 0x119E, 0x11A8, 0x11A8,
0x11AB, 0x11AB, 0x11AE, 0x11AF, 0x11B7, 0x11B8, 0x11BA, 0x11BA, 0x11BC, 0x11C2, 0x11EB, 0x11EB,
0x11F0, 0x11F0, 0x11F9, 0x11F9, 0x1E00, 0x1E9B, 0x1EA0, 0x1EF9, 0x1F00, 0x1F15, 0x1F18, 0x1F1D,
0x1F20, 0x1F45, 0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D,
0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC,
0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x20D0, 0x20DC,
0x20E1, 0x20E1, 0x2126, 0x2126, 0x212A, 0x212B, 0x212E, 0x212E, 0x2180, 0x2182, 0x3005, 0x3005,
0x3007, 0x3007, 0x3021, 0x302F, 0x3031, 0x3035, 0x3041, 0x3094, 0x3099, 0x309A, 0x309D, 0x309E,
0x30A1, 0x30FA, 0x30FC, 0x30FE, 0x3105, 0x312C, 0x4E00, 0x9FA5, 0xAC00, 0xD7A3,
]
}

View File

@ -92,15 +92,30 @@ public extension PureXML.Schema {
func derives(from other: BuiltinType) -> Bool {
var current: BuiltinType? = self
while let type = current {
if type == other { return true }
if type == other {
return true
}
current = type.derivationBase
}
return false
}
/// Whether this type's value space is the `decimal` value space: `decimal`
/// itself or any of the integer family (which derive from it). Values from
/// two such types compare in the one shared value space, so `1` (decimal)
/// equals `1` (unsignedByte). `float` and `double` are distinct primitive
/// value spaces and are deliberately excluded.
var isDecimalDerived: Bool {
self == .decimal || Self.integerLike.contains(self)
}
var primitive: Primitive {
if let primitive = Self.stringLike[self] { return primitive }
if Self.integerLike.contains(self) { return .integer }
if let primitive = Self.stringLike[self] {
return primitive
}
if Self.integerLike.contains(self) {
return .integer
}
return computedPrimitive
}

View File

@ -9,16 +9,25 @@ public extension PureXML.Schema {
case all
}
/// The namespace constraint of a wildcard (`xs:any`/`xs:anyAttribute`).
/// The namespace constraint of a wildcard (`xs:any`/`xs:anyAttribute`). These
/// are exactly the XSD 1.0 (with Errata E1-10) expressible {namespace
/// constraint} forms: `any`; a set of namespace names possibly including
/// absent; a pair of `not` and a namespace name; a pair of `not` and absent.
/// Both `not` forms exclude absent (E1-10 "Wildcard allows Namespace Name"), so
/// there is NO "everything except N but including absent" form -- a derivation
/// whose attribute-wildcard union/intersection would need one is `not
/// expressible` (`src-ct.4`/`src-ct.5`), checked in ``SchemaWildcardExpressibility``.
enum WildcardNamespace: Sendable, Equatable {
/// `##any`: a name in any namespace, or none.
case any
/// `##other`: a name in any namespace other than the target, and not in no
/// namespace.
case other
/// A specific set of namespace URIs; the empty string stands for no
/// namespace (`##local`).
case enumerated(Set<String>)
/// A pair of `not` and a namespace name: any namespace name other than the
/// given one, and NOT absent. `##other` is `notNamespace(targetNamespace)`.
case notNamespace(String)
/// A pair of `not` and absent: any (non-absent) namespace name.
case notAbsent
}
/// How a wildcard-matched item is validated: `skip` (no validation), `lax`
@ -30,6 +39,16 @@ public extension PureXML.Schema {
case strict
}
/// The outcome of combining two wildcard constraints: a constraint (possibly
/// `nil`, meaning no wildcard), or `notExpressible` when the XSD 1.0 (Errata
/// E1-10) union/intersection cannot be written as one of the four
/// `WildcardNamespace` forms -- a `src-ct.4` (intersection) or `src-ct.5`
/// (union) Complex Type Definition Representation error.
enum WildcardCombination: Sendable, Equatable {
case constraint(Wildcard?)
case notExpressible
}
/// An element or attribute wildcard (`xs:any`/`xs:anyAttribute`): which
/// namespaces it admits, and how matched items are validated.
struct Wildcard: Sendable, Equatable {
@ -54,12 +73,142 @@ public extension PureXML.Schema {
switch namespace {
case .any:
return true
case .other:
return !namespaceURI.isEmpty && namespaceURI != (targetNamespace ?? "")
case let .enumerated(uris):
return uris.contains(namespaceURI)
case let .notNamespace(excluded):
return !namespaceURI.isEmpty && namespaceURI != excluded
case .notAbsent:
return !namespaceURI.isEmpty
}
}
/// The union of two wildcard constraints (XSD 1.0 `cos-aw-union` + Errata
/// E1-10), used when a complexContent extension adds its own `anyAttribute`.
func union(with other: Wildcard) -> WildcardCombination {
guard let namespace = Self.unionNamespace(namespace, other.namespace) else { return .notExpressible }
return .constraint(Wildcard(
namespace: namespace,
processContents: Self.unionProcessContents(processContents, other.processContents),
targetNamespace: targetNamespace ?? other.targetNamespace,
))
}
/// Combines optional wildcards from a base type and a derivation by union.
static func union(_ left: Wildcard?, _ right: Wildcard?) -> WildcardCombination {
switch (left, right) {
case (nil, nil): .constraint(nil)
case (let left?, nil): .constraint(left)
case (nil, let right?): .constraint(right)
case let (left?, right?): left.union(with: right)
}
}
/// The union of two namespace constraints (XSD 1.0 §3.10.6 with Errata
/// E1-10), or `nil` when the result is not expressible. Both `not` forms
/// exclude absent, so a union that would admit "everything except a name,
/// plus absent" (rule 5.1.3) has no expressible form.
static func unionNamespace(_ left: WildcardNamespace, _ right: WildcardNamespace) -> WildcardNamespace? {
switch (left, right) {
case (.any, _), (_, .any):
return .any
case let (.enumerated(lhs), .enumerated(rhs)):
return .enumerated(lhs.union(rhs))
case let (.notNamespace(lhsName), .notNamespace(rhsName)):
// not(x) admits the other's excluded name, so different names union
// to "any named, no absent"; the same name is unchanged.
return lhsName == rhsName ? .notNamespace(lhsName) : .notAbsent
case (.notAbsent, .notNamespace), (.notNamespace, .notAbsent), (.notAbsent, .notAbsent):
return .notAbsent
case let (.notNamespace(name), .enumerated(set)), let (.enumerated(set), .notNamespace(name)):
let hasName = set.contains(name), hasAbsent = set.contains("")
if hasName, hasAbsent {
return .any
} // E1-10 5.1.1
if hasName {
return .notAbsent
} // E1-10 5.1.2
if hasAbsent {
return nil
} // NOT EXPRESSIBLE, E1-10 5.1.3
return .notNamespace(name) // E1-10 5.1.4
case let (.notAbsent, .enumerated(set)), let (.enumerated(set), .notAbsent):
return set.contains("") ? .any : .notAbsent
}
}
private static func unionProcessContents(_ left: ProcessContents, _ right: ProcessContents) -> ProcessContents {
if left == .strict || right == .strict {
return .strict
}
if left == .lax || right == .lax {
return .lax
}
return .skip
}
/// The intersection of two wildcard constraints (XSD 1.0 `cos-aw-intersect`
/// + Errata E1-10), used to combine the `anyAttribute` wildcards a complex
/// type or attribute group draws from its own declaration and its referenced
/// attribute groups: the effective wildcard admits only what every source
/// admits.
func intersection(with other: Wildcard) -> WildcardCombination {
.constraint(Wildcard(
namespace: Self.intersectNamespace(namespace, other.namespace),
processContents: Self.intersectProcessContents(processContents, other.processContents),
targetNamespace: targetNamespace ?? other.targetNamespace,
))
}
/// Combines optional wildcards from several sources by intersection. A nil
/// source contributes no constraint (the unconstrained `##any` identity).
static func intersection(_ left: Wildcard?, _ right: Wildcard?) -> WildcardCombination {
switch (left, right) {
case (nil, nil): .constraint(nil)
case (let left?, nil): .constraint(left)
case (nil, let right?): .constraint(right)
case let (left?, right?): left.intersection(with: right)
}
}
/// The intersection of two namespace constraints (XSD 1.0 §3.10.6 with
/// Errata E1-10). Two `not` forms negating DIFFERENT namespace names have a
/// true intersection of `not({a,b})` excluding absent, which has no single
/// expressible form (`src-ct.4`). Detecting that as a schema error is a
/// deferred rule; until then this returns the less restrictive `not(lhs)` (an
/// over-approximation, so an attribute is never WRONGLY rejected -- the prior
/// behaviour), rather than dropping the wildcard and over-rejecting valid
/// cross-namespace attributes on an accepted schema.
static func intersectNamespace(_ left: WildcardNamespace, _ right: WildcardNamespace) -> WildcardNamespace {
switch (left, right) {
case (.any, _): right
case (_, .any): left
case let (.enumerated(lhs), .enumerated(rhs)):
.enumerated(lhs.intersection(rhs))
case let (.enumerated(set), .notNamespace(name)), let (.notNamespace(name), .enumerated(set)):
.enumerated(set.subtracting([name, ""])) // not(name) also excludes absent
case let (.enumerated(set), .notAbsent), let (.notAbsent, .enumerated(set)):
.enumerated(set.subtracting([""]))
case let (.notNamespace(lhsName), .notNamespace(_)):
.notNamespace(lhsName) // same name: unchanged; different: lenient over-approximation
case (.notAbsent, .notAbsent):
.notAbsent
case let (.notNamespace(name), .notAbsent), let (.notAbsent, .notNamespace(name)):
.notNamespace(name)
}
}
private static func intersectProcessContents(_ left: ProcessContents, _ right: ProcessContents) -> ProcessContents {
// When the two differ the intersection is strictly not expressible; keep
// the less strict so a matched attribute is never validated more harshly
// than either source asked for (under-validation, not over-rejection).
if left == right {
return left
}
if left == .skip || right == .skip {
return .skip
}
return .lax
}
}
/// The term of a particle: an element declaration, a nested model group, or a
@ -69,7 +218,17 @@ public extension PureXML.Schema {
/// (a built-in, a user type, or `anyType` for an absent type), or nil when
/// the type is an inline anonymous definition. It preserves the derivation
/// identity the flattened `type` (`ElementType`) loses, for NameAndTypeOK.
case element(name: PureXML.Model.QualifiedName, type: ElementType?, typeName: String?)
/// `block` is the element's `{disallowed substitutions}` (its own `block`, or
/// the schema's `blockDefault`), needed for the NameAndTypeOK block-superset
/// rule in Particle Valid (Restriction); empty when nothing is blocked.
case element(
name: PureXML.Model.QualifiedName,
type: ElementType?,
typeName: String?,
valueConstraint: ValueConstraint? = nil,
block: Set<DerivationMethod> = [],
nillable: Bool = false,
)
case group(Group)
case wildcard(Wildcard)
}
@ -77,14 +236,35 @@ public extension PureXML.Schema {
/// A particle: a term with an occurrence range. `maxOccurs` nil means
/// unbounded.
struct Particle: Sendable {
public var minOccurs: Int
public var maxOccurs: Int?
public var occurrenceRange: OccurrenceRange
public var term: Term
/// The declared global-element reference this particle was compiled from, when it
/// came from `<xs:element ref="..."/>`. Substitution-group expansion rewrites the
/// term (an abstract head is dropped, members join a synthetic choice), but XSD 1.0
/// section 3.9.6 defines particle restriction over the DECLARED particles, so the
/// restriction check needs the pre-expansion identity the term no longer carries.
public var declaredReference: PureXML.Model.QualifiedName?
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term) {
self.minOccurs = minOccurs
self.maxOccurs = maxOccurs
public var minOccurs: Int {
get { occurrenceRange.minimum.clamped(to: Int.max) }
set { occurrenceRange.minimum = NonNegativeDecimal(newValue) }
}
public var maxOccurs: Int? {
get { occurrenceRange.maximum.clamped(to: Int.max) }
set { occurrenceRange.maximum = OccurrenceUpper(newValue) }
}
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term, declaredReference: PureXML.Model.QualifiedName? = nil) {
occurrenceRange = OccurrenceRange(minimum: minOccurs, maximum: maxOccurs)
self.term = term
self.declaredReference = declaredReference
}
public init(occurrenceRange: OccurrenceRange, term: Term, declaredReference: PureXML.Model.QualifiedName? = nil) {
self.occurrenceRange = occurrenceRange
self.term = term
self.declaredReference = declaredReference
}
}
@ -92,10 +272,20 @@ public extension PureXML.Schema {
struct Group: Sendable {
public var compositor: Compositor
public var particles: [Particle]
/// A stable identity shared by every reference that expands to this same
/// `<xs:group>` definition (in the same scope), assigned when the expansion
/// is first compiled. It lets a content-model compiler emit a multiply
/// referenced group once instead of inlining it per reference (the `2^K`
/// build the design doc closes with a graph-structured-stack matcher). It
/// is metadata only: `Group` is not `Equatable`/`Hashable`, so carrying it
/// changes no comparison, and it is `nil` for an inline (non-referenced)
/// group.
public var definitionID: Int?
public init(compositor: Compositor, particles: [Particle]) {
public init(compositor: Compositor, particles: [Particle], definitionID: Int? = nil) {
self.compositor = compositor
self.particles = particles
self.definitionID = definitionID
}
}
@ -116,7 +306,9 @@ public extension PureXML.Schema {
/// The fixed value, when this is a `fixed` constraint.
public var fixedValue: String? {
if case let .fixed(value) = self { return value }
if case let .fixed(value) = self {
return value
}
return nil
}
@ -135,17 +327,28 @@ public extension PureXML.Schema {
public var type: SimpleType
public var required: Bool
public var valueConstraint: ValueConstraint?
/// When true, an unprefixed instance attribute may match this use if the
/// element is in the same namespace (chameleon include only).
public var chameleonUnprefixed: Bool
/// `use="prohibited"`: the attribute is excluded from the type's effective
/// {attribute uses}, so it must not appear on an instance. The use is kept
/// (rather than dropped) so schema-validity checks still see the declaration.
public var prohibited: Bool
public init(
name: PureXML.Model.QualifiedName,
type: SimpleType,
required: Bool = false,
valueConstraint: ValueConstraint? = nil,
chameleonUnprefixed: Bool = false,
prohibited: Bool = false,
) {
self.name = name
self.type = type
self.required = required
self.valueConstraint = valueConstraint
self.chameleonUnprefixed = chameleonUnprefixed
self.prohibited = prohibited
}
}

View File

@ -2,6 +2,11 @@
/// namespace to avoid nesting a type two levels deep.
typealias XSDFailure = PureXML.Validation.ValidationError
typealias XSDPath = [PureXML.Validation.PathKey]
private struct XSDChildValidationFrame {
let path: XSDPath
let namespaceBindings: [String: String]
}
private typealias XSDGroup = PureXML.Schema.Group
private typealias XSDTerm = PureXML.Schema.Term
private typealias XSDTermLabel = PureXML.Schema.TermLabel
@ -18,18 +23,28 @@ public extension PureXML.Schema {
/// The named type table that `ElementType.typeReference` resolves against.
let types: [String: ElementType]
/// Local names of elements declared `nillable="true"`.
private let nillableElements: Set<String>
let nillableElements: Set<String>
/// The `default`/`fixed` value constraint declared on each element name.
private let elementConstraints: [String: ValueConstraint]
/// Local names of complex types declared `abstract="true"`.
let elementConstraints: [String: ValueConstraint]
/// Complex types declared `abstract="true"`, keyed by namespaced identity
/// (`{ns}local`) so same-local-name types in different namespaces stay distinct.
let abstractTypes: Set<String>
/// Derivation methods each named type forbids through `xsi:type`.
/// Derivation methods each named type forbids through `xsi:type`, keyed by
/// namespaced identity (`{ns}local`).
let typeBlock: [String: Set<DerivationMethod>]
/// Derivation methods each element declaration forbids through `xsi:type`
/// (the `block` on the element, distinct from the block on its type).
/// (the `block` on the element, distinct from the block on its type), keyed by
/// namespaced identity (`{ns}local`).
let elementBlock: [String: Set<DerivationMethod>]
/// Each named complex type's base and derivation method.
/// Each named complex type's base and derivation method, keyed by namespaced
/// identity (`{ns}local`).
let typeDerivation: [String: TypeDerivation]
/// Each named union type's member type keys (`{ns}local`), so the
/// `xsi:type`-`block` check can follow a derivation that reaches a declared
/// union through one of its members (cos-st-derived-OK 2.2.4).
let unionMembers: [String: [String]]
/// Global attribute declarations for strict/lax `anyAttribute` validation.
let globalAttributes: [String: AttributeUse]
/// Document-scoped xs:ID/xs:IDREF accumulator, filled during the typed walk
/// and reported by `idErrors()` once the whole tree has been seen. See the
/// ID/IDREF extension for `idErrors()` and `recordIDs(_:value:at:)`.
@ -37,20 +52,24 @@ public extension PureXML.Schema {
public init(
types: [String: ElementType] = [:],
globalAttributes: [String: AttributeUse] = [:],
nillableElements: Set<String> = [],
elementConstraints: [String: ValueConstraint] = [:],
abstractTypes: Set<String> = [],
typeBlock: [String: Set<DerivationMethod>] = [:],
elementBlock: [String: Set<DerivationMethod>] = [:],
typeDerivation: [String: TypeDerivation] = [:],
unionMembers: [String: [String]] = [:],
) {
self.types = types
self.globalAttributes = globalAttributes
self.nillableElements = nillableElements
self.elementConstraints = elementConstraints
self.abstractTypes = abstractTypes
self.typeBlock = typeBlock
self.elementBlock = elementBlock
self.typeDerivation = typeDerivation
self.unionMembers = unionMembers
}
/// Validates `element` against `type` at `path`, one error per violation.
@ -58,44 +77,41 @@ public extension PureXML.Schema {
_ element: PureXML.Model.Element,
against type: ComplexType,
at path: [PureXML.Validation.PathKey] = [],
namespaceBindings inheritedBindings: [String: String] = [:],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateTagged(element, against: type, at: path, namespaceBindings: inheritedBindings).map(\.error)
}
/// The tagged form of ``validate(_:against:at:namespaceBindings:)``: the same
/// computation, with each finding labeled by the decomposed rule it belongs
/// to. The public, untagged form is a thin `.map(\.error)` over this so
/// behavior and output stay identical for every existing caller.
func validateTagged(
_ element: PureXML.Model.Element,
against type: ComplexType,
at path: [PureXML.Validation.PathKey] = [],
namespaceBindings inheritedBindings: [String: String] = [:],
) -> [XSDFinding] {
let namespaceBindings = Self.namespaceBindings(for: element, inherited: inheritedBindings)
var errors: [XSDFinding] = []
validateAttributes(element, type, at: path, into: &errors)
// An xsi:nil element keeps its attribute obligations but must have no
// content; its content model is not otherwise checked.
if let nilErrors = nilErrors(element, at: path) {
return errors + nilErrors
}
validateContent(element, type.content, at: path, into: &errors)
errors += elementFixedErrors(element, at: path)
validateContent(element, type.content, at: path, namespaceBindings: namespaceBindings, into: &errors)
// A simpleContent fixed value is compared in its simple type's value space
// (cvc-elt.5.2.2.1), e.g. "05" equals an xs:int fixed "5".
let simpleContentType: SimpleType? = if case let .simpleContent(simple) = type.content {
simple
} else {
nil
}
errors += elementFixedErrors(element, valueType: simpleContentType, at: path)
return errors
}
/// The errors from an `xsi:nil="true"` element: rejecting it when the
/// element is not nillable, or when it carries content. Returns nil when
/// the element is not nilled.
func nilErrors(_ element: PureXML.Model.Element, at path: XSDPath) -> [XSDFailure]? {
guard Self.isNil(element) else { return nil }
let name = element.name.localName
if !nillableElements.contains(name) {
return [XSDFailure(reason: "element '\(name)' is not nillable but has xsi:nil", at: path)]
}
if Self.hasContent(element) {
return [XSDFailure(reason: "nil element '\(name)' must be empty", at: path)]
}
return []
}
/// The error from a `fixed` element value constraint: the element's text
/// must equal the fixed value.
func elementFixedErrors(_ element: PureXML.Model.Element, at path: XSDPath) -> [XSDFailure] {
guard let fixed = elementConstraints[element.name.localName]?.fixedValue else { return [] }
let text = Self.textContent(element)
// An empty element takes the fixed value; only present content must equal it.
if text.isEmpty { return [] }
return text == fixed ? [] : [XSDFailure(reason: "element '\(element.name.localName)' is fixed and must be '\(fixed)'", at: path)]
}
/// Validates `element` against any element type at `path`, resolving a
/// `typeReference` through the type table.
public func validate(
@ -103,40 +119,19 @@ public extension PureXML.Schema {
as type: ElementType,
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateChild(element, against: type, at: path, into: &errors)
return errors
validateTagged(element, as: type, at: path).map(\.error)
}
// MARK: Attributes
func validateAttributes(
/// The tagged form of ``validate(_:as:at:)``. See ``validateTagged(_:against:at:namespaceBindings:)``.
func validateTagged(
_ element: PureXML.Model.Element,
_ type: ComplexType,
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) }
for use in type.attributes {
let match = present.first { Self.sameName($0.name, use.name) }
if let match {
if let error = use.type.validate(match.value) {
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)': \(error)", at: path))
}
if let fixed = use.valueConstraint?.fixedValue, match.value != fixed {
errors.append(XSDFailure(reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
}
recordIDs(use.type, value: match.value, at: path)
} else if use.required {
errors.append(XSDFailure(reason: "missing required attribute '\(use.name.localName)'", at: path))
}
}
// An undeclared attribute is allowed only if an xs:anyAttribute wildcard
// admits its namespace.
for attribute in present where !type.attributes.contains(where: { Self.sameName($0.name, attribute.name) }) {
if type.attributeWildcard?.admits(attribute.name) == true { continue }
errors.append(XSDFailure(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
}
as type: ElementType,
at path: [PureXML.Validation.PathKey] = [],
) -> [XSDFinding] {
var errors: [XSDFinding] = []
let namespaceBindings = Self.namespaceBindings(for: element)
validateChild(element, against: type, at: path, namespaceBindings: namespaceBindings, into: &errors)
return errors
}
// MARK: Content
@ -145,29 +140,34 @@ public extension PureXML.Schema {
_ element: PureXML.Model.Element,
_ content: ContentType,
at path: XSDPath,
into errors: inout [XSDFailure],
namespaceBindings: [String: String],
into errors: inout [XSDFinding],
) {
let children = element.children.compactMap(\.element)
switch content {
case .empty:
if !children.isEmpty { errors.append(XSDFailure(reason: "element must be empty", at: path)) }
if !children.isEmpty {
errors.append(XSDFinding(.emptyContentForbidsChildren, reason: "element must be empty", at: path))
}
rejectText(element, at: path, into: &errors)
case let .simpleContent(type):
if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) }
if !children.isEmpty {
errors.append(XSDFinding(.simpleContentForbidsChildren, reason: "element must not have children", at: path))
}
if let error = type.validate(Self.rawTextContent(element)) {
errors.append(XSDFailure(reason: "content: \(error)", at: path))
errors.append(XSDFinding(.simpleContentTypeValid, reason: "content: \(error)", at: path))
}
case let .elementOnly(particle):
rejectText(element, at: path, into: &errors)
validateParticle(particle, children: children, at: path, into: &errors)
validateParticle(particle, children: children, at: path, namespaceBindings: namespaceBindings, into: &errors)
case let .mixed(particle):
validateParticle(particle, children: children, at: path, into: &errors)
validateParticle(particle, children: children, at: path, namespaceBindings: namespaceBindings, into: &errors)
}
}
func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFailure]) {
func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFinding]) {
guard !Self.textContent(element).isEmpty else { return }
errors.append(XSDFailure(reason: "element must not contain text", at: path))
errors.append(XSDFinding(.textForbidden, reason: "element must not contain text", at: path))
}
// MARK: Particles
@ -176,89 +176,75 @@ public extension PureXML.Schema {
_ particle: Particle,
children: [PureXML.Model.Element],
at path: XSDPath,
into errors: inout [XSDFailure],
namespaceBindings: [String: String],
into errors: inout [XSDFinding],
) {
// Locate each content-model violation at the offending child (or the
// missing one), with a recovery hint, rather than one opaque failure,
// then still validate every well-placed child's own content.
// then assess every well-placed child against the exact particle the
// content model matched it to (#180), not a by-name lookup that loses
// which of two same-named particles, or a wildcard versus a named
// particle, actually matched.
let matched: [PureXML.Schema.MatchedParticle?]
if case let .group(group) = particle.term, group.compositor == .all {
allStructureErrors(group, children: children, at: path, into: &errors)
allStructureErrors(group, children: children, groupOptional: particle.occurrenceRange.minimum.isZero, at: path, into: &errors)
matched = allMatchedParticles(group, children: children)
} else {
sequenceStructureErrors(particle, children: children, at: path, into: &errors)
matched = PureXML.Schema.ContentRTNMatcher(PureXML.Schema.ContentRTNBuilder.build(particle)).matchedParticles(children.map(\.name))
}
validateChildren(
children,
childTypes: Self.elementTypes(in: particle.term),
wildcard: Self.wildcard(in: particle.term),
at: path,
matched: matched,
frame: XSDChildValidationFrame(path: path, namespaceBindings: namespaceBindings),
into: &errors,
)
}
private func validateChildren(
_ children: [PureXML.Model.Element],
childTypes: [String: ElementType],
wildcard: ProcessContents?,
at path: XSDPath,
into errors: inout [XSDFailure],
matched: [PureXML.Schema.MatchedParticle?],
frame: XSDChildValidationFrame,
into errors: inout [XSDFinding],
) {
let steps = Self.childSteps(children)
for (child, step) in zip(children, steps) {
if let declared = childTypes[Self.key(child.name)] {
validateChild(child, against: declared, at: path + [step], into: &errors)
} else if let wildcard {
// The structure already matched, so an undeclared child here was
// admitted by a wildcard; process its content per the wildcard.
validateWildcardChild(child, processContents: wildcard, at: path + [step], into: &errors)
for (index, child) in children.enumerated() {
let namespaceBindings = Self.namespaceBindings(for: child, inherited: frame.namespaceBindings)
let path = frame.path + [steps[index]]
switch matched[index] {
case let .element(type, valueConstraint):
// An element particle without an inlined type (a bare ref)
// resolves through its global declaration, as before.
guard let declared = type ?? globalElementDeclaration(for: child.name) else { continue }
validateChild(child, against: declared, at: path, namespaceBindings: namespaceBindings, particleConstraint: valueConstraint, into: &errors)
case let .wildcard(wildcard):
validateWildcardChild(child, processContents: wildcard.processContents, at: path, namespaceBindings: namespaceBindings, into: &errors)
case .none:
// The content model rejected this child (a structure error,
// already located above); the matched particle is undefined.
continue
}
}
}
/// Validates a wildcard-matched child by its `processContents`: skip does
/// nothing, lax validates against a global declaration when one exists, and
/// strict requires one.
private func validateWildcardChild(
_ child: PureXML.Model.Element,
processContents: ProcessContents,
at path: XSDPath,
into errors: inout [XSDFailure],
) {
switch processContents {
case .skip:
return
case .lax:
if let declaration = types["element:\(child.name.localName)"] {
validateChild(child, against: declaration, at: path, into: &errors)
}
case .strict:
guard let declaration = types["element:\(child.name.localName)"] else {
errors.append(XSDFailure(reason: "no declaration for wildcard-matched element '\(child.name.localName)'", at: path))
return
}
validateChild(child, against: declaration, at: path, into: &errors)
}
}
private func validateChild(
func validateChild(
_ child: PureXML.Model.Element,
against declared: ElementType,
at path: XSDPath,
into errors: inout [XSDFailure],
namespaceBindings: [String: String],
particleConstraint: ValueConstraint? = nil,
into errors: inout [XSDFinding],
) {
// An instance `xsi:type` overrides the declared type, provided the named
// type exists in the schema. Resolve to the type itself rather than a
// reference so re-entry does not re-read the same xsi:type. A `block` on
// the declared type can forbid the substitution; an abstract declared
// type requires one.
if let blocked = blockedSubstitutionError(declared: declared, child: child, at: path) {
errors.append(blocked)
if let xsiError = xsiTypeOverrideError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) {
errors.append(XSDFinding(.xsiTypeSubstitutionValid, xsiError))
return
}
if case let .typeReference(name) = declared, let missing = abstractTypeError(named: name, child: child, at: path) {
errors.append(missing)
return
}
guard let declared = overriddenType(declared, for: child, at: path, into: &errors) else { return }
guard let declared = overriddenType(declared, for: child, namespaceBindings: namespaceBindings, at: path, into: &errors) else { return }
switch declared {
case let .simple(simple):
// A simple type declares no attribute uses, so a simple-typed
@ -268,29 +254,32 @@ public extension PureXML.Schema {
// violation. Checked before the nil short-circuit, since an
// xsi:nil element is equally bound by it.
for attribute in child.attributes where !Self.isNamespaceDeclaration(attribute) && !Self.isSchemaInstanceAttribute(attribute) {
errors.append(XSDFailure(reason: "'\(child.name.localName)' has a simple type and must not carry attribute '\(attribute.name.localName)'", at: path))
errors.append(XSDFinding(
.simpleTypeDisallowedAttribute,
reason: "'\(child.name.localName)' has a simple type and must not carry attribute '\(attribute.name.localName)'",
at: path,
))
}
if let nilErrors = nilErrors(child, at: path) {
if let nilErrors = nilErrors(child, fixedValue: particleConstraint?.fixedValue, at: path) {
errors += nilErrors
return
}
if !child.children.compactMap(\.element).isEmpty {
errors.append(XSDFailure(reason: "'\(child.name.localName)' must not have children", at: path))
errors.append(XSDFinding(.simpleTypeForbidsChildren, reason: "'\(child.name.localName)' must not have children", at: path))
}
// An empty element takes its `default`/`fixed` value; that value
// (not the empty string) is what must be valid against the type,
// including any xsi:type override already resolved into `simple`.
let text = Self.rawTextContent(child)
let effective = text.isEmpty ? (elementConstraints[child.name.localName]?.value ?? text) : text
if let error = simple.validate(effective) {
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
if let error = simple.validate(effectiveSimpleText(for: child, particleConstraint: particleConstraint)) {
errors.append(XSDFinding(.simpleTypeValueValid, reason: "'\(child.name.localName)': \(error)", at: path))
}
recordIDs(simple, value: text, at: path)
errors += elementFixedErrors(child, at: path)
errors += elementFixedErrors(child, valueType: simple, particleFixed: particleConstraint?.fixedValue, at: path)
case let .complex(complex):
errors.append(contentsOf: validate(child, against: complex, at: path))
errors.append(contentsOf: validateTagged(child, against: complex, at: path, namespaceBindings: namespaceBindings))
case .typeReference:
validateResolvedReference(declared, child, at: path, into: &errors)
validateResolvedReference(declared, child, at: path, namespaceBindings: namespaceBindings, into: &errors)
}
}
@ -300,83 +289,17 @@ public extension PureXML.Schema {
_ declared: ElementType,
_ child: PureXML.Model.Element,
at path: XSDPath,
into errors: inout [XSDFailure],
namespaceBindings: [String: String],
into errors: inout [XSDFinding],
) {
switch resolveReference(declared) {
case let .unknown(name):
errors.append(XSDFailure(reason: "unknown type '\(name)'", at: path))
errors.append(XSDFinding(.typeReferenceResolves, reason: "unknown type '\(name)'", at: path))
case let .circular(name):
errors.append(XSDFailure(reason: "circular type reference '\(name)'", at: path))
errors.append(XSDFinding(.typeReferenceResolves, reason: "circular type reference '\(name)'", at: path))
case let .resolved(resolved):
validateChild(child, against: resolved, at: path, into: &errors)
validateChild(child, against: resolved, at: path, namespaceBindings: namespaceBindings, into: &errors)
}
}
}
}
/// Located content-model diagnostics: pinpoint which child breaks the model and
/// what was expected there, so an editor shows placed errors with recovery hints
/// rather than one opaque "content does not match" per element.
extension PureXML.Schema.ComplexValidator {
/// Walks the children through the content automaton, flagging the first child
/// the follow-set rejects, or the missing content when the sequence ends early.
func sequenceStructureErrors(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) {
let nfa = PureXML.Schema.ContentNFABuilder.build(particle)
let steps = Self.childSteps(children)
// Advance one active state-set across the children rather than re-walking
// the prefix per child (which is quadratic over the content model, #129).
var current = nfa.startStates()
for (index, child) in children.enumerated() {
guard let next = nfa.step(current, over: child.name) else {
let allowed = nfa.admissible(from: current)
errors.append(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here\(Self.expectation(allowed))", at: path + [steps[index]]))
return
}
current = next
}
if !nfa.isComplete(current) {
errors.append(XSDFailure(reason: "content is incomplete\(Self.expectation(nfa.admissible(from: current)))", at: path))
}
}
/// Locates `all`-group violations: each child that is not an in-bounds member,
/// recovering past it, then each required member that never appeared.
func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) {
var counts = [Int](repeating: 0, count: group.particles.count)
let steps = Self.childSteps(children)
for (index, child) in children.enumerated() {
guard let position = group.particles.indices.first(where: { slot in
let member = group.particles[slot]
let room = member.maxOccurs.map { counts[slot] < $0 } ?? true
return room && Self.memberMatches(member.term, child.name)
}) else {
errors.append(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here", at: path + [steps[index]]))
continue
}
counts[position] += 1
}
for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs {
if case let .element(name, _, _) = member.term {
errors.append(XSDFailure(reason: "element '\(name.localName)' is required but missing", at: path))
}
}
}
static func memberMatches(_ term: PureXML.Schema.Term, _ name: PureXML.Model.QualifiedName) -> Bool {
switch term {
case let .element(declared, _, _): declared.localName == name.localName && declared.namespaceURI == name.namespaceURI
case let .wildcard(wildcard): wildcard.admits(name)
case .group: false
}
}
/// A "; expected a, b" hint naming the elements the automaton accepts next.
static func expectation(_ labels: [PureXML.Schema.TermLabel]) -> String {
let names = labels.compactMap { label -> String? in
if case let .name(qualified) = label { return "<\(qualified.localName)>" }
return nil
}
let unique = Set(names).sorted()
return unique.isEmpty ? "" : "; expected \(unique.joined(separator: ", "))"
}
}

View File

@ -0,0 +1,146 @@
extension PureXML.Schema.ComplexValidator {
func validateAttributes(
_ element: PureXML.Model.Element,
_ type: PureXML.Schema.ComplexType,
at path: [PureXML.Validation.PathKey],
into errors: inout [XSDFinding],
) {
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) }
for use in type.attributes where !use.prohibited {
let match = present.first { Self.attributeMatches($0.name, declared: use.name, use: use, on: element.name) }
if let match {
if let error = use.type.validate(match.value) {
errors.append(XSDFinding(.attributeTypeValid, reason: "attribute '\(use.name.localName)': \(error)", at: path))
}
if let fixed = use.valueConstraint?.fixedValue, !use.type.valueMatches(match.value, literal: fixed) {
errors.append(XSDFinding(.attributeFixedMatches, reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
}
recordIDs(use.type, value: match.value, at: path)
} else if use.required {
errors.append(XSDFinding(.attributeRequiredPresent, reason: "missing required attribute '\(use.name.localName)'", at: path))
} else if let constraint = use.valueConstraint {
// An absent attribute takes its `default`/`fixed` value, so a
// defaulted or fixed IDREF/IDREFS must still resolve to a matching
// ID (cvc-id): the supplied value participates in document-scoped
// ID/IDREF checking exactly as a present value would.
recordIDs(use.type, value: constraint.value, at: path)
}
}
validateWildcardAttributes(present, element: element, type: type, at: path, into: &errors)
if idTypedAttributeCount(present, type, on: element) > 1 {
errors.append(XSDFinding(
.attributeSingleID,
reason: "an element may carry at most one attribute of type ID",
at: path,
))
}
}
/// The number of `present` attributes whose effective type is `xs:ID` (an
/// element may carry at most one; XSD 1.0 §3.4.6, cvc-complex-type). An
/// attribute's type is its declared use's type when it matches one, otherwise
/// the global declaration a matching wildcard admits.
private func idTypedAttributeCount(
_ present: [PureXML.Model.Attribute],
_ type: PureXML.Schema.ComplexType,
on element: PureXML.Model.Element,
) -> Int {
present.reduce(into: 0) { count, attribute in
if isIDTyped(attribute, type, on: element) {
count += 1
}
}
}
/// Whether `attribute`'s effective type is `xs:ID`: the type of the declared
/// use it matches, or the global declaration a matching wildcard admits.
private func isIDTyped(
_ attribute: PureXML.Model.Attribute,
_ type: PureXML.Schema.ComplexType,
on element: PureXML.Model.Element,
) -> Bool {
if let use = type.attributes.first(where: { !$0.prohibited && Self.attributeMatches(attribute.name, declared: $0.name, use: $0, on: element.name) }) {
return use.type.isID
}
guard let wildcard = type.attributeWildcard, wildcard.admits(attribute.name),
let use = globalAttributeUse(for: attribute.name)
else { return false }
return use.type.isID
}
/// Whether an instance attribute satisfies a declared use. Chameleon-included
/// global attributes may appear unprefixed on an element in the effective TN.
static func attributeMatches(
_ instance: PureXML.Model.QualifiedName,
declared: PureXML.Model.QualifiedName,
use: PureXML.Schema.AttributeUse,
on elementName: PureXML.Model.QualifiedName,
) -> Bool {
if sameName(instance, declared) {
return true
}
guard use.chameleonUnprefixed,
instance.namespaceURI == nil || instance.namespaceURI?.isEmpty == true,
let declaredNamespace = declared.namespaceURI, !declaredNamespace.isEmpty
else { return false }
return instance.localName == declared.localName && declaredNamespace == elementName.namespaceURI
}
private func validateWildcardAttributes(
_ present: [PureXML.Model.Attribute],
element: PureXML.Model.Element,
type: PureXML.Schema.ComplexType,
at path: [PureXML.Validation.PathKey],
into errors: inout [XSDFinding],
) {
// A `use="prohibited"` use is excluded from the effective {attribute uses},
// so a prohibited attribute is treated as undeclared and left to the
// attribute wildcard (which may still admit it) or reported as undeclared.
let declared = type.attributes.filter { !$0.prohibited }
let elementName = element.name
let wildcard = type.attributeWildcard
for attribute in present where !declared.contains(where: { Self.attributeMatches(attribute.name, declared: $0.name, use: $0, on: elementName) }) {
guard let wildcard, wildcard.admits(attribute.name) else {
errors.append(XSDFinding(.attributeDeclaredOrPermitted, reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
continue
}
switch wildcard.processContents {
case .skip:
continue
case .lax:
if let use = globalAttributeUse(for: attribute.name) {
validateWildcardAttribute(attribute, use: use, at: path, into: &errors)
}
case .strict:
guard let use = globalAttributeUse(for: attribute.name) else {
errors.append(XSDFinding(
.wildcardAttributeUndeclared,
reason: "no declaration for wildcard-matched attribute '\(attribute.name.localName)'",
at: path,
))
continue
}
validateWildcardAttribute(attribute, use: use, at: path, into: &errors)
}
}
}
private func globalAttributeUse(for name: PureXML.Model.QualifiedName) -> PureXML.Schema.AttributeUse? {
globalAttributes[PureXML.Schema.XSDParser.attributeDeclarationKey(name)]
}
private func validateWildcardAttribute(
_ attribute: PureXML.Model.Attribute,
use: PureXML.Schema.AttributeUse,
at path: [PureXML.Validation.PathKey],
into errors: inout [XSDFinding],
) {
if let error = use.type.validate(attribute.value) {
errors.append(XSDFinding(.wildcardAttributeTypeValid, reason: "attribute '\(attribute.name.localName)': \(error)", at: path))
}
if let fixed = use.valueConstraint?.fixedValue, !use.type.valueMatches(attribute.value, literal: fixed) {
errors.append(XSDFinding(.wildcardAttributeFixedMatches, reason: "attribute '\(attribute.name.localName)' is fixed and must be '\(fixed)'", at: path))
}
recordIDs(use.type, value: attribute.value, at: path)
}
}

Some files were not shown because too many files have changed in this diff Show More