Compare commits

...

83 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
479 changed files with 17558 additions and 7958 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

@ -8,30 +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
`docs/rules/rules-swift/` and mirrored from
`/Volumes/Code/DeveloperExt/public/rules-swift`. Use it when a local
`docs/rules/` copy is missing or has drifted, but keep the PureXML-specific
constraints in this file and `docs/rules/README.md` as stronger project
overrides.
`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)
- `docs/rules/rules-swift/core/no-shortcuts-first-principles.md`
- `docs/rules/rules-swift/core/proof-discipline.md` (when making correctness or conformance claims)
- `docs/rules/rules-swift/parsing-rules.md` (when changing parser behavior)
- `docs/rules/rules-swift/validation-rules.md` (when changing validation behavior)
- `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

View File

@ -7,8 +7,275 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- The XSLT `system-property('xsl:vendor-url')` now returns the project's
address on git.aleahim.com, where the repository moved from Codeberg.
### Fixed
- An element restricting a NON-repeating base choice ({max occurs} = 1) is now
checked by the literal RecurseAsIfGroup pairing (XSD 1.0 section 3.9.6): the
derived element, with its own occurrence range, must be a valid restriction
of some branch. The effective-total-range approximation is kept only for a
repeating base choice, where the particlesZ001 ambiguity actually lives
(occurrence counts accumulated across the choice's own repetitions, which the
literal rule cannot express); at {max occurs} = 1 no such accumulation exists
and the spec text governs. Closes XSTS particlesHa161 (an optional derived
element is not a pointwise restriction of a required branch) and
particlesM034 (a derived `d1+` cannot map into a branch `(d1*, d2+)` whose
required `d2` is unmapped and not emptiable; the settled-valid twin
particlesM035, `d2+` over the same branch, still passes), taking
invalid-schemas-accepted 6 -> 4; stricter than libxml2, which implements
neither edge. Separately, particlesIg004 is reclassified as a spec-divergent
schema test (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 (jointly unsatisfiable with
the settled Ig001/002/003 and Ig005 expectations). The remaining three
schema deviations (Ik025, Ik027, schN12) are documented policy divergences
in `docs/xsts-deviations.md`, including why src-redefine 6.2.2 cannot be
enforced uniformly (mgO013's settled-valid schema violates it). Valid
buckets are unchanged at zero.
- A complex-type restriction that swaps an element reference for one of its
substitution-group members is now rejected (NameAndTypeOK, XSD 1.0 section
3.9.6 Elt:Elt clause 1: the declared names must match, and no clause grants
substitution an allowance in particle restriction). 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 the swap looked name-identical; `Particle.declaredReference` now
preserves the pre-expansion identity and the restriction check pairs the
declared references first. Closes XSTS elemZ026 and particlesV020
(invalid-schemas-accepted 8 -> 6); stricter than libxml2, which does not
implement this edge. particlesZ008, byte-for-byte the same construct with a
settled `valid` expectation contradicting elemZ026's settled `invalid`, is
reclassified as a spec-divergent schema test with the adjudication recorded
in the suite. Valid buckets are unchanged at zero.
### Changed
- A fifth serialize-path optimization pass (#139, `docs/benchmarks.md` pass
31): `textNeedsEscaping`/`attributeNeedsEscaping` and the `comment`/
`processingInstruction` existence fast-paths now scan a raw contiguous
buffer (`String.utf8.withContiguousStorageIfAvailable`) instead of going
through `UTF8View`'s default `Collection` iteration, falling back to the
original scan when no contiguous storage is available (every string this
library builds is natively contiguous, so the fallback is believed dead in
practice). Found by profiling a new, deeply-nested/attribute-dense corpus
the standard flat catalog corpus has no density to indict; a standalone
micro-benchmark confirmed the raw-buffer scan ~2-2.75x faster across
several call shapes, order-swap-checked against warm-up bias. Two
abstraction attempts were tried and rejected: 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 lint violation) erased nearly the entire win even
though it is not a closure value; the violation is instead resolved by
splitting each function into a contiguous/collection pair, so the call
boundary sits outside the loop rather than inside it. Output is
byte-identical by construction (same predicate, same trigger bytes, only
the iteration mechanism changed); full local suite (1925 tests) and full
lint unchanged. Measured with interleaved same-state A/B under real
concurrent machine load (load average ~2.4): clean with zero overlap
across 5 pairs at the new attribute-heavy nested corpus (~9%); noisier at
the 100k-item standard corpus (2 of 5 pairs individually reversed by a
small margin) but the means still separate clearly (~4%), the same
trust-the-aggregate-under-diagnosed-noise call this log made for pass 28.
- Two serialize-path optimization passes (#139, `docs/benchmarks.md` passes
29-30), found by sample-profiling the serialize loop: the attribute writer
precomputes the quote character as a `String` once per element instead of
interpolating a `Character` into every attribute (a `Character`
interpolation goes through the string's grapheme-breadcrumb machinery, work
repeated twice per attribute for a value that never changes within an
element), and `Escaping.comment`/`processingInstruction`'s escaping
fallback (taken whenever a comment has a hyphen or PI data has a `?`, a
case pass 8 could only skip past, not speed up) is rewritten in the same
byte-scan bulk-copy shape as passes 23-24, so a comment or PI 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 (a `Character` and its single-scalar `String` render
identically); the comment/PI rewrite is verified against a new differential
suite (`Tests/CommentAndPIEscapingDifferentialTests.swift`) that
reimplements the original character-loop as an independent oracle and
checks hand-picked 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 (clean, two pairs); pass 30 ~2x on a dedicated
comment/PI micro-benchmark (no comment/PI content exists in the standard
corpus, so it does not move the headline ratio there, but real-world
documents with copyright banners or hyphenated prose in comments exercise
it directly). A same-session multi-append restructuring attempt (splitting
a single interpolated `output +=` into several direct appends) measured a
clear ~6% regression and was reverted rather than landed; a suspected
`hasInlineContent` double-scan of an element's children was profiled and
found to contribute ~0.2% of samples, not worth a pass. Both outcomes are
recorded in `docs/benchmarks.md` rather than silently dropped.
- A fourth parse-path optimization pass (#139, `docs/benchmarks.md` pass 28):
`scanAttributes`' duplicate-name check reuses one stored scratch
`Set<String>` (cleared with `removeAll(keepingCapacity: true)` per element)
instead of allocating fresh native Set storage for every element with
attributes. Safe because `scanAttributes` cannot re-enter itself; duplicate
detection, recovering-mode behavior, and thrown errors are unchanged (full
local suite and full W3C XSTS pass unchanged). Measured ~2-4% on parse with
an interleaved same-state A/B (both patched runs beat both baseline runs),
the protocol chosen because the session had concurrent machine load. Two
companion outcomes from the same profiling round, recorded rather than
hidden: a children-capacity reservation candidate measured neutral and was
reverted per the benchmarks log's ship-only-measured-wins rule, and the
`TreeNode` weak-parent side-table cost (~9% of parse) is documented in
`docs/benchmarks.md` as a structural finding that needs a design decision,
not an optimization pass, since `parent`'s nil-after-release semantics are
documented public API.
- A third parse-path optimization pass (#139, `docs/benchmarks.md` pass 27),
found by sample-profiling `scanContentMarkup()` in isolation after passes
25-26 (it dominates parse time, ~46%, since it inlines
`scanStartTag`/`scanAttributes`/`scanAttributeValue`/`NamespaceContext.enterElement`):
`scanAttributeValue`'s opening and closing quote checks now use the byte
fast path (`peekByte()` + `reader.consume(_:)`/a byte peek before the
Character peek) instead of the Character path, for every attribute value in
the document; `NamespaceContext.checkExpandedNames` skips its `Set<String>`
allocation and key-string building entirely when fewer than two attributes
carry a namespace URI (the check can only ever throw on a collision between
two such attributes, so 0 or 1 can never trigger it, and this is the
overwhelming majority of elements); and `EntityDecoder`'s `raw.contains("&")`
guard is now a UTF-8 byte scan instead of a `Character`-based one, the same
shape as `NamespaceContext.checkNameShape`'s colon check in pass 26. All
three are same-observable-behavior changes, verified against the full local
suite (1858 tests, 6 new covering the quote-handling edge cases directly:
empty value, single-quoted, a quote-kind nested inside the other, and
unterminated-at-end-of-input, plus byte/Character-path agreement for each)
and the full W3C XSTS suite (14383 groups), both unchanged. Measured parse
~6.00x -> ~4.5-4.75x versus libxml2 at the 20000-item/4.2MB corpus, best of
5; see `docs/benchmarks.md` for the full before/after and reasoning.
- Two parse-path optimization passes (#139, `docs/benchmarks.md` passes 25-26),
found by sample-profiling a 42MB generated corpus after re-measuring the
parse/libxml2 ratio fresh rather than trusting the doc's last-recorded
numbers: `scanAttributeValue`'s closing-quote check now compares the
already-buffered peek directly instead of re-deriving the same answer
through `Reader.consume`'s general `String`-iteration path (which allocated
a fresh `String(quote)` per attribute value in the document), and
`NamespaceContext.enterElement` scans a name's UTF-8 bytes instead of its
Characters to find an illegal embedded colon, and skips its per-element
attribute-array allocation entirely when no attribute actually needs
namespace resolution (the common all-unprefixed-attributes case). Both are
same-observable-behavior changes (verified with the full local suite, 1852
tests, and the full W3C XSTS suite, 14383 groups, both unchanged) that
measured parse ~6.59x -> ~6.00x versus libxml2 at the 20000-item/4.2MB
corpus, best of 5; see `docs/benchmarks.md` for the full before/after and
the reasoning behind each change.
- Update Codeberg links after the NodetteHQ transfer, including README badges,
support and roadmap issue URLs, and the XSLT `system-property('xsl:vendor-url')`
result.
- Rebuild the issue tracker on Codeberg after the GitHub tracker loss: the
production-readiness epic, both XSTS gate trackers, the performance epic,
and the Xalan burn-down are recreated with current dated statuses, and
`docs/roadmap.md` and `README.md` now cite the new numbers. The stale
baseline figures in both documents are corrected to the enforced ratchet
in `Tests/XSTSSuiteTests.swift` (0 / 8 / 0 / 4); shipped items (located
diagnostics, the schema differential harness) no longer show as open.
## [0.4.8] - 2026-07-05
### Changed
- A generated-value facet differential against libxml2 (`FacetValidationDifferentialTests`, opt-in via `FACET_DIFF` and `xmllint`) exercises the datatype and facet value space, the largest correctness surface the determinism and instance-content differentials do not touch: for each generated simple type restricting a built-in (`xs:int`, `decimal`, `double`, `string`, `token`, `boolean`, `date`, `dateTime`, `nonNegativeInteger`) by a subset of its facets (min/maxInclusive/Exclusive, length/min/maxLength, totalDigits/fractionDigits, pattern, enumeration, whiteSpace), it validates a pool of values chosen at and around the facet boundaries with PureXML and with libxml2 and flags every disagreement, asserting the cardinal rule (zero values PureXML rejects that libxml2 accepts). Over about ten thousand values it found one false positive, the `NaN` enumeration fixed below, and otherwise zero; the residual false negatives are contested datatype readings (whitespace collapse, `NaN` against a range facet) where PureXML matches the XSD spec and this libxml2 build is stricter. Test-only.
- Compiling a schema with a long `<xs:simpleType>` restriction chain (`S0` restricting `S1` restricting `S2`, and so on) is no longer near-cubic in the chain length, so a deep chain no longer stalls the compiler, a keystroke-time interactive-safety hang on hostile or generated input. Such a chain is flat in the document, so the same base type was resolved over and over: the named simple types were resolved by an N-pass fixpoint that re-resolved every type on every pass, and the facet-applicability variety check re-scanned the schema's children to find each base at every link of every type's chain. Named simple types are now resolved once in dependency order (base before derived), and the variety check resolves a base through a precomputed name map in constant time and memoizes each type's variety, so a chain is walked once rather than per type. A 600-link restriction chain that took about 4.4 seconds to compile now takes about 30 milliseconds. Behaviour-preserving: the resolved types and the emitted findings are identical, verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged), the libxml2 determinism differential unchanged, and the full suite. The analogous repeated re-scan in attribute-group reference chains is milder and remains, tracked.
- Compiling a schema with a long `<xs:complexType>` `simpleContent` extension chain (`T0` extending `T1` extending `T2`, and so on) is likewise no longer near-cubic. Each of the N types re-walked its whole base chain to resolve the simple type its `simpleContent` derives from, the same flat-document re-resolution. The resolved simple type of a complex type's `simpleContent` is now memoized by name (`SimpleContentMemo`, a reference type that survives the value-type context copies), so a chain is walked once rather than per type. A 400-link chain that took about 23 seconds to compile now takes about 1.2 seconds; past the content-model expansion-depth bound (512) such a chain is rejected with a located error as before. Behaviour-preserving: the resolved type is intrinsic to the name for an acyclic chain (a derivation cycle is rejected elsewhere), verified conformance-neutral over the full W3C XSTS corpus in release. The attribute flattening that accumulates each type's attributes along the chain is inherently quadratic in the bounded chain length and is tracked.
- A generated-instance validation differential against libxml2 (`InstanceValidationDifferentialTests`, opt-in via `INSTANCE_DIFF` and `xmllint`) exercises 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. Where the schema-compile determinism check has its own differential, this characterises instance validation, 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. Test-only; no shipped behaviour change.
- The graph-structured-stack content matcher's runtime resource use now has a proven, asserted bound: `ContentRTNMatcher` exposes `peakLiveConfigurations` (the largest live-configuration set in any closure) and `gssNodeCount` (the total graph-structured-stack nodes built), and `ContentRTNDifferentialTests` asserts they stay linear in a shared-group chain's depth (measured `gss = K + 2`, peak configurations `= 3K + 3`), where an explicit return stack would reach `O(distinct stacks)`. The bound is checked by count, not by a machine-dependent wall-clock timeout, so a regression to super-linear fails the test rather than only slowing down. Test-and-metadata only; no shipped behaviour change.
- A generated-schema determinism differential against libxml2 (`DeterminismDifferentialTests`, opt-in via `DETERMINISM_DIFF` and `xmllint`) fuzzes content models, elements, group references, choices, sequences, `all` groups, occurrences, and wildcards, and compares PureXML's Unique Particle Attribution accept/reject verdict to the reference's. The XSTS corpus is finite and did not exercise the multiply-referenced-group overlap that was a real gap; this characterises the UPA check over the open shape space instead, asserting the cardinal rule (zero schemas PureXML rejects as ambiguous that libxml2 accepts) and reporting false negatives for triage. It found zero false positives and a 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 gap before the enclosing repetition's loop edge, so the combined decision set is never checked. Test-only; no shipped behaviour change.
- A model `Group` now carries an internal `definitionID`, a stable identity stamped on each `<xs:group>` expansion (from the existing per-compile memo key) and shared by every reference to it. It is metadata only (`Group` is not `Equatable`/`Hashable`, so it perturbs no comparison) and has no consumer yet: it is the first step of replacing the per-instance content-model build's per-reference group inlining (the `2^K` doubling-chain hang) with the graph-structured-stack matcher designed in `docs/design/counted-content-automaton.md`. No behaviour change; the XSTS buckets are unchanged.
- The counted content matcher gains a shared-group form: `ContentRTNBuilder` compiles a particle to a Recursive Transition Network in which each `definitionID` group is compiled once and reached by a `call`, and `ContentRTNMatcher` simulates it with a graph-structured stack (configurations are `(state, counters, gssNode)`; a `call` saves the caller's counters on an interned GSS node and a `return` restores them, so a multiply-referenced group never mixes counter state across its callers). This replaces the inlining matcher's `2^K` state and reset-action blow-up with an `O(groups)` build: a depth-24 doubling chain (`2^24` inlined states) compiles to under 480 states in under a millisecond. The matcher exposes the full surface the content-model validator reads: `matchesWhole`, `matchedParticles`, and the streaming `startStates`/`step`/`admissible`/`isComplete` used for located diagnostics and the "expected a, b" hints. It is verified against the trusted inlining matcher (`ContentNFA`) as the oracle by a differential gate (`ContentRTNDifferentialTests`) that locks not only the accept verdict and the matched particle but the located rejection index, the `admissible` expectation set at every prefix, and `isComplete`, over: the hand-written 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 group references stamped the way the parser stamps them (a unique id per distinct group, a shared id only for genuinely shared structure, some anonymous inline groups). It is built alongside the inlining matcher and is not yet wired into validation, so there is no behaviour change and the XSTS buckets are unchanged; the switch-over is gated on this differential plus XSTS neutrality.
- Content-model validation and completion now run on the graph-structured-stack matcher: the three call sites that built the inlining automaton per validated element, `ComplexValidatorContentModel.sequenceStructureErrors` (located structure errors), `ComplexValidator.validateParticle` (per-child particle attribution), and `XSDCompletions` (the acceptable-next labels), now build `ContentRTNBuilder` and run `ContentRTNMatcher`. This removes the last per-instance `2^K` content-model blow-up: a schema whose nested `<xs:group>` references inlined 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 now built in `O(groups)`. 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) and behaviour-identical on the located diagnostics and completions by the surface differential above. `ContentNFABuilder`/`ContentNFA` are retained as the differential oracle.
- The schema-validity differential harness (`XSTSDifferentialTests`, the opt-in libxml2 characterisation for production-readiness stopper 3) now reports each disagreeing schema by its path relative to the corpus root, not its bare basename. The same basename recurs across subdirectories (`Facets/Schemas/gMonth_maxInclusive001.xsd`, which carries `value=""` and is correctly rejected, versus `Facets/gMonth/gMonth_maxInclusive001.xsd`, which carries the contested 2001-REC `--MM--` form), so a basename-only entry hid which file actually disagrees. The header now also records the triage of the "libxml2 stricter" queue: it is dominated by libxml2 over-strictness (a `maxOccurs` like `999999999999999` is a valid unbounded-precision integer libxml2 rejects on its machine-int limit; an unresolvable `include`/`import` it fails to load) and contested datatype-version readings (`gMonth --MM--`, accepted to keep valid XSTS instances; `anyURI` strictness), not clean false negatives. Characterisation only; no shipped behaviour changes.
### Fixed
- An unprefixed `xsi:type` value now resolves in the instance's namespace context rather than matching a namespaced type by local name alone. `xsi:type` 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. The resolver now keys an unprefixed `xsi:type` to the in-scope default namespace (none here), so `xsi:type="Test"` names no-namespace `Test`, which does not exist; the root element then has neither a declaration nor a usable type and the instance is invalid (W3C XSTS targetNS00101m; libxml2 rejects it identically). A prefixed `xsi:type` and an unprefixed one under a matching default namespace are unaffected. Verified over the full W3C XSTS corpus in release: `invalid-instances-accepted` 6 -> 5, `valid-instances-rejected` held at 0 across all 14383 groups, and the full suite passes.
- An XSD `key`/`keyref`/`unique` field that selects a node whose declared type is not simple is now rejected (cvc-identity-constraint.3), rather than wrongly accepted. The XSD identity-constraint rule requires each 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 failing W3C XSTS case idZ010 nests `anyType` fields several levels deep). A compile-time pass (`XSDParser.identityNonSimpleFieldKeys`) now 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` (cvc reads the node's governing type, so a text-only `anyType` element with `xsi:type="xsd:int"` is still 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. 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 introduced), and the full suite passes.
- The XSD regex escapes `\i` and `\c` now use the XML 1.0 Second Edition name classes (Appendix B), not the parser's Fifth Edition Name production. XSD 1.0 (Datatypes Appendix F) 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 XML 1.0 Second Edition productions, which are the enumerated Unicode-2.0-era character classes of XML 1.0 Appendix B. `\i`/`\c` were wired to `PureXML.Parsing.XMLCharacter`, which correctly implements the Fifth Edition 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, for example the combining mark `U+0346` (`COMBINING BRIDGE ABOVE`), which Appendix B's `CombiningChar` excludes (its first range ends at `U+0345`). A new `XSDNameCharacter`, transcribed clean-room from the Appendix B grammar (identical across editions one through four) and stored as sorted code-point ranges with binary-search membership, now backs both escapes; the parser's own name check is untouched. The two range tables are verified to reproduce the W3C XSTS `\i`/`\c` enumerations (`reZ005`, `reZ006`, 34516 and 35122 code points) exactly and in both directions, so 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), with both false-positive buckets at 0. 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.
- The Ethiopic digits `U+1369`..`U+1371` (`ETHIOPIC DIGIT ONE`..`NINE`) now match `\d` in an XSD pattern and not `\D`. XSD 1.0 fixes the regular-expression repertoire at Unicode 3.2 (Datatypes Appendix F), where these nine code points are general category `Nd` and each carries 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 override table was missing these nine, so `\d` used the modern `No` and rejected them. This was a two-directional bug: a valid instance whose `\d`-constrained value was an Ethiopic digit was wrongly rejected (the cardinal rule: never reject a valid value), and a `\D`-constrained value of one was wrongly accepted. They are now pinned to `Nd`, which is the complete set of code points that were `Nd` at 3.2 and are not today (the reverse direction is empty: every code point that became `Nd` later was unassigned at 3.2 and so already reports `Cn`, verified by diffing the 3.2 and current Unicode `Nd` sets), so `\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), the matching `\d` valid cases (`reS17`, `reS38`, `reZ004`) no longer need their suspect-test exclusion and pass as ordinary agreements, and the two false-positive buckets stay 0. Found while triaging the invalid-instance tail against libxml2 (which rejects `reT17`/`reT38`, the reference-aligned reading) back to the authoritative `UnicodeData-3.2.0`; a prior note that had treated these as a disputed spec-divergence was the wrong call and is corrected.
- A value of `NaN` is no longer wrongly rejected by an `xs:double` or `xs:float` `enumeration` that lists `NaN`. Value-space equality (`OrderedValue.==`) compared with IEEE equality, under which `NaN != NaN`, so a `NaN` value never matched a `NaN` enumeration member and was rejected, a false positive (the cardinal rule: never reject a valid value). The comparison now treats `NaN` as identical to itself, the value-space identity the reference (libxml2) and XSD 1.1 use throughout, so `<v>NaN</v>` validates against a type restricting `xs:double` by `<xs:enumeration value="NaN"/>`; `INF`/`-INF` already matched. This equality also backs the `key`/`unique` identity constraints and `fixed`, where the same identity holds (verified against `xmllint`): two `NaN`-valued unique fields collide as duplicates, and `fixed="NaN"` matches `NaN`. The ordering facets (`minInclusive` and friends) use the order relation, in which `NaN` stays incomparable, and are unaffected. Found by the new generated-value facet differential against libxml2, with the key/fixed behaviour confirmed after an adversarial critic flagged the shared comparison; the four W3C XSTS buckets held and the full suite passes, with regressions in `SchemaDoubleEnumerationTests`.
- A long `<xs:attributeGroup ref>` chain (`ag0` referencing `ag1` referencing `ag2`, and so on) no longer overflows the stack or hangs the compiler. Such a chain is flat in the schema document, so flattening a complex type's attributes through it recursed once per link, overflowing the stack past about six thousand links and running near-cubic before that (about 17 seconds at three thousand links). The attribute-group reference depth is now bounded by `maximumContentModelExpansionDepth` (512, the same bound the content-model expansion uses), since the cycle-guard `visited` set already counts the chain depth: a chain past the bound is rejected with a located error (`attribute group reference nesting exceeds the maximum depth of 512`) rather than crashed. Behaviour-preserving for every schema within the bound, which no realistic schema or the XSTS corpus approaches; verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged) and by the full suite. Disclosed residual: a schema with very many distinct over-bound attribute-group chains still rejects in time quadratic in the bounded depth per group, because the attribute flattening is re-done per group; memoizing it, as the `simpleContent` resolution now is, is the linear follow-up.
- The schema compiler no longer overflows the stack on a content model whose expanded depth is unbounded. 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 (the group definitions are siblings, not nested) and a `complexContent` derivation chain (`B0` extends `B1` extends `B2`, and so on, the type definitions also siblings). The particle compiler inlines each group reference and composes each derivation once per link, so a long chain recursed once per link until the stack overflowed while compiling a legal, if hostile, schema, a keystroke-time interactive-safety crash. The expanded depth is now bounded by a single counter (`XSDContext.expansionDepth`, incremented at each compositor level, group-reference expansion, and derivation step) against `maximumContentModelExpansionDepth` (512, twice the element-nesting limit: room for a maximally nested model plus as much again for expansion). A model past the bound is rejected with a located error (`content model nesting ... exceeds the maximum depth of 512`, or the derivation form), not crashed: a bound, not a band-aid. The bound is on total expanded depth, so it covers both a long chain of shallow links and a short chain of internally deep ones. Together with the determinism-check fix below, this closes the group-reference-chain crash on every compiler pass that recursed on the chain. Verified conformance-neutral over the full W3C XSTS corpus in release (all four buckets unchanged; no valid schema newly rejected, the corpus's content models nesting far below the bound), the libxml2 determinism differential unchanged, and the full suite. An adversarial over-rejection critic confirmed the counter lives in a value-type context whose copies make it track depth, not width, so a wide-but-shallow model (hundreds of sibling particles) is never falsely rejected, and that the rejection is harvested into the schema's errors rather than silently truncating the model.
- A deeply nested chain of `<xs:group>` references (`g0` referencing `g1` referencing `g2`, and so on) no longer overflows the stack in the Unique Particle Attribution determinism check. Such a chain is flat in the schema document, the group definitions being siblings rather than nested, so it is not bounded by the parser's `maxDepth`; the compositional check summarised each group lazily by recursing through the reference at its use site, so a long chain recursed once per link and overflowed the stack while compiling a legal (if hostile) schema, a keystroke-time interactive-safety crash on the primary platform. Group summaries form an acyclic graph (a circular group reference is rejected elsewhere), so they are now precomputed bottom-up in dependency order: every reachable group is summarised once, in an order where each group's own references are already cached, so the walk's group lookup is always a cache hit and recursion is bounded by a single group's model nesting (which `maxDepth` bounds), not by the chain length. A group summary is context-independent, so this only reorders the work; the set of decision sets it produces is identical and the accept/reject verdict does not depend on their order. Verified verdict-equivalent over the full W3C XSTS corpus in release (all four buckets unchanged: `valid-schemas-rejected` and `valid-instances-rejected` stay 0, `invalid-schemas-accepted` 8, `invalid-instances-accepted` 14), the generated-schema determinism differential against libxml2 unchanged (zero false positives, the same three named false negatives), and the full 1816-test suite. The particle-compiler pass that inlines a group reference, which this left recursing on the same chain, is now bounded as well (see the content-model expansion-depth entry above).
- A repeating model whose optional trailing particle and leading particle share a name, `(c, b?, c?)*`, is now correctly rejected as a Unique Particle Attribution violation, 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 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) with 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`.
- Two references to the same `<xs:group>` that overlap (`(g?, g?)`, `(g | g)`, `(g*, g)`) are now correctly rejected as a Unique Particle Attribution violation, as the inline forms `(x?, x?)` / `(x | x)` already were 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 that two references are two expanded occurrences, and PureXML accepted these models (an invalid-schemas-accepted false negative). It also let the per-instance content matcher run on such a non-UPA model, where a multiply-referenced nullable group is exponential: a depth-18 optional doubling chain was accepted and then hung instance validation, a keystroke-time interactive-safety 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, while the repetition self-loop (`a*`, `(a*)*`) keeps its non-flagging dedupe so a single particle looping is not misread as two occurrences. This also bounds the check: the deduped first/follow sets stay `O(labels)` instead of `2^K`, so the same depth-40 chain is now rejected at compile in 11 ms and no instance is ever matched against it. A redefine self-extension (`<extension base="T">` inside the redefinition of `T`) lists a type's own content twice through `effectiveModelNodes`; that assembly artifact is deduped before the walk so it is not read as a conflict (addB094, schZ007). Verified over the full W3C XSTS corpus in release: `valid-schemas-rejected` and `valid-instances-rejected` stay 0 and `invalid-*-accepted` are unchanged (the corpus does not exercise this shape in its pinned buckets), with an adversarial over-rejection critic finding no valid schema newly rejected across the group-overlap shape space.
- XSTS invalid-schemas-accepted (#145): a particle restriction whose restricting element has a union-typed base element now applies Type Derivation OK (Simple) (`cos-st-derived-ok`) rather than structural member subsetting. The NameAndTypeOK type clause (§3.9.6) requires the restricting element's type to be validly derived from the base element's type; PureXML accepted a *separately declared* union whenever its member types were a subset of the base union's members, which is not the spec's derivation relation. 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. Catches particlesIk011 (a fresh `union(integer)` is not a restriction of `union(integer, string)`): invalid-schemas-accepted 9 to 8, with valid-schemas-rejected held at 0 (verified over the full XSTS; the change is scoped to the element-restriction path, so no attribute or simple-type derivation check moved). Disclosed divergence: particlesIk025/Ik027, where the restricting type derives from a union *member*, are accepted per `cos-st-derived-ok` clause 2.2.4, a contested reading their XSTS `invalid` expectation does not share.
### Removed
- Five unused internal schema-consistency wrappers (`idAttributeErrors`, `consistencyErrors`, `postNamedTypeErrors`, `structureErrors`, `includeCompositionErrors`) that returned `[String]` reason lists, along with the now-orphaned `postCompileErrors(schema:context:containers:namedTypes:)` overload and the `SchemaCompileNamedTypes` struct it consumed. None had callers; the `XSDParser.consistencyErrors` overload was shadowed by the distinct, still-live `Validation.XSDSchema.consistencyErrors` (a different signature returning `ValidationError`), which is why a name-only search first read it as used. The located-diagnostic API (the `*Findings` functions returning `SchemaLocatedFinding`, and `Validation.XSDSchema` returning `ValidationError`) superseded all of them. Removing them deletes dead code with no behavior change; the live `*Findings` and `Validation.XSDSchema` functions are unchanged. A stale doc-comment reference to the renamed `userTypeValueConstraintErrors` (now `userTypeValueConstraintFindings`) was corrected at the same time.
- Two unused private helpers in `XSLTNamespaceFixup` (`fixAttributeName` and `resolvedPrefix`). The result-tree attribute fixup they implemented was inlined into `fix(_ element:)` and evolved past them (the inlined version additionally shadows a carried prefix through `localPrefixes`, which the helpers did not), leaving both with zero callers. Removing them deletes dead, diverged duplicate logic with no behavior change; the live inlined path is unchanged.
- Three more dead file-scoped functions found by a private-scope sweep: `selectNodes` in `XSLTTransformer` (its sibling `selectXPathNodes` is the live one; a doc comment that referenced it was reworded), `processingInstructionMatches` in `EvaluatorSteps`, and `parseExternalID` in `DoctypeScanner`. All three are `private`/`fileprivate` with zero callers, so removing them is provably behavior-neutral (the compiler confirms).
- Three more dead functions found by a repo-wide reference sweep: `XSLTTransform.transformToNode` (a transform-to-tree variant nothing called; the live `transform` returning a string is independent), and `ComplexValidatorHelpers.matchesAll` together with its now-orphaned helper `label(of:)`. All had zero references; build and 1775 tests confirm behavior is unchanged.
### Fixed
- The XSLT evaluator is now iterative, so template recursion and result-tree nesting no longer consume the native stack (#356). A recursive named template whose depth is driven by source data (for example `<xsl:with-param name="n" select="count(//x)"/>`) could previously recurse without bound and crash the process (SIGBUS) on attacker-controllable input; and even an identity transform of a deeply-nested source overflowed the stack on a small (Swift-concurrency Task) stack. The mutually-recursive `instantiate`/`evaluate`/`applyTemplates` engine is replaced by 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 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 (the namespace fixup, the `text` and string-value extractions, and the content-type-meta and CDATA-section rewrites) were made iterative as well. 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 otherwise exhaust memory), now defaulting to 40000 and tripping `XSLTError.recursionLimitExceeded` only past that. Behavior is otherwise unchanged: the full Apache Xalan XSLT 1.0 conformance corpus and the in-repo XSLT/XPath suites pass identically (one previously-failing Xalan case, `mdocs07`, now passes and was de-baselined). `maxTemplateDepth` and `PureXML.XSLT.defaultMaxTemplateDepth` (added for the earlier stack-based guard) keep their public shape.
- Serializing a tree as HTML (`PureXML.HTML.serialize`, the html output method's serializer) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested tree (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`): 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. The output is byte-identical: the void-element, raw-text, comment, and escaping semantics are unchanged, with the HTML conformance corpus passing. A 50000-deep tree now serializes without crashing. (#350)
- Processing `xi:include` elements (`PureXML.XInclude.process`) no longer recurses on the tree's depth, so it no longer overflows the stack on a deeply-nested document, even one with no includes (as shallow as a couple of thousand deep on a Swift Task stack). The pass now drives an explicit work stack: each branch gathers its processed children into a reference accumulator, then a deferred build step assembles the node, while the `xml:base`, inclusion-chain depth, and cycle-detection visited set thread down 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 pass. A 50000-deep document, with or without an include at its base, now processes without crashing. (#350)
- The two Canonical XML transforms that still recursed on a document's depth are now iterative, so they no longer overflow the stack on a deeply-nested tree (#350). The C14N 2.0 sequential prefix rewrite walks the tree twice (a document-order pass that assigns one canonical prefix per namespace, then a rebuild pass); both now drive explicit stacks, the assign pass a pre-order walk and the rebuild pass a child-gathering accumulator, with the in-scope declarations and the root flag threaded down per element. The node-subset serializer (`canonicalize(_:including:)`, the C14N form over a selected subtree) now drives the same deferred-close work stack as the main serializer, threading each element's in-scope and rendered namespace context, with an omitted element still passing its declarations to descendants. The canonical bytes are unchanged: the C14N exclusive, 2.0, and node-subset corpora pass. A 50000-deep document now rewrites and subset-canonicalizes without crashing. (#350)
- Canonicalizing a document (`Canonicalizer`, the C14N form used for XML signatures) no longer recurses on the document's depth, so it no longer overflows the stack on a deeply-nested document (it crashed around 5000 deep). The serializer now drives a deferred-close work stack that threads each element's namespace context to its children. The canonical bytes are unchanged: the C14N 1.0/1.1 and exclusive conformance corpora pass. (#350)
- Applying DTD attribute defaults to a document (`parseApplyingInternalDTDDefaults`, and the defaults pass behind DTD validation) no longer recurses on the document's depth, so it no longer overflows the stack on a deeply-nested document. The tree is rebuilt bottom-up through a new shared iterative spine (`Node.rebuildingBottomUp`), which the other tree-to-tree transforms will adopt. (#350)
- String-value extraction no longer recurses on a node's depth, so computing the string-value of a deeply-nested node no longer overflows the stack (it crashed as shallow as a couple of thousand deep on a Swift Task stack). `TreeNode.textContent` and the value-`Node` string-value behind `XPath.Query.strings`/`Selection.stringValue` now walk the subtree iteratively. (#350)
- The value-`Node` string-value (behind `XPath.Query.strings` and `Selection.stringValue`) no longer includes the data of descendant comment and processing-instruction nodes in an element's string-value, matching XPath 1.0 (sections 5.1 and 5.5, where only text and CDATA contribute) and the tree-node path. A comment or PI node's own string-value is still its data (5.6, 5.7).
- The value-returning XPath result API (`PureXML.xpath`, `XPath.Query.evaluate`/`elements`/`strings`) is now linear for results that nest within one another, not quadratic. Each result was rebuilt as an independent value `Node` subtree, so selecting a deep chain of nested elements materialized the sum of their subtrees. The projection now shares a per-result-set memo keyed by tree-node identity, so a result reuses the already-built value `Node` of a deeper one by reference (copy-on-write); each tree node is projected once. `//a` over a 8000-deep chain drops from about 19 s to about 0.04 s, and the result set is unchanged. (#348)
- Sorting an XPath result into document order is now linear in the document, not quadratic in its depth. Each node's order key was its root path, an array as long as the node's depth, so ordering the descendants of a deeply-nested document was O(n log n x depth). The key is now a single O(1) index from a per-root pre-order numbering (built once per document and shared through the evaluation cache), with a band ordering an element before its namespace and attribute nodes and its children. A node-set spanning several documents is grouped by root. This linearizes the reference query path (`Query.nodes`, which XSLT and Schematron drive) on deep documents; the underlying subtree walk (`appendSubtree`) is now iterative too, so it no longer recurses. Document order is unchanged.
- Parsing is now linear in nesting depth, not quadratic. Resolving each open element's name and attributes looked up namespace bindings by walking a scope stack as deep as the document, so a deeply-nested document was O(depth^2): a 40000-deep document took tens of seconds. The parser now maintains the current in-scope bindings incrementally (a single map plus a per-element undo list), so a lookup is O(1) and parsing is O(n). The same document parses in a fraction of a second, with no change to resolved namespaces. (#345)
- The immutable value `Model.Node` tree type is now depth-safe. An `Element` previously stored its children inline (the enum 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 (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`): the `Node` enum is no longer `indirect`, so it is a fixed-size value, and `==`, `hashValue`, and release all walk the tree iteratively through explicit work stacks. Release flattens only storage it solely owns, so a copy-on-write sibling keeps its subtree. Value semantics are unchanged (copying a node still copies its children, lazily) and `Element`/`Node` remain `Equatable`, `Hashable`, and `Sendable`. A 50000-deep tree now builds, compares, hashes, and releases in a fraction of a second with no crash. (Completes the value-`Node` half of #341.)
- The mutable `Model.TreeNode` tree type is now depth-safe: building one from a value `Node`, projecting it back with `node`, and releasing it all run in bounded native stack instead of recursing one frame per level. Construction (`TreeNode(_:)`) and projection (`node`/`projectedChildren`) drive explicit post-order work stacks, and `deinit` flattens its subtree into a heap worklist before releasing, so a deeply-nested tree no longer overflows the stack through a recursive chain of child releases. The teardown only flattens nodes it solely owns (`isKnownUniquelyReferenced`), so a child still referenced elsewhere keeps its subtree. A 60000-deep tree releases in well under a tenth of a second; behaviour for ordinary trees is unchanged. The validator walk and the XPath descendant axis (`AxisNavigation.appendDescendants`) were made iterative for the same reason. (Part of #341. The immutable value `Node`'s own recursive release, and XPath result ordering/dedup, are still depth-bounded and tracked there.)
- Rejecting a billion-laughs document is no longer exponential in nesting depth. The "does this entity produce markup" check (`splitAtMarkupEntity` / `producesMarkup`) recursed into every reference of every replacement without memoizing, so a chain `lol8 -> 10x lol7 -> 100x lol6 -> ...` was traversed O(references^depth) times. The check is now memoized per entity name (the property depends only on the fixed entity graph), making it O(entities). A classic eight-level billion-laughs with DTD processing enabled drops from about 18 s to about 0.6 s (now bounded by the expansion budget, not the traversal), with no change to which documents are accepted; a cyclic entity is still rejected.
- The No-Recursion check (`checkRecursion`) no longer re-traverses shared sub-chains. It validated the reference graph with only an in-progress (grey) set, so a billion-laughs graph was walked O(references^depth) times; a markup-producing eight-level document took about 31 s to reject. It is now a three-colour DFS that records each fully-verified entity (black set) and skips it on revisit, one pass over the distinct edges. The same document drops to about 11 s; cyclic entities are still rejected and no accepted document changes. (The residual cost is the re-injection re-read of the growing remainder on the markup-inclusion path, where `Reader.inject` re-maps the unconsumed tail each cycle; tracked in #339.)
- Entity and content expansion is no longer quadratic in the size of a single expansion. The parser's lookahead buffer consumed from the front with `Array.removeFirst()` and re-injected entity replacement text with `insert(at: 0)`, both O(buffer), so a large internal entity (or a long run pushed back by reference inclusion) was consumed in O(n^2). The buffer now advances a head index, so front-consume is O(1), and re-injection reuses the space the consumed prefix freed, so a single large expansion is linear. An 800000-character internal text entity that previously took quadratic time now parses in about 0.6 s. Behavior, characters, and limits are unchanged; guarded by the standing suite. (A separate quadratic in deeply-nested entity references, where the content scanner re-reads the un-expanded remainder, is tracked as its own issue.)
- A complex type with simple content that restricts a base may no longer relax a base `required` attribute to `optional` (XSD 1.0 `cos-ct-derived-ok` / `derivation-ok-restriction.2`). The attribute-use restriction check ran only over `complexContent` restrictions, so a `simpleContent` restriction that relaxed a required attribute (or added a non-base attribute, or widened the attribute wildcard) was accepted; it now runs over both derivation roots. Closes XSTS `particlesZ030_d`, which libxml2 also rejects, so the fix aligns with the reference rather than exceeding it. XSTS invalid-schemas-accepted 10 -> 9 with valid-schemas-rejected held at 0 (full archive). Guarded by `SimpleContentAttrRestrictionTests`.
- `xpointer(string-range(...))` no longer uses a quadratic substring search. It matched the search string at every position of each node's value and re-allocated the whole needle-width window per position (O(text * needle), a denial-of-service vector on a long node value with a near-matching needle); it now uses a linear-time Knuth-Morris-Pratt search with the failure function built once. The 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), and the cost is now linear. Guarded by the new non-overlapping and backtracking cases in `XPointerRangeTests`.
- The XSLT `id()` function no longer scans the whole document ID index on every call. It filtered the entire index by `tokens.contains(...)` (O(index) per call), so a stylesheet calling `id()` across a wide document was quadratic; it now looks each requested token up directly (O(tokens)). The result is unchanged: the same elements in document order. A stylesheet resolving 4000 `id()` references against a 4000-entry index drops from about 0.34 s to about 0.03 s (~13x), and the cost is now linear. (`key()` already used direct lookups.)

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

@ -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
@ -72,9 +73,12 @@ are all shipped (summarized above). The beyond-parity libxml2 conformance audit
output encodings, streaming validation, the last encodings, the analysis
findings, and the first W3C-suite pass) are all **complete**. Feature parity is
done; the remaining distance is **production readiness for IDE use**, tracked under
epic **#167** (schema authority, differential oracle, located diagnostics). Conformance
suite maintenance (**#122**, closed) leaves **#130** (XSLT xalan burn-down) as the only
active runner work; see `docs/roadmap.md`.
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
@ -101,19 +105,19 @@ flowchart TB
BaseURI["#138 per-entity base URIs: complete"]:::done
Epic122["#122 conformance suite maintenance: complete (#130 survivor)"]:::epic
XSTS["#129 XSTS runner shipped; gates #145-#148 at 0/39/0/26"]:::review
XSLTTS["#130 XSLT 1.0 suite (xalan-test): ~126 baseline failures"]:::review
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["#167 production readiness for IDE use (v0.2.0)"]:::epic
Oracle171["#171 schema differential harness"]:::todo
Epic167["#3 production readiness for IDE use"]:::epic
Oracle171["schema differential harness (shipped)"]:::done
CrossDoc161["#161 cross-document composition"]:::todo
Gates145["#145-#148 XSTS gates: burn-down to 0"]:::review
Loc169["#169 located schema diagnostics"]:::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
@ -141,7 +145,7 @@ flowchart TB
## Status
**Current focus:** production readiness for IDE use ([#167](https://github.com/mihaelamj/PureXML/issues/167)). XSTS settled baselines (valid schemas rejected / invalid schemas accepted / valid instances rejected / invalid instances accepted), enforced in `Tests/XSTSSuiteTests.swift`: **0 / 39 / 0 / 26**. See [`docs/roadmap.md`](docs/roadmap.md) for milestones and open tasks.
**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
@ -220,11 +224,13 @@ beyond-parity audit epics (**#71**, its parser-breadth sub-epic **#74**) and the
parity-frontier epic (**#105**) are closed.
The remaining distance is **production readiness for IDE use**, tracked under epic
**#167**: driving the XSTS conformance buckets toward zero (currently
**0 / 39 / 0 / 26**), located schema diagnostics (#169), the schema differential
oracle (#171), and cross-document composition (#161). The one active runner
burn-down is **#130** (the XSLT 1.0 xalan suite). See
[`docs/roadmap.md`](docs/roadmap.md).
[#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

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

@ -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

@ -20,12 +20,16 @@ extension PureXML.Emitting {
while let marker = value.utf8[runStart...].firstIndex(where: {
textEscapableByte($0, asciiOnly: asciiOnly, escapeCarriageReturn: escapeCarriageReturn)
}) {
if runStart < marker { result += value[runStart ..< marker] }
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...] }
if runStart < value.endIndex {
result += value[runStart...]
}
return result
}
@ -55,8 +59,42 @@ extension PureXML.Emitting {
/// 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.
/// 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
@ -98,12 +136,16 @@ extension PureXML.Emitting {
while let marker = value.utf8[runStart...].firstIndex(where: {
attributeEscapableByte($0, quoteByte: quoteByte, asciiOnly: asciiOnly)
}) {
if runStart < marker { result += value[runStart ..< marker] }
if runStart < marker {
result += value[runStart ..< marker]
}
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...] }
if runStart < value.endIndex {
result += value[runStart...]
}
return result
}
@ -137,8 +179,33 @@ extension PureXML.Emitting {
/// 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.
/// 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
@ -155,15 +222,44 @@ extension PureXML.Emitting {
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.
if !value.utf8.contains(0x2D) { return value }
// 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
for character in value {
if character == "-", previousHyphen { result.append(" ") }
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 += " "
}
if previousHyphen { result.append(" ") }
return result
}
@ -172,14 +268,39 @@ extension PureXML.Emitting {
/// (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.
if !data.utf8.contains(0x3F) { return data }
// 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
for character in data {
if character == ">", previousQuestion { result.append(" ") }
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

@ -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

@ -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,51 +5,63 @@ 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):
"<!--\(PureXML.Emitting.Escaping.comment(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() {
@ -124,7 +136,9 @@ extension PureXML.HTML {
default: result += latin1Entity(scalar) ?? String(Character(scalar))
}
}
if previousWasAmpersand { result += "&amp;" }
if previousWasAmpersand {
result += "&amp;"
}
return result
}
@ -176,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

@ -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

@ -111,6 +111,35 @@ public extension PureXML.Model {
}
}
/// 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.
@ -165,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)
}

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(adopting: .document, children: children.map(PureXML.Model.TreeNode.init))
self.init(adopting: .document, children: Self.buildForest(children))
case let .element(element):
self.init(
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

@ -70,14 +70,21 @@ public extension PureXML.Model.TreeNode {
/// 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 {
switch kind {
case .text, .cdata:
value
case .element, .document, .entityReference:
children.reduce(into: "") { $0 += $1.textContent }
case .namespace, .comment, .processingInstruction, .doctype:
""
// 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

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

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

@ -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)
}
}

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
}
}
}
}
@ -153,16 +164,24 @@ public extension PureXML.Parsing {
// 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 }
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 lead == "<" { return try scanContentMarkup() }
if let text = try scanText() { return text }
if lead == "<" {
return try scanContentMarkup()
}
if let text = try scanText() {
return text
}
}
}
@ -192,97 +211,6 @@ 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
// 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(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(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

View File

@ -3,13 +3,20 @@
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()
// 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 '/'
if byte == 0x3E || byte == 0x2F {
return attributes
} // '>' or '/'
} else {
guard let next = reader.peek(), next != ">", next != "/" else {
return attributes
@ -23,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.
@ -51,11 +58,7 @@ 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: UInt8 = quote == "\"" ? 0x22 : 0x27
let (quoteByte, quoteCharacter) = try consumeAttributeValueOpenQuote()
var raw = ""
var length = 0
// Whether any '&' was seen: without one the value has no references, so
@ -72,7 +75,16 @@ extension PureXML.Parsing.EventReader {
raw += run
continue
}
guard let character = reader.peek(), character != quote else { break }
// 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 == "<" {
@ -81,18 +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 }
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

@ -31,9 +31,15 @@ extension PureXML.Parsing.EventReader {
/// 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() }
if reader.matches("</") {
return try scanEndTag()
}
if reader.matches("<?") {
return try scanContentProcessingInstruction()
}
if reader.matches("<!") {
return try scanContentDeclaration()
}
return try scanStartTag()
}
@ -46,7 +52,9 @@ extension PureXML.Parsing.EventReader {
recordEmptyElementContent("a comment")
return try scanComment()
}
if reader.matches("<![CDATA[") { return try scanCDATA() }
if reader.matches("<![CDATA[") {
return try scanCDATA()
}
if reader.matches("<!DOCTYPE") {
throw PureXML.Parsing.ParseError.unsupportedDoctype(reader.mark)
}

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
@ -136,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

@ -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

@ -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,11 +12,22 @@ extension PureXML.Parsing {
/// `String`.
struct Reader {
private var pull: () -> 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)`/`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
@ -56,7 +67,9 @@ extension PureXML.Parsing {
// 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 }
@ -106,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 {
@ -122,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}" {
@ -147,7 +164,7 @@ 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
@ -170,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
@ -185,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
@ -238,10 +264,18 @@ extension PureXML.Parsing {
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 }
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
@ -273,7 +307,9 @@ extension PureXML.Parsing {
}
mutating func skipSpace() {
if skipSpaceBytes() { return }
if skipSpaceBytes() {
return
}
while let character = peek(), character.isXMLWhitespace {
advance()
}
@ -309,7 +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)
}
}
}
}

View File

@ -22,19 +22,25 @@ extension PureXML.Parsing.Reader {
// 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 }
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 } // '<'
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 }
if index == byteIndex {
return nil
}
break
}
if byte == 0x0A {

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

@ -67,7 +67,9 @@ public extension PureXML.Parsing {
/// `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, // "."
@ -86,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

@ -30,7 +30,9 @@ extension PureXML.Regex {
next.insert(transition.target)
}
}
if next.isEmpty { return false }
if next.isEmpty {
return false
}
current = closure(next)
}
return current.contains(accept)
@ -104,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())
@ -127,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,7 +23,9 @@ 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)
}

View File

@ -20,7 +20,9 @@ extension PureXML.Regex {
}
static func parse(_ pattern: String) throws -> Node {
if pattern.isEmpty { return .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,7 +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 }
if let maximum, minimum > maximum {
throw RegexError.reversedQuantifier
}
advance()
return .repeated(atom, min: minimum, max: maximum)
}
@ -186,7 +192,9 @@ extension PureXML.Regex {
cls.negated = true
}
// A class must have at least one member; `[]` (or `[^]`) is invalid.
if peek() == "]" { throw RegexError.emptyClass }
if peek() == "]" {
throw RegexError.emptyClass
}
while let character = peek(), character != "]" {
if character == "-", peek(1) == "[" {
advance()
@ -236,13 +244,19 @@ extension PureXML.Regex {
// 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 }
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"))
}
@ -274,7 +288,9 @@ extension PureXML.Regex {
}
private mutating func advance() {
if index < chars.count { index += 1 }
if index < chars.count {
index += 1
}
}
}
}

View File

@ -81,8 +81,11 @@ extension PureXML.Regex {
case .word:
let code = XSDCategory.generalCategoryCode(scalar)
return !(code.hasPrefix("P") || code.hasPrefix("Z") || code.hasPrefix("C"))
case .nameStart: return PureXML.Parsing.XMLCharacter.isNameStart(scalar)
case .nameChar: return PureXML.Parsing.XMLCharacter.isNameChar(scalar)
// \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"
}
}
@ -118,7 +121,9 @@ extension PureXML.Regex {
// 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 }
if hit, subtraction.contains(where: { $0.matches(scalar) }) {
hit = false
}
return hit
}

View File

@ -20,6 +20,13 @@ extension PureXML.Regex {
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",
]
@ -40,7 +47,9 @@ extension PureXML.Regex {
]
static func generalCategoryCode(_ scalar: Unicode.Scalar) -> String {
if let override = categoryOverrides[scalar.value] { return override }
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,7 +92,9 @@ 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
@ -108,8 +110,12 @@ public extension PureXML.Schema {
}
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

@ -121,9 +121,15 @@ public extension PureXML.Schema {
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
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
@ -131,8 +137,12 @@ public extension PureXML.Schema {
}
private static func unionProcessContents(_ left: ProcessContents, _ right: ProcessContents) -> ProcessContents {
if left == .strict || right == .strict { return .strict }
if left == .lax || right == .lax { return .lax }
if left == .strict || right == .strict {
return .strict
}
if left == .lax || right == .lax {
return .lax
}
return .skip
}
@ -191,8 +201,12 @@ public extension PureXML.Schema {
// 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 }
if left == right {
return left
}
if left == .skip || right == .skip {
return .skip
}
return .lax
}
}
@ -224,6 +238,12 @@ public extension PureXML.Schema {
struct Particle: Sendable {
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 var minOccurs: Int {
get { occurrenceRange.minimum.clamped(to: Int.max) }
@ -235,14 +255,16 @@ public extension PureXML.Schema {
set { occurrenceRange.maximum = OccurrenceUpper(newValue) }
}
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term) {
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) {
public init(occurrenceRange: OccurrenceRange, term: Term, declaredReference: PureXML.Model.QualifiedName? = nil) {
self.occurrenceRange = occurrenceRange
self.term = term
self.declaredReference = declaredReference
}
}
@ -250,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
}
}
@ -274,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
}

View File

@ -23,9 +23,9 @@ 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]
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>
@ -79,8 +79,21 @@ public extension PureXML.Schema {
at path: [PureXML.Validation.PathKey] = [],
namespaceBindings inheritedBindings: [String: String] = [:],
) -> [PureXML.Validation.ValidationError] {
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: [XSDFailure] = []
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.
@ -90,68 +103,15 @@ public extension PureXML.Schema {
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 }
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, fixedValue: String? = nil, at path: XSDPath) -> [XSDFailure]? {
let name = element.name.localName
// cvc-elt.3.1: a non-nillable element must carry no `xsi:nil` attribute at
// all, whatever its value (so `xsi:nil="false"` is equally forbidden, not
// only `xsi:nil="true"`).
if Self.nilAttributeValue(element) != nil, !nillableElements.contains(name) {
return [XSDFailure(reason: "element '\(name)' is not nillable but has xsi:nil", at: path)]
}
guard Self.isNil(element) else { return nil }
if Self.hasContent(element) {
return [XSDFailure(reason: "nil element '\(name)' must be empty", at: path)]
}
// cvc-elt.3.2.2: a nilled element may not have a fixed {value constraint}.
// The matched particle's constraint is authoritative; the by-local-name map
// is a fallback for paths without a particle (e.g. the resolved-type entry).
if (fixedValue ?? elementConstraints[name]?.fixedValue) != nil {
return [XSDFailure(reason: "nil element '\(name)' must not have a fixed value constraint", at: path)]
}
return []
}
/// The text an element's simple content is validated against: its character
/// content, or, when the element is empty, its `default`/`fixed` value (an
/// empty element takes that value, and the value, not the empty string, must
/// satisfy the type). The matched particle's constraint takes precedence over
/// the by-local-name `elementConstraints` map, exactly as the tree path's
/// `validateChild` resolves it.
func effectiveSimpleText(for element: PureXML.Model.Element, particleConstraint: ValueConstraint? = nil) -> String {
let text = Self.rawTextContent(element)
let constraint = particleConstraint ?? elementConstraints[element.name.localName]
return text.isEmpty ? (constraint?.value ?? text) : text
}
/// The error from a `fixed` element value constraint: the element's text
/// must equal the fixed value in the element type's value space when known.
func elementFixedErrors(
_ element: PureXML.Model.Element,
valueType: SimpleType? = nil,
particleFixed: String? = nil,
at path: XSDPath,
) -> [XSDFailure] {
guard let fixed = particleFixed ?? elementConstraints[element.name.localName]?.fixedValue else { return [] }
// A fixed element's content is its fixed character value: it may not carry
// element children, even under a mixed content type (cvc-elt.5.2.2.2.1).
if !element.children.compactMap(\.element).isEmpty {
return [XSDFailure(reason: "fixed element '\(element.name.localName)' must not have element children", at: path)]
}
let text = Self.rawTextContent(element)
// An empty element takes the fixed value; only present content must equal it.
if text.isEmpty { return [] }
if let valueType, valueType.valueMatches(text, literal: fixed) { 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(
@ -159,7 +119,16 @@ public extension PureXML.Schema {
as type: ElementType,
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateTagged(element, as: type, at: path).map(\.error)
}
/// The tagged form of ``validate(_:as:at:)``. See ``validateTagged(_:against:at:namespaceBindings:)``.
func validateTagged(
_ element: PureXML.Model.Element,
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
@ -172,17 +141,21 @@ public extension PureXML.Schema {
_ content: ContentType,
at path: XSDPath,
namespaceBindings: [String: String],
into errors: inout [XSDFailure],
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)
@ -192,9 +165,9 @@ public extension PureXML.Schema {
}
}
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
@ -204,7 +177,7 @@ public extension PureXML.Schema {
children: [PureXML.Model.Element],
at path: XSDPath,
namespaceBindings: [String: String],
into errors: inout [XSDFailure],
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,
@ -218,7 +191,7 @@ public extension PureXML.Schema {
matched = allMatchedParticles(group, children: children)
} else {
sequenceStructureErrors(particle, children: children, at: path, into: &errors)
matched = PureXML.Schema.ContentNFABuilder.build(particle).matchedParticles(children.map(\.name))
matched = PureXML.Schema.ContentRTNMatcher(PureXML.Schema.ContentRTNBuilder.build(particle)).matchedParticles(children.map(\.name))
}
validateChildren(
children,
@ -232,7 +205,7 @@ public extension PureXML.Schema {
_ children: [PureXML.Model.Element],
matched: [PureXML.Schema.MatchedParticle?],
frame: XSDChildValidationFrame,
into errors: inout [XSDFailure],
into errors: inout [XSDFinding],
) {
let steps = Self.childSteps(children)
for (index, child) in children.enumerated() {
@ -254,60 +227,13 @@ public extension PureXML.Schema {
}
}
/// 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 globalElementDeclaration(for name: PureXML.Model.QualifiedName) -> PureXML.Schema.ElementType? {
// A global element declaration is always in the schema's target
// namespace, so an instance element matches one only by full qualified
// name. The namespaced key already covers a no-namespace global (its key
// is `element:{}local`); matching an unqualified instance against a
// namespaced global by bare local name would conflate namespaces, e.g.
// accepting unqualified text where a namespaced `xs:short` is declared.
types[PureXML.Schema.XSDParser.elementDeclarationKey(name)]
}
private func validateWildcardChild(
_ child: PureXML.Model.Element,
processContents: ProcessContents,
at path: XSDPath,
namespaceBindings: [String: String],
into errors: inout [XSDFailure],
) {
switch processContents {
case .skip:
return
case .lax:
if let declaration = globalElementDeclaration(for: child.name) {
validateChild(child, against: declaration, at: path, namespaceBindings: namespaceBindings, into: &errors)
}
case .strict:
if let declaration = globalElementDeclaration(for: child.name) {
validateChild(child, against: declaration, at: path, namespaceBindings: namespaceBindings, into: &errors)
} else if let type = xsiDeclaredType(for: child, namespaceBindings: namespaceBindings) {
validateChild(child, against: type, at: path, namespaceBindings: namespaceBindings, into: &errors)
} else {
errors.append(XSDFailure(reason: "no declaration for wildcard-matched element '\(child.name.localName)'", at: path))
}
}
}
/// The type named by a wildcard-matched element's `xsi:type`, when present.
private func xsiDeclaredType(for child: PureXML.Model.Element, namespaceBindings: [String: String]) -> ElementType? {
guard Self.xsiTypeAttributeValue(child) != nil else { return nil }
guard let reference = Self.xsiTypeReference(child, namespaceBindings: namespaceBindings),
let type = Self.resolveNamedType(reference, in: types)
else { return nil }
return type
}
private func validateChild(
func validateChild(
_ child: PureXML.Model.Element,
against declared: ElementType,
at path: XSDPath,
namespaceBindings: [String: String],
particleConstraint: ValueConstraint? = nil,
into errors: inout [XSDFailure],
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
@ -315,7 +241,7 @@ public extension PureXML.Schema {
// the declared type can forbid the substitution; an abstract declared
// type requires one.
if let xsiError = xsiTypeOverrideError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) {
errors.append(xsiError)
errors.append(XSDFinding(.xsiTypeSubstitutionValid, xsiError))
return
}
guard let declared = overriddenType(declared, for: child, namespaceBindings: namespaceBindings, at: path, into: &errors) else { return }
@ -328,26 +254,30 @@ 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, 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)
if let error = simple.validate(effectiveSimpleText(for: child, particleConstraint: particleConstraint)) {
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
errors.append(XSDFinding(.simpleTypeValueValid, reason: "'\(child.name.localName)': \(error)", at: path))
}
recordIDs(simple, value: text, 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, namespaceBindings: namespaceBindings))
errors.append(contentsOf: validateTagged(child, against: complex, at: path, namespaceBindings: namespaceBindings))
case .typeReference:
validateResolvedReference(declared, child, at: path, namespaceBindings: namespaceBindings, into: &errors)
}
@ -360,13 +290,13 @@ public extension PureXML.Schema {
_ child: PureXML.Model.Element,
at path: XSDPath,
namespaceBindings: [String: String],
into errors: inout [XSDFailure],
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, namespaceBindings: namespaceBindings, into: &errors)
}

View File

@ -3,21 +3,21 @@ extension PureXML.Schema.ComplexValidator {
_ element: PureXML.Model.Element,
_ type: PureXML.Schema.ComplexType,
at path: [PureXML.Validation.PathKey],
into errors: inout [PureXML.Validation.ValidationError],
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(PureXML.Validation.ValidationError(reason: "attribute '\(use.name.localName)': \(error)", at: path))
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(PureXML.Validation.ValidationError(reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
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(PureXML.Validation.ValidationError(reason: "missing required attribute '\(use.name.localName)'", at: path))
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
@ -28,7 +28,8 @@ extension PureXML.Schema.ComplexValidator {
}
validateWildcardAttributes(present, element: element, type: type, at: path, into: &errors)
if idTypedAttributeCount(present, type, on: element) > 1 {
errors.append(PureXML.Validation.ValidationError(
errors.append(XSDFinding(
.attributeSingleID,
reason: "an element may carry at most one attribute of type ID",
at: path,
))
@ -45,7 +46,9 @@ extension PureXML.Schema.ComplexValidator {
on element: PureXML.Model.Element,
) -> Int {
present.reduce(into: 0) { count, attribute in
if isIDTyped(attribute, type, on: element) { count += 1 }
if isIDTyped(attribute, type, on: element) {
count += 1
}
}
}
@ -73,7 +76,9 @@ extension PureXML.Schema.ComplexValidator {
use: PureXML.Schema.AttributeUse,
on elementName: PureXML.Model.QualifiedName,
) -> Bool {
if sameName(instance, declared) { return true }
if sameName(instance, declared) {
return true
}
guard use.chameleonUnprefixed,
instance.namespaceURI == nil || instance.namespaceURI?.isEmpty == true,
let declaredNamespace = declared.namespaceURI, !declaredNamespace.isEmpty
@ -86,7 +91,7 @@ extension PureXML.Schema.ComplexValidator {
element: PureXML.Model.Element,
type: PureXML.Schema.ComplexType,
at path: [PureXML.Validation.PathKey],
into errors: inout [PureXML.Validation.ValidationError],
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
@ -96,7 +101,7 @@ extension PureXML.Schema.ComplexValidator {
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(PureXML.Validation.ValidationError(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
errors.append(XSDFinding(.attributeDeclaredOrPermitted, reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
continue
}
switch wildcard.processContents {
@ -108,7 +113,8 @@ extension PureXML.Schema.ComplexValidator {
}
case .strict:
guard let use = globalAttributeUse(for: attribute.name) else {
errors.append(PureXML.Validation.ValidationError(
errors.append(XSDFinding(
.wildcardAttributeUndeclared,
reason: "no declaration for wildcard-matched attribute '\(attribute.name.localName)'",
at: path,
))
@ -127,13 +133,13 @@ extension PureXML.Schema.ComplexValidator {
_ attribute: PureXML.Model.Attribute,
use: PureXML.Schema.AttributeUse,
at path: [PureXML.Validation.PathKey],
into errors: inout [PureXML.Validation.ValidationError],
into errors: inout [XSDFinding],
) {
if let error = use.type.validate(attribute.value) {
errors.append(PureXML.Validation.ValidationError(reason: "attribute '\(attribute.name.localName)': \(error)", at: path))
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(PureXML.Validation.ValidationError(reason: "attribute '\(attribute.name.localName)' is fixed and must be '\(fixed)'", at: path))
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)
}

View File

@ -4,29 +4,33 @@
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)
func sequenceStructureErrors(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFinding]) {
let matcher = PureXML.Schema.ContentRTNMatcher(PureXML.Schema.ContentRTNBuilder.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).
let inputLength = children.count
var current = nfa.startStates(inputLength: inputLength)
var current = matcher.startStates(inputLength: inputLength)
for (index, child) in children.enumerated() {
guard let next = nfa.step(current, over: child.name, inputLength: inputLength) 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]]))
guard let next = matcher.step(current, over: child.name) else {
let allowed = matcher.admissible(from: current)
errors.append(XSDFinding(
.contentModelStructure,
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))
if !matcher.isComplete(current) {
errors.append(XSDFinding(.contentModelStructure, reason: "content is incomplete\(Self.expectation(matcher.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], groupOptional: Bool = false, at path: XSDPath, into errors: inout [XSDFailure]) {
func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], groupOptional: Bool = false, at path: XSDPath, into errors: inout [XSDFinding]) {
var counts = [Int](repeating: 0, count: group.particles.count)
let steps = Self.childSteps(children)
for (index, child) in children.enumerated() {
@ -35,7 +39,7 @@ extension PureXML.Schema.ComplexValidator {
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]]))
errors.append(XSDFinding(.contentModelStructure, reason: "element '\(child.name.localName)' is not allowed here", at: path + [steps[index]]))
continue
}
counts[position] += 1
@ -44,10 +48,12 @@ extension PureXML.Schema.ComplexValidator {
// absent (zero occurrences), so its required members are not expected; but
// once any child appears the group is present once and every member's own
// `minOccurs` applies. A required group (`minOccurs="1"`) always enforces.
if groupOptional, children.isEmpty { return }
if groupOptional, children.isEmpty {
return
}
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))
errors.append(XSDFinding(.contentModelStructure, reason: "element '\(name.localName)' is required but missing", at: path))
}
}
}
@ -92,7 +98,9 @@ extension PureXML.Schema.ComplexValidator {
/// 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)>" }
if case let .name(qualified) = label {
return "<\(qualified.localName)>"
}
return nil
}
let unique = Set(names).sorted()

View File

@ -14,12 +14,24 @@ extension PureXML.Schema.ComplexValidator {
at path: [PureXML.Validation.PathKey],
namespaceBindings: [String: String],
) -> PureXML.Validation.ValidationError? {
if let blocked = blockedSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) { return blocked }
if let notDerived = notDerivedSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) { return notDerived }
if let anyTypeSub = anyTypeForAnySimpleTypeError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) { return anyTypeSub }
if let listUnion = listUnionSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) { return listUnion }
if let abstractType = abstractXsiTypeError(child: child, at: path, namespaceBindings: namespaceBindings) { return abstractType }
if case let .typeReference(name) = declared { return abstractTypeError(named: name, child: child, at: path) }
if let blocked = blockedSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) {
return blocked
}
if let notDerived = notDerivedSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) {
return notDerived
}
if let anyTypeSub = anyTypeForAnySimpleTypeError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) {
return anyTypeSub
}
if let listUnion = listUnionSubstitutionError(declared: declared, child: child, at: path, namespaceBindings: namespaceBindings) {
return listUnion
}
if let abstractType = abstractXsiTypeError(child: child, at: path, namespaceBindings: namespaceBindings) {
return abstractType
}
if case let .typeReference(name) = declared {
return abstractTypeError(named: name, child: child, at: path)
}
return nil
}
@ -99,7 +111,9 @@ extension PureXML.Schema.ComplexValidator {
case .simple, .complex: declared
case let .typeReference(reference): resolvedDeclaredType(reference)
}
if case let .simple(type)? = resolved { return type.isAnySimpleType }
if case let .simple(type)? = resolved {
return type.isAnySimpleType
}
return false
}
@ -157,7 +171,9 @@ extension PureXML.Schema.ComplexValidator {
/// Whether an element type resolves to a complex type.
static func isComplexType(_ type: PureXML.Schema.ElementType) -> Bool {
if case .complex = type { return true }
if case .complex = type {
return true
}
return false
}
@ -237,13 +253,17 @@ extension PureXML.Schema.ComplexValidator {
for member in members {
// The substitute IS a member: derived with no method of its own, so
// this path is unblocked and the substitution is permitted outright.
if member == substitute { return false }
if member == substitute {
return false
}
guard let toMember = PureXML.Schema.XSDParser.derivationMethods(from: substitute, to: member, typeDerivation) else {
continue
}
anyConfirmedPath = true
// An unblocked path to any member makes the whole substitution valid.
if toMember.isDisjoint(with: blocked) { return false }
if toMember.isDisjoint(with: blocked) {
return false
}
}
// Every confirmed member path is blocked (and at least one exists): bar it.
return anyConfirmedPath
@ -325,8 +345,12 @@ extension PureXML.Schema.ComplexValidator {
/// namespaced backbone and the check stays silent rather than risk a false
/// positive.
static func derivationKey(fromReference reference: String) -> String {
if reference.hasPrefix("type:") { return String(reference.dropFirst("type:".count)) }
if reference.hasPrefix("{") || reference.contains(":") { return reference }
if reference.hasPrefix("type:") {
return String(reference.dropFirst("type:".count))
}
if reference.hasPrefix("{") || reference.contains(":") {
return reference
}
return key(PureXML.Model.QualifiedName(localName: reference, namespaceURI: nil))
}
}

View File

@ -14,32 +14,6 @@ extension PureXML.Schema.ComplexValidator {
PureXML.Validation.PathKey.steps(forChildNames: children.map(\.name.description))
}
/// Validates an `all` group order-independently: every child matches a
/// member within its occurrence bounds, and each member meets its minimum.
static func matchesAll(_ group: PureXML.Schema.Group, names: [PureXML.Model.QualifiedName]) -> Bool {
var counts = [Int](repeating: 0, count: group.particles.count)
for name in names {
guard let index = group.particles.indices.first(where: { position in
let member = group.particles[position]
let room = member.maxOccurs.map { counts[position] < $0 } ?? true
return room && label(of: member.term).matches(name)
}) else { return false }
counts[index] += 1
}
for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs {
return false
}
return true
}
fileprivate static func label(of term: XSDTerm) -> XSDTermLabel {
switch term {
case let .element(name, _, _, _, _, _): .name(name)
case let .wildcard(wildcard): .wildcard(wildcard)
case .group: .wildcard(XSDWildcard())
}
}
/// The `processContents` of a wildcard reachable in `term`, if the content
/// model contains one.
static func wildcard(in term: PureXML.Schema.Term) -> PureXML.Schema.ProcessContents? {
@ -48,7 +22,9 @@ extension PureXML.Schema.ComplexValidator {
return wildcard.processContents
case let .group(group):
for member in group.particles {
if let found = wildcard(in: member.term) { return found }
if let found = wildcard(in: member.term) {
return found
}
}
return nil
case .element:
@ -65,7 +41,9 @@ extension PureXML.Schema.ComplexValidator {
fileprivate static func collectTypes(_ term: XSDTerm, into result: inout [String: XSDElementType]) {
switch term {
case let .element(name, type, _, _, _, _):
if let type { result[key(name)] = type }
if let type {
result[key(name)] = type
}
case let .group(group):
for member in group.particles {
collectTypes(member.term, into: &result)
@ -88,7 +66,9 @@ extension PureXML.Schema.ComplexValidator {
fileprivate static func collectValueConstraints(_ term: XSDTerm, into result: inout [String: PureXML.Schema.ValueConstraint]) {
switch term {
case let .element(name, _, _, valueConstraint, _, _):
if let valueConstraint { result[key(name)] = valueConstraint }
if let valueConstraint {
result[key(name)] = valueConstraint
}
case let .group(group):
for member in group.particles {
collectValueConstraints(member.term, into: &result)
@ -137,17 +117,20 @@ extension PureXML.Schema.ComplexValidator {
return match?.value.trimmingXMLWhitespace()
}
/// Resolves an instance `xsi:type` attribute to a named type-table key.
/// Resolves an instance `xsi:type` attribute to a named type-table key. The
/// value is a QName resolved in the instance's namespace context: a prefix
/// binds through that prefix, an unprefixed name through the in-scope default
/// namespace. With no default namespace an unprefixed name is in no namespace,
/// so it keys against the no-namespace type, NOT a namespaced type of the same
/// local name (XSTS targetNS00101m: `xsi:type="Test"` with no default namespace
/// does not name `{targetNS}Test`).
static func xsiTypeReference(_ element: PureXML.Model.Element, namespaceBindings: [String: String]) -> String? {
guard let value = xsiTypeAttributeValue(element) else { return nil }
let local = value.split(separator: ":").last.map(String.init) ?? value
if value.contains(":"), let prefix = value.split(separator: ":", maxSplits: 1).first.map(String.init), let uri = namespaceBindings[prefix] {
return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: uri)
}
if let uri = namespaceBindings[""] {
return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: uri)
}
return local
return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: namespaceBindings[""])
}
/// The element's character content, trimmed: used to decide whether an
@ -223,7 +206,13 @@ extension PureXML.Schema.ComplexValidator {
/// Whether the element has any child element or non-whitespace text.
static func hasContent(_ element: PureXML.Model.Element) -> Bool {
!textContent(element).isEmpty || element.children.contains { if case .element = $0 { true } else { false } }
!textContent(element).isEmpty || element.children.contains {
if case .element = $0 {
true
} else {
false
}
}
}
}
@ -259,7 +248,9 @@ extension PureXML.Schema.ComplexValidator {
/// Resolves a named type reference, including built-in datatypes and ur-types.
static func resolveNamedType(_ name: String, in types: [String: PureXML.Schema.ElementType]) -> PureXML.Schema.ElementType? {
if let next = types[name] { return next }
if let next = types[name] {
return next
}
if name.hasPrefix("type:"), let (uri, local) = parseQualifiedTypeKey(name) {
if uri == PureXML.Schema.XSDParser.xsdNamespace, let builtin = urOrBuiltinType(named: local) {
return builtin

View File

@ -0,0 +1,62 @@
extension PureXML.Schema.ComplexValidator {
/// 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, fixedValue: String? = nil, at path: XSDPath) -> [XSDFinding]? {
let name = element.name.localName
// cvc-elt.3.1: a non-nillable element must carry no `xsi:nil` attribute at
// all, whatever its value (so `xsi:nil="false"` is equally forbidden, not
// only `xsi:nil="true"`).
if Self.nilAttributeValue(element) != nil, !nillableElements.contains(name) {
return [XSDFinding(.nilAttributeRequiresNillable, reason: "element '\(name)' is not nillable but has xsi:nil", at: path)]
}
guard Self.isNil(element) else { return nil }
if Self.hasContent(element) {
return [XSDFinding(.nilElementMustBeEmpty, reason: "nil element '\(name)' must be empty", at: path)]
}
// cvc-elt.3.2.2: a nilled element may not have a fixed {value constraint}.
// The matched particle's constraint is authoritative; the by-local-name map
// is a fallback for paths without a particle (e.g. the resolved-type entry).
if (fixedValue ?? elementConstraints[name]?.fixedValue) != nil {
return [XSDFinding(.nilElementNoFixedConstraint, reason: "nil element '\(name)' must not have a fixed value constraint", at: path)]
}
return []
}
/// The text an element's simple content is validated against: its character
/// content, or, when the element is empty, its `default`/`fixed` value (an
/// empty element takes that value, and the value, not the empty string, must
/// satisfy the type). The matched particle's constraint takes precedence over
/// the by-local-name `elementConstraints` map, exactly as the tree path's
/// `validateChild` resolves it.
func effectiveSimpleText(for element: PureXML.Model.Element, particleConstraint: PureXML.Schema.ValueConstraint? = nil) -> String {
let text = Self.rawTextContent(element)
let constraint = particleConstraint ?? elementConstraints[element.name.localName]
return text.isEmpty ? (constraint?.value ?? text) : text
}
/// The error from a `fixed` element value constraint: the element's text
/// must equal the fixed value in the element type's value space when known.
func elementFixedErrors(
_ element: PureXML.Model.Element,
valueType: PureXML.Schema.SimpleType? = nil,
particleFixed: String? = nil,
at path: XSDPath,
) -> [XSDFinding] {
guard let fixed = particleFixed ?? elementConstraints[element.name.localName]?.fixedValue else { return [] }
// A fixed element's content is its fixed character value: it may not carry
// element children, even under a mixed content type (cvc-elt.5.2.2.2.1).
if !element.children.compactMap(\.element).isEmpty {
return [XSDFinding(.fixedElementNoChildren, reason: "fixed element '\(element.name.localName)' must not have element children", at: path)]
}
let text = Self.rawTextContent(element)
// An empty element takes the fixed value; only present content must equal it.
if text.isEmpty {
return []
}
if let valueType, valueType.valueMatches(text, literal: fixed) {
return []
}
return text == fixed ? [] : [XSDFinding(.fixedElementValueMatches, reason: "element '\(element.name.localName)' is fixed and must be '\(fixed)'", at: path)]
}
}

View File

@ -17,6 +17,8 @@ public extension PureXML.Schema {
}
}
extension PureXML.Schema.ComplexValidator: PureXML.Validation.Validatable {}
public extension PureXML.Schema.ComplexValidator {
// MARK: Streaming support
@ -36,6 +38,19 @@ public extension PureXML.Schema.ComplexValidator {
}
}
/// The streaming path's document-scoped ID/IDREF check as a composable
/// ``PureXML/Validation`` value, reusing ``idErrors()`` (the same accumulator
/// the tree path's `idIdrefConsistent` rule reads) rather than the streaming
/// driver calling it imperatively outside the framework. The validator is its
/// own subject: by the time this runs (after the whole document has streamed
/// through ``shallowValidity``), its `idTracker` already holds every recorded
/// ID/IDREF.
static var idConsistency: PureXML.Validation.Validation<PureXML.Schema.ComplexValidator, Void> {
.init(description: "Every streamed xs:ID is unique and every streamed xs:IDREF resolves to one") { context in
context.subject.idErrors()
}
}
/// The matched particle's `default`/`fixed` value constraint for a child of
/// `parent`'s content model, keyed by namespaced identity (so same-local-name
/// elements in different types do not collide as in the flat `elementConstraints`).
@ -109,7 +124,9 @@ public extension PureXML.Schema.ComplexValidator {
guard case let .complex(complex) = parent else { return nil }
switch complex.content {
case let .elementOnly(particle), let .mixed(particle):
if Self.elementTypes(in: particle.term)[Self.key(child)] != nil { return nil }
if Self.elementTypes(in: particle.term)[Self.key(child)] != nil {
return nil
}
guard Self.wildcardMatch(for: child, in: particle.term) == .strict,
types[PureXML.Schema.XSDParser.elementDeclarationKey(child)] == nil
else { return nil }
@ -131,7 +148,9 @@ public extension PureXML.Schema.ComplexValidator {
return nil
case let .group(group):
for member in group.particles {
if let match = wildcardMatch(for: name, in: member.term) { return match }
if let match = wildcardMatch(for: name, in: member.term) {
return match
}
}
return nil
}
@ -160,28 +179,45 @@ public extension PureXML.Schema.ComplexValidator {
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
validateShallowTagged(element, as: type, valueConstraint: valueConstraint, at: path).map(\.error)
}
/// The tagged form of ``validateShallow(_:as:valueConstraint:at:)``. See
/// ``PureXML/Schema/ComplexValidator/validateTagged(_:against:at:namespaceBindings:)``.
private func validateShallowTagged(
_ element: PureXML.Model.Element,
as type: PureXML.Schema.ElementType,
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: [PureXML.Validation.PathKey] = [],
) -> [XSDFinding] {
var errors: [XSDFinding] = []
switch type {
case let .simple(simple):
validateSimpleElement(element, simple, valueConstraint: valueConstraint, at: path, into: &errors)
case let .complex(complex):
validateAttributes(element, complex, at: path, into: &errors)
if let nilErrors = nilErrors(element, at: path) { return errors + nilErrors }
if let nilErrors = nilErrors(element, at: path) {
return errors + nilErrors
}
shallowContent(element, complex.content, at: path, into: &errors)
// A simpleContent fixed value is compared in its simple type's value space
// (cvc-elt.5.2.2.1), matching the tree path's validate(_:against:).
let simpleContentType: PureXML.Schema.SimpleType? = if case let .simpleContent(simple) = complex.content { simple } else { nil }
let simpleContentType: PureXML.Schema.SimpleType? = if case let .simpleContent(simple) = complex.content {
simple
} else {
nil
}
errors += elementFixedErrors(element, valueType: simpleContentType, at: path)
case .typeReference:
// The shared resolver also guards a circular chain, which would
// otherwise recurse here without terminating.
switch resolveReference(type) {
case let .unknown(name):
return [XSDFailure(reason: "unknown type '\(name)'", at: path)]
return [XSDFinding(.typeReferenceResolves, reason: "unknown type '\(name)'", at: path)]
case let .circular(name):
return [XSDFailure(reason: "circular type reference '\(name)'", at: path)]
return [XSDFinding(.typeReferenceResolves, reason: "circular type reference '\(name)'", at: path)]
case let .resolved(resolved):
return validateShallow(element, as: resolved, valueConstraint: valueConstraint, at: path)
return validateShallowTagged(element, as: resolved, valueConstraint: valueConstraint, at: path)
}
}
return errors
@ -192,21 +228,21 @@ public extension PureXML.Schema.ComplexValidator {
_ simple: PureXML.Schema.SimpleType,
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: XSDPath,
into errors: inout [XSDFailure],
into errors: inout [XSDFinding],
) {
if let nilErrors = nilErrors(element, fixedValue: valueConstraint?.fixedValue, at: path) {
errors += nilErrors
return
}
if !element.children.compactMap(\.element).isEmpty {
errors.append(XSDFailure(reason: "'\(element.name.localName)' must not have children", at: path))
errors.append(XSDFinding(.simpleTypeForbidsChildren, reason: "'\(element.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, matching the tree
// path's validateChild.
let text = Self.rawTextContent(element)
if let error = simple.validate(effectiveSimpleText(for: element, particleConstraint: valueConstraint)) {
errors.append(XSDFailure(reason: "'\(element.name.localName)': \(error)", at: path))
errors.append(XSDFinding(.simpleTypeValueValid, reason: "'\(element.name.localName)': \(error)", at: path))
}
recordIDs(simple, value: text, at: path)
// Compare a present fixed value in the type's value space (e.g. "05" == "5"
@ -215,17 +251,21 @@ public extension PureXML.Schema.ComplexValidator {
errors += elementFixedErrors(element, valueType: simple, particleFixed: valueConstraint?.fixedValue, at: path)
}
private func shallowContent(_ element: PureXML.Model.Element, _ content: PureXML.Schema.ContentType, at path: XSDPath, into errors: inout [XSDFailure]) {
private func shallowContent(_ element: PureXML.Model.Element, _ content: PureXML.Schema.ContentType, at path: XSDPath, 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))
}
let text = Self.rawTextContent(element)
if let error = type.validate(text) {
errors.append(XSDFailure(reason: "content: \(error)", at: path))
errors.append(XSDFinding(.simpleContentTypeValid, reason: "content: \(error)", at: path))
}
recordIDs(type, value: text, at: path)
case let .elementOnly(particle):
@ -236,7 +276,7 @@ public extension PureXML.Schema.ComplexValidator {
}
}
private func shallowStructure(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) {
private func shallowStructure(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFinding]) {
if case let .group(group) = particle.term, group.compositor == .all {
allStructureErrors(group, children: children, groupOptional: particle.occurrenceRange.minimum.isZero, at: path, into: &errors)
} else {

View File

@ -0,0 +1,48 @@
extension PureXML.Schema.ComplexValidator {
/// A global element declaration is always in the schema's target
/// namespace, so an instance element matches one only by full qualified
/// name. The namespaced key already covers a no-namespace global (its key
/// is `element:{}local`); matching an unqualified instance against a
/// namespaced global by bare local name would conflate namespaces, e.g.
/// accepting unqualified text where a namespaced `xs:short` is declared.
func globalElementDeclaration(for name: PureXML.Model.QualifiedName) -> PureXML.Schema.ElementType? {
types[PureXML.Schema.XSDParser.elementDeclarationKey(name)]
}
/// Validates a wildcard-matched child by its `processContents`: skip does
/// nothing, lax validates against a global declaration when one exists, and
/// strict requires one.
func validateWildcardChild(
_ child: PureXML.Model.Element,
processContents: PureXML.Schema.ProcessContents,
at path: XSDPath,
namespaceBindings: [String: String],
into errors: inout [XSDFinding],
) {
switch processContents {
case .skip:
return
case .lax:
if let declaration = globalElementDeclaration(for: child.name) {
validateChild(child, against: declaration, at: path, namespaceBindings: namespaceBindings, into: &errors)
}
case .strict:
if let declaration = globalElementDeclaration(for: child.name) {
validateChild(child, against: declaration, at: path, namespaceBindings: namespaceBindings, into: &errors)
} else if let type = xsiDeclaredType(for: child, namespaceBindings: namespaceBindings) {
validateChild(child, against: type, at: path, namespaceBindings: namespaceBindings, into: &errors)
} else {
errors.append(XSDFinding(.wildcardElementUndeclared, reason: "no declaration for wildcard-matched element '\(child.name.localName)'", at: path))
}
}
}
/// The type named by a wildcard-matched element's `xsi:type`, when present.
func xsiDeclaredType(for child: PureXML.Model.Element, namespaceBindings: [String: String]) -> PureXML.Schema.ElementType? {
guard Self.xsiTypeAttributeValue(child) != nil else { return nil }
guard let reference = Self.xsiTypeReference(child, namespaceBindings: namespaceBindings),
let type = Self.resolveNamedType(reference, in: types)
else { return nil }
return type
}
}

View File

@ -7,20 +7,26 @@ extension PureXML.Schema.ComplexValidator {
for child: PureXML.Model.Element,
namespaceBindings: [String: String] = [:],
at path: XSDPath,
into errors: inout [XSDFailure],
into errors: inout [XSDFinding],
) -> PureXML.Schema.ElementType? {
guard Self.xsiTypeAttributeValue(child) != nil else { return declared }
let reference = Self.xsiTypeReference(child, namespaceBindings: namespaceBindings) ?? Self.xsiTypeName(child) ?? ""
if let resolved = Self.resolveNamedType(reference, in: types) { return resolved }
if let resolved = Self.resolveNamedType(reference, in: types) {
return resolved
}
let overriding = Self.xsiTypeName(child) ?? reference
if let builtin = PureXML.Schema.BuiltinType(rawValue: overriding) {
if xsiBuiltinSubstitutes(builtin, for: declared) {
return .simple(PureXML.Schema.SimpleType(base: builtin))
}
errors.append(XSDFailure(reason: "xsi:type '\(overriding)' is not validly derived from the declared type of '\(child.name.localName)'", at: path))
errors.append(XSDFinding(
.xsiTypeOverrideResolves,
reason: "xsi:type '\(overriding)' is not validly derived from the declared type of '\(child.name.localName)'",
at: path,
))
return nil
}
errors.append(XSDFailure(reason: "unknown xsi:type '\(overriding)' on '\(child.name.localName)'", at: path))
errors.append(XSDFinding(.xsiTypeOverrideResolves, reason: "unknown xsi:type '\(overriding)' on '\(child.name.localName)'", at: path))
return nil
}
@ -32,7 +38,9 @@ extension PureXML.Schema.ComplexValidator {
private func xsiBuiltinSubstitutes(_ xsi: PureXML.Schema.BuiltinType, for declared: PureXML.Schema.ElementType) -> Bool {
switch declared {
case let .typeReference(name):
if name == "anyType" || name == "anySimpleType" || name.hasSuffix("}anyType") || name.hasSuffix("}anySimpleType") { return true }
if name == "anyType" || name == "anySimpleType" || name.hasSuffix("}anyType") || name.hasSuffix("}anySimpleType") {
return true
}
switch resolveReference(declared) {
case let .resolved(resolved): return xsiBuiltinSubstitutes(xsi, for: resolved)
case .unknown, .circular: return false

View File

@ -140,7 +140,9 @@ extension PureXML.Schema {
func admissible(from current: Set<ContentConfiguration>) -> [TermLabel] {
var labels: [TermLabel] = []
for configuration in current where states[configuration.state].target != nil {
if let label = states[configuration.state].label { labels.append(label) }
if let label = states[configuration.state].label {
labels.append(label)
}
}
return labels
}
@ -203,7 +205,9 @@ extension PureXML.Schema {
next.insert(ContentConfiguration(state: target, counters: configuration.counters))
}
}
if next.isEmpty { return nil }
if next.isEmpty {
return nil
}
return closure(next, caps: counterCaps(for: inputLength))
}

View File

@ -63,8 +63,12 @@ public extension PureXML.Schema {
/// foreign annotation content.
private static func forEachComplexType(_ node: XSDTree, _ visit: (XSDTree) -> Void) {
let local = PureXML.Schema.XSDNode.localName(node)
if local == "appinfo" || local == "documentation" { return }
if local == "complexType" { visit(node) }
if local == "appinfo" || local == "documentation" {
return
}
if local == "complexType" {
visit(node)
}
for child in PureXML.Schema.XSDNode.elementChildren(node) {
forEachComplexType(child, visit)
}

View File

@ -1,7 +1,8 @@
/// One distinct particle occurrence in a content model: its source-node identity
/// and label. A particle never conflicts with a repetition of itself, so identity,
/// not label alone, decides conflict (per the UPA semantics). File-private.
private struct CompItem: Hashable {
/// not label alone, decides conflict (per the UPA semantics). Module-internal so the
/// conflict tests (``CompConflict``) can live in their own file.
struct CompItem: Hashable {
let particle: ObjectIdentifier
let label: PureXML.Schema.TermLabel
@ -43,7 +44,20 @@ extension PureXML.Schema {
/// sequence (a complexContent extension's effective content), or nil.
static func violation(_ models: [XSDTree], _ bindings: [String: String], _ context: XSDContext) -> String? {
var walk = CompWalk(bindings: bindings, context: context)
let summary = walk.sequence(models.map { walk.summarize($0, visiting: []) })
// Dedupe the effective-model nodes by identity. A redefine self-extension
// (`<extension base="T">` inside the redefinition of `T`) resolves its base to
// the redefinition itself, so `effectiveModelNodes` lists this type's own
// content twice: an assembly artifact, not two real occurrences. Counting the
// same node once keeps the genuine two-reference detection (a group referenced
// twice WITHIN a model) while not inventing a conflict from the duplication
// (addB094, schZ007).
var seenModel: Set<ObjectIdentifier> = []
let uniqueModels = models.filter { seenModel.insert(ObjectIdentifier($0)).inserted }
// Summarize every reachable group up front, in dependency order, so the walk
// below never recurses through a `<group ref>` chain (which is flat in the
// document and so unbounded by `maxDepth`); see `precomputeGroupSummaries`.
walk.precomputeGroupSummaries(uniqueModels)
let summary = walk.sequence(uniqueModels.map { walk.summarize($0, visiting: []) })
// The root's entry decision set, plus the followpos of each item that can
// end the whole model (nothing follows the root, so its followpos is just
// its internal follow).
@ -51,8 +65,13 @@ extension PureXML.Schema {
for entry in summary.lastFollow {
walk.emit(entry.follow)
}
// A same-particle collision (two references to one group) found during the
// walk, then the distinct-identity, same-label overlaps.
if let conflict = walk.identityConflict {
return "ambiguous content model: '\(conflict)' can be matched by more than one particle"
}
for set in walk.decisionSets {
if let conflict = CompWalk.overlap(set) {
if let conflict = CompConflict.overlap(set) {
return "ambiguous content model: '\(conflict)' can be matched by more than one particle"
}
}
@ -68,26 +87,112 @@ private struct CompWalk {
let context: PureXML.Schema.XSDContext
var decisionSets: [[CompItem]] = []
var groupCache: [String: CompSummary] = [:]
/// A conflict label found because the SAME particle reaches one decision point from
/// two distinct contributions. A `<xs:group ref>` is summarized once and its items
/// reuse the group's node identity across references, so two references to the same
/// group (the `(g?, g?)` / `(g | g)` overlap) would otherwise be invisible to the
/// distinct-identity `overlap` check, even though XSD expands each reference into a
/// separate occurrence. Detecting the collision here both reports that conflict and,
/// because the merge dedupes, keeps a multiply-referenced nullable group's first and
/// follow sets `O(labels)` instead of `2^K`.
var identityConflict: String?
mutating func emit(_ set: [CompItem]) {
if set.count > 1 { decisionSets.append(set) }
if set.count > 1 {
decisionSets.append(set)
}
}
/// Dedupe a decision set (first set or follow set) by particle identity. A repeated
/// identity means the same particle reaches this point from two contributions, which
/// XSD treats as two expanded occurrences: record it as a conflict. The deduped set
/// is still handed to `overlap` for the distinct-identity, same-label case.
mutating func merge(_ lists: [CompItem]...) -> [CompItem] {
mergeList(lists)
}
mutating func mergeList(_ lists: [[CompItem]]) -> [CompItem] {
var seen: Set<ObjectIdentifier> = []
var result: [CompItem] = []
for item in lists.joined() {
if seen.insert(item.particle).inserted {
result.append(item)
} else if identityConflict == nil {
identityConflict = labelName(item.label)
}
}
return result
}
/// Dedupe by particle identity WITHOUT flagging a conflict, for combinations where a
/// repeated identity is one particle looping back on itself, not two occurrences: a
/// repetition's loop edge (`a*`, and nesting like `(a*)*`) folds the body's first
/// into its own follow, which is legitimate. Only combinations of DISTINCT child
/// particles (first sets, sequence gaps, `all` cross-follows) use the flagging merge.
func dedupOnly(_ lists: [[CompItem]]) -> [CompItem] {
var seen: Set<ObjectIdentifier> = []
var result: [CompItem] = []
for item in lists.joined() where seen.insert(item.particle).inserted {
result.append(item)
}
return result
}
/// Dedupe `lastFollow` end-items by particle, unioning their follow sets, so a
/// multiply-referenced nullable group does not double the carried follow list at
/// every level. End-item identity collision is not itself a conflict (the overlap of
/// the two references is decided where they START, in a merged first set), so this
/// does not flag `identityConflict`.
mutating func mergeLastFollow(_ entries: [(item: CompItem, follow: [CompItem])]) -> [(item: CompItem, follow: [CompItem])] {
var indexByParticle: [ObjectIdentifier: Int] = [:]
var result: [(item: CompItem, follow: [CompItem])] = []
for entry in entries {
if let index = indexByParticle[entry.item.particle] {
result[index].follow = dedupOnly([result[index].follow, entry.follow])
} else {
indexByParticle[entry.item.particle] = result.count
result.append((entry.item, dedupOnly([entry.follow])))
}
}
return result
}
private func labelName(_ label: PureXML.Schema.TermLabel) -> String {
if case let .name(qname) = label {
return qname.localName
}
return "any"
}
mutating func summarize(_ node: PureXML.Model.TreeNode, visiting: Set<String>) -> CompSummary {
let (minimum, maximum) = PureXML.Schema.XSDNode.occurrence(node)
if let maximum, maximum <= 0 { return CompSummary(nullable: true, first: [], lastFollow: []) }
var body = bodySummary(node, visiting: visiting)
// A repeat edge (last -> first) is added for a true repetition (max unbounded or >= 2) with min < max. A
// fixed `{n,n}` clamps to copies sharing the body's particle identities (no new
// conflict); a `{0,1}` is optional, not a repetition. The loop folds the body's
// first into every end-item's follow, so a later gap (or the root) checks the
// combined loop+continuation decision set.
if minimum != maximum, maximum == nil || (maximum ?? 0) > 1 {
body.lastFollow = body.lastFollow.map { (item: $0.item, follow: $0.follow + body.first) }
if let maximum, maximum <= 0 {
return CompSummary(nullable: true, first: [], lastFollow: [])
}
var body = bodySummary(node, visiting: visiting)
// A repeat edge folds the body's first into every end-item's follow. Kept in a
// separate method, not inline: `summarize` recurses on nesting depth, so any
// locals it holds multiply the per-level stack frame, and a deep group chain
// already overflows a small (WASI) stack. The repeat-edge work runs as the
// recursion unwinds, so its frame is not on the deepest descent path.
body.lastFollow = withRepeatEdge(body, minimum: minimum, maximum: maximum)
return CompSummary(nullable: minimum == 0 || body.nullable, first: body.first, lastFollow: body.lastFollow)
}
/// Adds the repetition loop edge (`last -> first`) for a true repetition (max
/// unbounded or `>= 2`, with `min < max`). A fixed `{n,n}` clamps to copies sharing
/// the body's particle identities (no new conflict); `{0,1}` is optional, not a
/// repetition. The loop fold uses the non-flagging dedupe: a single particle looping
/// is not two occurrences.
private mutating func withRepeatEdge(_ body: CompSummary, minimum: Int, maximum: Int?) -> [(item: CompItem, follow: [CompItem])] {
guard minimum != maximum, maximum == nil || (maximum ?? 0) > 1 else { return body.lastFollow }
var looped: [(item: CompItem, follow: [CompItem])] = []
for entry in body.lastFollow {
looped.append((entry.item, dedupOnly([entry.follow, body.first])))
}
return looped
}
private mutating func bodySummary(_ node: PureXML.Model.TreeNode, visiting: Set<String>) -> CompSummary {
switch PureXML.Schema.XSDNode.localName(node) {
case "element":
@ -121,37 +226,57 @@ private struct CompWalk {
/// sequence). The sequence's own `lastFollow` is its trailing nullable suffix's
/// end-items, carried up for the parent to combine with whatever follows.
mutating func sequence(_ parts: [CompSummary]) -> CompSummary {
// `suffixFirst[index]` is the first of the nullable run starting after `index`,
// i.e. what can follow an end-item of `parts[index]` WITHIN this sequence.
var suffixFirst: [[CompItem]] = Array(repeating: [], count: parts.count)
for index in parts.indices {
var suffixFirst: [CompItem] = []
var suffix: [CompItem] = []
for next in parts[(index + 1)...] {
suffixFirst.append(contentsOf: next.first)
if !next.nullable { break }
suffix.append(contentsOf: next.first)
if !next.nullable {
break
}
}
guard !suffixFirst.isEmpty else { continue }
suffixFirst[index] = suffix
}
for index in parts.indices where !suffixFirst[index].isEmpty {
for entry in parts[index].lastFollow {
emit(entry.follow + suffixFirst)
emit(merge(entry.follow, suffixFirst[index]))
}
}
var first: [CompItem] = []
var prefixFirsts: [[CompItem]] = []
for part in parts {
first.append(contentsOf: part.first)
if !part.nullable { break }
prefixFirsts.append(part.first)
if !part.nullable {
break
}
}
var lastFollow: [(item: CompItem, follow: [CompItem])] = []
for part in parts.reversed() {
lastFollow.append(contentsOf: part.lastFollow)
if !part.nullable { break }
// Each trailing end-item carries its internal follow PLUS the suffix that follows
// it inside the sequence. Otherwise an end-item that is also followed internally
// (an optional element after it) loses that continuation in `lastFollow`, so a
// parent gap, or an enclosing repetition's loop edge, forms only a partial
// followpos: a repeating `(c, b?, c?)` would not see that after `b` both the
// trailing `c` and the looped leading `c` are reachable. The gap loop already
// flagged the internal followpos, so dedupe without re-flagging here.
var trailing: [(item: CompItem, follow: [CompItem])] = []
for index in parts.indices.reversed() {
for entry in parts[index].lastFollow {
trailing.append((entry.item, dedupOnly([entry.follow, suffixFirst[index]])))
}
if !parts[index].nullable {
break
}
}
return CompSummary(nullable: parts.allSatisfy(\.nullable), first: first, lastFollow: lastFollow)
return CompSummary(nullable: parts.allSatisfy(\.nullable), first: mergeList(prefixFirsts), lastFollow: mergeLastFollow(trailing))
}
private mutating func choice(_ parts: [CompSummary]) -> CompSummary {
let first = parts.flatMap(\.first)
let first = mergeList(parts.map(\.first))
emit(first)
return CompSummary(
nullable: parts.isEmpty || parts.contains(where: \.nullable),
first: first,
lastFollow: parts.flatMap(\.lastFollow),
lastFollow: mergeLastFollow(parts.flatMap(\.lastFollow)),
)
}
@ -161,27 +286,31 @@ private struct CompWalk {
/// follow is recorded in `followlast` so a parent gap combines it with the suffix
/// (mirrors `ContentModelDeterminism.all`).
private mutating func all(_ parts: [CompSummary]) -> CompSummary {
let first = parts.flatMap(\.first)
let first = mergeList(parts.map(\.first))
emit(first)
var lastFollow: [(item: CompItem, follow: [CompItem])] = []
for (index, part) in parts.enumerated() {
let others = parts.enumerated().filter { $0.offset != index }.flatMap(\.element.first)
for entry in part.lastFollow {
lastFollow.append((entry.item, entry.follow + others))
lastFollow.append((entry.item, merge(entry.follow, others)))
}
}
return CompSummary(
nullable: parts.isEmpty || parts.allSatisfy(\.nullable),
first: first,
lastFollow: lastFollow,
lastFollow: mergeLastFollow(lastFollow),
)
}
private mutating func group(_ node: PureXML.Model.TreeNode, visiting: Set<String>) -> CompSummary {
guard let ref = PureXML.Schema.XSDNode.attribute(node, "ref") else { return CompSummary(nullable: true, first: [], lastFollow: []) }
let name = PureXML.Schema.XSDNode.stripPrefix(ref)
if visiting.contains(name) { return redefineSelfReference(name, visiting: visiting) }
if let cached = groupCache[name] { return cached }
if visiting.contains(name) {
return redefineSelfReference(name, visiting: visiting)
}
if let cached = groupCache[name] {
return cached
}
guard let definition = context.groups[name], let model = modelChild(definition)
else { return CompSummary(nullable: true, first: [], lastFollow: []) }
// Summarize the group once: its internal decision sets are context-independent
@ -218,31 +347,6 @@ private struct CompWalk {
.first { ["sequence", "choice", "all"].contains(PureXML.Schema.XSDNode.localName($0) ?? "") }
}
// MARK: Conflict
static func overlap(_ set: [CompItem]) -> String? {
var nameOwner: [PureXML.Model.QualifiedName: ObjectIdentifier] = [:]
var wildcards: [(card: PureXML.Schema.Wildcard, particle: ObjectIdentifier)] = []
for item in set {
switch item.label {
case let .name(qname):
if let owner = nameOwner[qname], owner != item.particle { return qname.localName }
if nameOwner[qname] == nil { nameOwner[qname] = item.particle }
case let .wildcard(card):
wildcards.append((card, item.particle))
}
}
for (index, wildcard) in wildcards.enumerated() {
// A wildcard conflicts with any name it admits, from a distinct particle,
// and with any other wildcard it intersects, from a distinct particle.
if nameOwner.contains(where: { wildcard.card.admits($0.key) && $0.value != wildcard.particle }) { return "any" }
for other in wildcards[(index + 1)...] where other.particle != wildcard.particle && wildcardsOverlap(wildcard.card, other.card) {
return "any"
}
}
return nil
}
// MARK: Helpers (mirror ContentModelDeterminism)
private func elementQName(_ node: PureXML.Model.TreeNode) -> PureXML.Model.QualifiedName {
@ -263,20 +367,23 @@ private struct CompWalk {
return PureXML.Model.QualifiedName(localName: qname, namespaceURI: bindings[""])
}
static func wildcardsOverlap(_ lhs: PureXML.Schema.Wildcard, _ rhs: PureXML.Schema.Wildcard) -> Bool {
// Two wildcards overlap when some namespace name is admitted by both. Any
// two `not` forms always share infinitely many named namespaces; a `not`
// form overlaps a set when the set holds a named namespace it admits.
switch (lhs.namespace, rhs.namespace) {
case (.any, _), (_, .any): true
case let (.enumerated(lhsSet), .enumerated(rhsSet)): !lhsSet.isDisjoint(with: rhsSet)
case let (.enumerated(set), .notNamespace(name)), let (.notNamespace(name), .enumerated(set)):
set.contains { !$0.isEmpty && $0 != name }
case let (.enumerated(set), .notAbsent), let (.notAbsent, .enumerated(set)):
set.contains { !$0.isEmpty }
case (.notNamespace, .notNamespace), (.notNamespace, .notAbsent),
(.notAbsent, .notNamespace), (.notAbsent, .notAbsent):
true
// MARK: Group summaries (depth-safe precomputation)
/// Summarizes every group reachable from `models`, in dependency order, into
/// `groupCache`, so the main walk's `group(_:)` is always a cache hit and
/// `summarize` never recurses through a `<group ref>` chain. A group reference
/// chain is FLAT in the schema document (the definitions are siblings, not
/// nested), so it is NOT bounded by the parser's `maxDepth`; summarizing lazily
/// through references therefore recursed on the chain length and overflowed the
/// stack on a long (hostile but legal) chain. Each group's own model nesting is
/// document structure, so it stays `maxDepth`-bounded. The cached summary is the
/// SAME object the lazy walk would have produced (a group summary is
/// context-independent), so this only reorders the work; the decision sets it
/// emits are identical as a set, and the verdict does not depend on their order.
mutating func precomputeGroupSummaries(_ models: [PureXML.Model.TreeNode]) {
for name in GroupDependencyOrder.reachable(from: models, context: context) where groupCache[name] == nil {
guard let definition = context.groups[name], let model = modelChild(definition) else { continue }
groupCache[name] = summarize(model, visiting: [name])
}
}
}

View File

@ -0,0 +1,53 @@
/// The overlap tests for a compositional-determinism decision set: a UPA violation
/// is two DISTINCT particles in one decision set that can match the same element
/// (same element name, or a wildcard admitting the other's name/namespace). Pure
/// functions over ``CompItem`` sets, factored out of ``CompWalk`` so the walk file
/// stays within its length budget; the walk accumulates decision sets and hands
/// each to ``overlap(_:)``.
enum CompConflict {
static func overlap(_ set: [CompItem]) -> String? {
var nameOwner: [PureXML.Model.QualifiedName: ObjectIdentifier] = [:]
var wildcards: [(card: PureXML.Schema.Wildcard, particle: ObjectIdentifier)] = []
for item in set {
switch item.label {
case let .name(qname):
if let owner = nameOwner[qname], owner != item.particle {
return qname.localName
}
if nameOwner[qname] == nil {
nameOwner[qname] = item.particle
}
case let .wildcard(card):
wildcards.append((card, item.particle))
}
}
for (index, wildcard) in wildcards.enumerated() {
// A wildcard conflicts with any name it admits, from a distinct particle,
// and with any other wildcard it intersects, from a distinct particle.
if nameOwner.contains(where: { wildcard.card.admits($0.key) && $0.value != wildcard.particle }) {
return "any"
}
for other in wildcards[(index + 1)...] where other.particle != wildcard.particle && wildcardsOverlap(wildcard.card, other.card) {
return "any"
}
}
return nil
}
static func wildcardsOverlap(_ lhs: PureXML.Schema.Wildcard, _ rhs: PureXML.Schema.Wildcard) -> Bool {
// Two wildcards overlap when some namespace name is admitted by both. Any
// two `not` forms always share infinitely many named namespaces; a `not`
// form overlaps a set when the set holds a named namespace it admits.
switch (lhs.namespace, rhs.namespace) {
case (.any, _), (_, .any): true
case let (.enumerated(lhsSet), .enumerated(rhsSet)): !lhsSet.isDisjoint(with: rhsSet)
case let (.enumerated(set), .notNamespace(name)), let (.notNamespace(name), .enumerated(set)):
set.contains { !$0.isEmpty && $0 != name }
case let (.enumerated(set), .notAbsent), let (.notAbsent, .enumerated(set)):
set.contains { !$0.isEmpty }
case (.notNamespace, .notNamespace), (.notNamespace, .notAbsent),
(.notAbsent, .notNamespace), (.notAbsent, .notAbsent):
true
}
}
}

View File

@ -3,10 +3,14 @@ extension PureXML.Schema.ContentModelDeterminism {
/// complex type's content model, reached through `complexContent` and its
/// `restriction`/`extension`, or nil for simple or empty content.
static func contentModelNode(_ complexType: XSDTree) -> XSDTree? {
if let direct = modelGroupChild(complexType) { return direct }
if let direct = modelGroupChild(complexType) {
return direct
}
guard let complexContent = PureXML.Schema.XSDNode.firstChild(complexType, named: "complexContent") else { return nil }
for derivation in PureXML.Schema.XSDNode.elementChildren(complexContent) {
if let model = modelGroupChild(derivation) { return model }
if let model = modelGroupChild(derivation) {
return model
}
}
return nil
}

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