Compare commits
1 Commits
main
...
chore/adop
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ab7adfd0a |
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and ideas
|
||||
url: https://git.aleahim.com/NodetteHQ/PureXML/issues
|
||||
about: Ask questions, float ideas, or discuss design in an issue.
|
||||
url: https://github.com/mihaelamj/PureXML/discussions
|
||||
about: Ask questions, float ideas, or discuss design in Discussions.
|
||||
- name: Security issue
|
||||
url: https://git.aleahim.com/NodetteHQ/PureXML/src/branch/main/SECURITY.md
|
||||
url: https://github.com/mihaelamj/PureXML/blob/main/SECURITY.md
|
||||
about: Do not file security issues publicly. Follow the security policy instead.
|
||||
|
||||
7
.github/workflows/swift-windows.yml
vendored
7
.github/workflows/swift-windows.yml
vendored
@ -1,12 +1,9 @@
|
||||
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:
|
||||
tags: ['v*']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
27
.github/workflows/swift-xsts.yml
vendored
27
.github/workflows/swift-xsts.yml
vendored
@ -1,27 +0,0 @@
|
||||
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 +0,0 @@
|
||||
2.14.0
|
||||
267
CHANGELOG.md
267
CHANGELOG.md
@ -7,275 +7,8 @@ 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.)
|
||||
|
||||
@ -25,9 +25,6 @@ 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"),
|
||||
],
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
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") })
|
||||
}
|
||||
}
|
||||
@ -39,9 +39,7 @@ 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
|
||||
|
||||
48
README.md
48
README.md
@ -1,11 +1,10 @@
|
||||
# PureXML
|
||||
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=style.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-macos.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-linux.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-windows.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-wasm.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-xsts.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml)
|
||||
[](LICENSE)
|
||||
|
||||
PureXML is a dependency-free XML package written entirely in Swift.
|
||||
@ -20,7 +19,7 @@ intentionally strict about portability:
|
||||
- root Swift package layout
|
||||
- macOS, Linux, Windows, and WASI build gates
|
||||
|
||||
It is a sibling project to [PureYAML](https://git.aleahim.com/NodetteHQ/PureYAML) and
|
||||
It is a sibling project to [PureYAML](https://github.com/mihaelamj/PureYAML) and
|
||||
follows the same structure, rules, and verification gates.
|
||||
|
||||
## Roadmap
|
||||
@ -73,12 +72,9 @@ 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 [#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.
|
||||
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`.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@ -105,19 +101,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["#7 XSLT 1.0 suite (xalan-test): 26 pinned baseline failures"]:::review
|
||||
XSLTTS["#130 XSLT 1.0 suite (xalan-test): ~126 baseline failures"]:::review
|
||||
Parity100["Behavioral parity closed: #141 top-level params, #142 public streaming, #143 XPath budget, #144 WASI runtime proof, pl/ru sort collation"]:::done
|
||||
Epic139["#6 faster than libxml2, measured + streaming (benchmarks in docs/benchmarks.md; quadratics killed; unsafe authorized)"]:::epic
|
||||
Epic139["#139 faster than libxml2, measured + streaming (benchmarks in docs/benchmarks.md; quadratics killed; unsafe authorized)"]:::epic
|
||||
Audit140["#140 XSLT validation-framework audit"]:::todo
|
||||
Epic139 --> Parity100
|
||||
Epic139 --> Audit140
|
||||
RNGTS["#131 RELAX NG spec suite: complete, one documented class"]:::done
|
||||
C14NTS["#132 C14N spec vectors: complete"]:::done
|
||||
Epic167["#3 production readiness for IDE use"]:::epic
|
||||
Oracle171["schema differential harness (shipped)"]:::done
|
||||
Epic167["#167 production readiness for IDE use (v0.2.0)"]:::epic
|
||||
Oracle171["#171 schema differential harness"]:::todo
|
||||
CrossDoc161["#161 cross-document composition"]:::todo
|
||||
Gates145["#4 / #5 XSTS gates: burn-down 8 and 4 to 0"]:::review
|
||||
Loc169["located schema diagnostics (shipped)"]:::done
|
||||
Gates145["#145-#148 XSTS gates: burn-down to 0"]:::review
|
||||
Loc169["#169 located schema diagnostics"]:::todo
|
||||
Epic195["#195 xsi:type and substitution-group derivation correctness"]:::epic
|
||||
Epic105 --> Epic121
|
||||
Epic105 --> Epic122
|
||||
@ -145,7 +141,7 @@ flowchart TB
|
||||
|
||||
## Status
|
||||
|
||||
**Current focus:** production readiness for IDE use ([#3](https://git.aleahim.com/NodetteHQ/PureXML/issues/3)). XSTS settled baselines (valid schemas rejected / invalid schemas accepted / valid instances rejected / invalid instances accepted), enforced in `Tests/XSTSSuiteTests.swift`: **0 / 8 / 0 / 4**. See [`docs/roadmap.md`](docs/roadmap.md) for milestones and open tasks.
|
||||
**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.
|
||||
|
||||
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
|
||||
@ -224,13 +220,11 @@ 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
|
||||
[#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).
|
||||
**#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).
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
Need help with PureXML? Here is where to go.
|
||||
|
||||
- **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)
|
||||
- **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)
|
||||
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).
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,3 @@
|
||||
/// 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
|
||||
@ -33,47 +16,41 @@ extension PureXML.Canonical {
|
||||
var assignment: [String: String] = [:]
|
||||
var order = 0
|
||||
assign(node, inScope: [:], labels: labels, &assignment, &order)
|
||||
return transform(node, assignment: assignment, labels: labels)
|
||||
return transform(node, inScope: [:], assignment: assignment, labels: labels, isRoot: true)
|
||||
}
|
||||
|
||||
// MARK: First pass: assign a canonical prefix per visibly-used namespace
|
||||
|
||||
private static func assign(
|
||||
_ root: PureXML.Model.Node,
|
||||
_ node: PureXML.Model.Node,
|
||||
inScope: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
_ assignment: inout [String: String],
|
||||
_ order: inout Int,
|
||||
) {
|
||||
// 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
|
||||
switch node {
|
||||
case let .document(children):
|
||||
for child in children {
|
||||
assign(child, inScope: inScope, labels: labels, &assignment, &order)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,74 +73,48 @@ 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(
|
||||
_ root: PureXML.Model.Node,
|
||||
assignment: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
) -> PureXML.Model.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
|
||||
}
|
||||
|
||||
/// 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,
|
||||
target: CanonicalRewriteAccumulator,
|
||||
stack: inout [CanonicalRewriteWork],
|
||||
) {
|
||||
) -> PureXML.Model.Node {
|
||||
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))
|
||||
}
|
||||
.document(children.map { transform($0, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot) })
|
||||
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))
|
||||
}
|
||||
.element(transform(element, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot))
|
||||
default:
|
||||
target.nodes.append(node)
|
||||
node
|
||||
}
|
||||
}
|
||||
|
||||
private static func transform(
|
||||
_ element: PureXML.Model.Element,
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
/// document element so every canonical prefix is in scope from the top.
|
||||
private static func canonicalDeclarations(_ assignment: [String: String]) -> [PureXML.Model.Attribute] {
|
||||
@ -208,12 +159,8 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
/// 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)
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
/// 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)
|
||||
}
|
||||
@ -5,9 +5,7 @@ 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))
|
||||
@ -96,35 +94,27 @@ 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]
|
||||
@ -202,19 +192,11 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,9 +68,7 @@ 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):
|
||||
@ -79,9 +77,7 @@ 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)?>"
|
||||
}
|
||||
@ -93,35 +89,6 @@ 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 {
|
||||
@ -142,25 +109,10 @@ public extension PureXML.Canonical {
|
||||
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
|
||||
}
|
||||
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
|
||||
for child in element.children {
|
||||
emit(child, inScope: childInScope, rendered: childRendered, output: &output)
|
||||
}
|
||||
output += "</\(element.name.description)>"
|
||||
}
|
||||
|
||||
// MARK: Namespace selection
|
||||
@ -204,9 +156,7 @@ 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))
|
||||
}
|
||||
@ -223,21 +173,15 @@ 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
|
||||
}
|
||||
}
|
||||
@ -248,6 +192,77 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -266,54 +281,49 @@ public extension PureXML.Canonical.Canonicalizer {
|
||||
}
|
||||
|
||||
private func emitSelected(
|
||||
_ root: PureXML.Model.TreeNode,
|
||||
_ node: 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
private func emitSelected(
|
||||
element node: PureXML.Model.TreeNode,
|
||||
inScope: [String: String],
|
||||
rendered: [String: String],
|
||||
including predicate: (PureXML.Model.TreeNode) -> Bool,
|
||||
into output: inout String,
|
||||
) -> [CanonicalSelectedStep] {
|
||||
output: inout String,
|
||||
) {
|
||||
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 {
|
||||
return node.children.reversed().map { .open($0, inScope: childInScope, rendered: rendered) }
|
||||
// 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
|
||||
}
|
||||
let attributes = Self.plainAttributes(element)
|
||||
let toRender = selectedNamespaces(element, inScope: childInScope, attributes: attributes, rendered: rendered)
|
||||
@ -329,26 +339,10 @@ public extension PureXML.Canonical.Canonicalizer {
|
||||
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
|
||||
}
|
||||
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
|
||||
for child in node.children {
|
||||
emitSelected(child, inScope: childInScope, rendered: childRendered, including: predicate, output: &output)
|
||||
}
|
||||
output += "</\(element.name.description)>"
|
||||
}
|
||||
|
||||
/// The namespaces a selected element renders: in inclusive mode every in-scope
|
||||
|
||||
@ -27,9 +27,7 @@ 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":
|
||||
@ -64,9 +62,7 @@ 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
|
||||
}
|
||||
|
||||
@ -163,22 +163,16 @@ 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
|
||||
}
|
||||
|
||||
@ -27,22 +27,16 @@ 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)))
|
||||
@ -56,17 +50,11 @@ 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
|
||||
}
|
||||
|
||||
@ -20,16 +20,12 @@ 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
|
||||
}
|
||||
|
||||
@ -59,42 +55,8 @@ 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. 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.
|
||||
/// ASCII-only mode, a non-ASCII lead byte.
|
||||
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
|
||||
@ -136,16 +98,12 @@ 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
|
||||
}
|
||||
|
||||
@ -179,33 +137,8 @@ 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. Run
|
||||
/// on every attribute value in a document, so it prefers the same raw
|
||||
/// contiguous-buffer scan as ``textNeedsEscaping``, for the same measured
|
||||
/// reason.
|
||||
/// any non-ASCII byte. Every trigger is ASCII bar the non-ASCII lead.
|
||||
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
|
||||
@ -222,44 +155,15 @@ 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.
|
||||
// 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
|
||||
}
|
||||
if !value.utf8.contains(0x2D) { return value }
|
||||
var result = ""
|
||||
var previousHyphen = false
|
||||
var runStart = value.startIndex
|
||||
// Find each candidate hyphen byte, copy the verbatim run before it in
|
||||
// one append, and resolve it to its full grapheme (a byte match inside
|
||||
// a combining-mark cluster is not a bare "-" and is copied like any
|
||||
// other character); only a genuine "-" can extend or start a run.
|
||||
while let marker = value.utf8[runStart...].firstIndex(where: { $0 == 0x2D }) {
|
||||
if runStart < marker {
|
||||
result += value[runStart ..< marker]
|
||||
previousHyphen = false
|
||||
}
|
||||
let character = value[marker]
|
||||
if character == "-", previousHyphen {
|
||||
result += " "
|
||||
}
|
||||
for character in value {
|
||||
if character == "-", previousHyphen { result.append(" ") }
|
||||
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
|
||||
}
|
||||
|
||||
@ -268,39 +172,14 @@ 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. 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
|
||||
}
|
||||
// unchanged rather than rebuilt character by character.
|
||||
if !data.utf8.contains(0x3F) { return data }
|
||||
var result = ""
|
||||
var previousQuestion = false
|
||||
var runStart = data.startIndex
|
||||
// Candidates are '?' (may start the offending pair) and '>' (may close
|
||||
// it); every other character, including one that merely shares a byte
|
||||
// with a combining-mark cluster, is bulk-copied and resets the flag.
|
||||
while let marker = data.utf8[runStart...].firstIndex(where: { $0 == 0x3F || $0 == 0x3E }) {
|
||||
if runStart < marker {
|
||||
result += data[runStart ..< marker]
|
||||
previousQuestion = false
|
||||
}
|
||||
let character = data[marker]
|
||||
if character == ">", previousQuestion {
|
||||
result += " "
|
||||
}
|
||||
for character in data {
|
||||
if character == ">", previousQuestion { result.append(" ") }
|
||||
result.append(character)
|
||||
previousQuestion = character == "?"
|
||||
runStart = data.index(after: marker)
|
||||
}
|
||||
if runStart < data.endIndex {
|
||||
result += data[runStart...]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@ -103,10 +103,9 @@ 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)=\(quoteString)\(value)\(quoteString)"
|
||||
output += " \(attribute.name.description)=\(quote)\(value)\(quote)"
|
||||
}
|
||||
|
||||
if element.children.isEmpty, options.selfCloseEmptyElements {
|
||||
|
||||
@ -37,12 +37,8 @@ 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
|
||||
}
|
||||
@ -52,9 +48,7 @@ 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]) {
|
||||
@ -97,9 +91,7 @@ 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)
|
||||
@ -112,9 +104,7 @@ 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
|
||||
}
|
||||
@ -134,9 +124,7 @@ 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
|
||||
@ -144,9 +132,7 @@ 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
|
||||
}
|
||||
@ -158,19 +144,13 @@ 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) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
/// 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.
|
||||
@ -78,12 +84,8 @@ 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)
|
||||
}
|
||||
@ -94,17 +96,13 @@ 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
|
||||
@ -142,9 +140,7 @@ 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) {
|
||||
@ -172,9 +168,7 @@ final class HTMLDocument {
|
||||
process(token)
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) {
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) { return }
|
||||
mode = .inBody
|
||||
process(token)
|
||||
}
|
||||
@ -216,11 +210,7 @@ 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:
|
||||
@ -233,12 +223,27 @@ 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)
|
||||
@ -265,6 +270,51 @@ 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 {
|
||||
@ -280,9 +330,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@ -59,17 +59,13 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
/// 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"
|
||||
}
|
||||
@ -5,63 +5,51 @@ 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):
|
||||
stack.append(contentsOf: children.reversed().map { HTMLSerializeStep.node($0, rawTextParent: false) })
|
||||
children.map(serialize).joined()
|
||||
case let .element(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) })
|
||||
serializeElement(element)
|
||||
case let .text(value), let .cdata(value):
|
||||
output += rawTextParent ? value : escapeText(value)
|
||||
escapeText(value)
|
||||
case let .comment(value):
|
||||
output += "<!--\(PureXML.Emitting.Escaping.comment(value))-->"
|
||||
"<!--\(PureXML.Emitting.Escaping.comment(value))-->"
|
||||
case let .processingInstruction(target, data):
|
||||
output += data.isEmpty ? "<?\(target)>" : "<?\(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)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -136,9 +124,7 @@ extension PureXML.HTML {
|
||||
default: result += latin1Entity(scalar) ?? String(Character(scalar))
|
||||
}
|
||||
}
|
||||
if previousWasAmpersand {
|
||||
result += "&"
|
||||
}
|
||||
if previousWasAmpersand { result += "&" }
|
||||
return result
|
||||
}
|
||||
|
||||
@ -190,12 +176,3 @@ 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)
|
||||
}
|
||||
|
||||
@ -27,18 +27,10 @@ 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()
|
||||
}
|
||||
|
||||
@ -46,9 +38,7 @@ 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()
|
||||
@ -63,21 +53,16 @@ extension PureXML.HTML {
|
||||
var selfClosing = false
|
||||
while !isAtEnd {
|
||||
skipSpace()
|
||||
if consume("/>") {
|
||||
selfClosing = true
|
||||
break
|
||||
}
|
||||
if consume(">") {
|
||||
if consume("/>") { selfClosing = true
|
||||
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)
|
||||
}
|
||||
|
||||
@ -148,13 +133,9 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -1,41 +1,9 @@
|
||||
public extension PureXML.Model {
|
||||
/// An XML element: a qualified name, ordered attributes, and ordered child nodes.
|
||||
///
|
||||
/// 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 {
|
||||
struct Element: Equatable, Hashable, Sendable {
|
||||
public var name: QualifiedName
|
||||
public var attributes: [Attribute]
|
||||
/// 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 var children: [Node]
|
||||
|
||||
public init(
|
||||
name: QualifiedName,
|
||||
@ -44,7 +12,7 @@ public extension PureXML.Model {
|
||||
) {
|
||||
self.name = name
|
||||
self.attributes = attributes
|
||||
storage = ElementStorage(children)
|
||||
self.children = children
|
||||
}
|
||||
|
||||
public init(
|
||||
@ -72,16 +40,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,7 @@ 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.
|
||||
///
|
||||
/// 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 {
|
||||
indirect enum Node: Equatable, Hashable, Sendable {
|
||||
/// The root of a parsed document, holding its prolog and root element.
|
||||
case document([Node])
|
||||
/// An element with a name, attributes, and children.
|
||||
@ -29,105 +23,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -111,35 +111,6 @@ 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.
|
||||
@ -194,12 +165,8 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
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: Self.buildForest(children))
|
||||
self.init(adopting: .document, children: children.map(PureXML.Model.TreeNode.init))
|
||||
case let .element(element):
|
||||
self.init(
|
||||
adopting: .element,
|
||||
name: element.name,
|
||||
attributes: element.attributes,
|
||||
children: Self.buildForest(element.children),
|
||||
children: element.children.map(PureXML.Model.TreeNode.init),
|
||||
)
|
||||
case let .text(value):
|
||||
self.init(kind: .text, value: value)
|
||||
@ -26,46 +22,17 @@ 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. Iterative on depth (see ``projectedChildren(of:)``).
|
||||
/// or value comparison.
|
||||
var node: PureXML.Model.Node {
|
||||
switch kind {
|
||||
case .document:
|
||||
.document(Self.projectedChildren(of: self))
|
||||
.document(projectedChildren)
|
||||
case .element:
|
||||
.element(PureXML.Model.Element(
|
||||
name: name ?? PureXML.Model.QualifiedName(""),
|
||||
attributes: attributes,
|
||||
children: Self.projectedChildren(of: self),
|
||||
children: projectedChildren,
|
||||
))
|
||||
case .text:
|
||||
.text(value)
|
||||
@ -85,208 +52,19 @@ public extension PureXML.Model.TreeNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The value-`Node` children of `root`'s subtree, projected into a pure
|
||||
/// content tree without recursing on depth: doctype and namespace nodes are
|
||||
/// The value-`Node` children of this subtree: doctype and namespace nodes are
|
||||
/// dropped (they carry no content), and an entity-reference node is spliced
|
||||
/// 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
|
||||
/// 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
|
||||
switch child.kind {
|
||||
case .doctype, .namespace:
|
||||
continue
|
||||
[]
|
||||
case .entityReference:
|
||||
stack.append(ProjectFrame(child, splice: true))
|
||||
case .element, .document:
|
||||
stack.append(ProjectFrame(child, splice: false))
|
||||
child.projectedChildren
|
||||
default:
|
||||
frame.built.append(projectLeaf(child))
|
||||
[child.node]
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,9 +45,7 @@ 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 }
|
||||
@ -55,12 +53,8 @@ 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
|
||||
|
||||
@ -75,9 +75,7 @@ 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
|
||||
|
||||
@ -70,21 +70,14 @@ 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 {
|
||||
// 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
|
||||
}
|
||||
switch kind {
|
||||
case .text, .cdata:
|
||||
value
|
||||
case .element, .document, .entityReference:
|
||||
children.reduce(into: "") { $0 += $1.textContent }
|
||||
case .namespace, .comment, .processingInstruction, .doctype:
|
||||
""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// This node's position among its parent's children, or nil when it has no
|
||||
|
||||
@ -9,9 +9,7 @@ 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
|
||||
|
||||
@ -16,9 +16,7 @@ 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))
|
||||
@ -28,9 +26,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -166,9 +166,7 @@ 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):
|
||||
@ -193,9 +191,7 @@ 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)
|
||||
|
||||
@ -90,12 +90,8 @@ 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
|
||||
}
|
||||
|
||||
@ -104,9 +104,7 @@ 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)])
|
||||
|
||||
@ -25,17 +25,13 @@ 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
|
||||
}
|
||||
@ -45,9 +41,7 @@ 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)
|
||||
|
||||
@ -8,9 +8,7 @@ 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
|
||||
@ -76,9 +74,7 @@ private struct ModelScanner {
|
||||
var sawAlternative = false
|
||||
while true {
|
||||
skipSpace()
|
||||
if consume(")") {
|
||||
break
|
||||
}
|
||||
if consume(")") { break }
|
||||
guard consume("|") else { return false }
|
||||
sawAlternative = true
|
||||
skipSpace()
|
||||
@ -108,9 +104,7 @@ 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 }
|
||||
|
||||
@ -83,9 +83,7 @@ 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
|
||||
}
|
||||
|
||||
@ -300,6 +298,22 @@ 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()
|
||||
|
||||
@ -50,9 +50,7 @@ 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)
|
||||
@ -170,9 +168,7 @@ 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
|
||||
}
|
||||
|
||||
@ -95,9 +95,7 @@ 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 {
|
||||
@ -239,9 +237,7 @@ 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)
|
||||
}
|
||||
|
||||
@ -26,9 +26,7 @@ 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))
|
||||
@ -38,9 +36,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -17,9 +17,7 @@ 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))
|
||||
@ -29,9 +27,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -18,14 +18,7 @@ extension PureXML.Parsing {
|
||||
budget: inout Int,
|
||||
at mark: Mark,
|
||||
) throws -> String {
|
||||
// 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 }
|
||||
guard raw.contains("&") else { return raw }
|
||||
var expander = EntityExpander(entities: entities, mark: mark, budget: budget)
|
||||
try expander.expand(raw, visiting: [], counts: false)
|
||||
budget = expander.budget
|
||||
@ -44,9 +37,7 @@ extension PureXML.Parsing {
|
||||
at mark: Mark,
|
||||
undeclared: inout [String],
|
||||
) throws -> String {
|
||||
// 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 }
|
||||
guard raw.contains("&") else { return raw }
|
||||
var expander = EntityExpander(entities: entities, mark: mark, budget: budget, undeclared: [])
|
||||
try expander.expand(raw, visiting: [], counts: false)
|
||||
budget = expander.budget
|
||||
@ -78,12 +69,11 @@ 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, cache: &markupCache) {
|
||||
if !name.hasPrefix("#"), !predefined.contains(name), producesMarkup(name, entities: entities, visiting: []) {
|
||||
return MarkupSplit(
|
||||
prefix: String(raw[..<amp]),
|
||||
name: name,
|
||||
@ -120,24 +110,12 @@ extension PureXML.Parsing.EntityDecoder {
|
||||
}
|
||||
|
||||
/// Whether `name`'s replacement contains markup, directly or transitively.
|
||||
/// 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
|
||||
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]))
|
||||
}
|
||||
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(
|
||||
@ -145,32 +123,13 @@ 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) where !verified.contains(reference) {
|
||||
for reference in declaredReferences(in: replacement) {
|
||||
guard !visiting.contains(reference) else {
|
||||
throw PureXML.Parsing.ParseError.recursiveEntity(name: reference, mark)
|
||||
}
|
||||
try checkRecursion(reference, entities: entities, visiting: visiting.union([reference]), verified: &verified, at: mark)
|
||||
verified.insert(reference)
|
||||
try checkRecursion(reference, entities: entities, visiting: visiting.union([reference]), at: mark)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -57,13 +57,9 @@ 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
|
||||
}
|
||||
@ -104,9 +100,7 @@ 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
|
||||
@ -132,12 +126,8 @@ 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
|
||||
@ -156,16 +146,12 @@ 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,14 +160,10 @@ 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
|
||||
@ -192,9 +174,7 @@ 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
|
||||
|
||||
@ -26,13 +26,6 @@ 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
|
||||
@ -88,16 +81,12 @@ 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -164,24 +153,16 @@ 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,6 +192,97 @@ 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
|
||||
|
||||
@ -3,20 +3,13 @@
|
||||
extension PureXML.Parsing.EventReader {
|
||||
mutating func scanAttributes() throws -> [PureXML.Model.Attribute] {
|
||||
var attributes: [PureXML.Model.Attribute] = []
|
||||
// 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)
|
||||
var seen: Set<String> = []
|
||||
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
|
||||
@ -30,7 +23,7 @@ extension PureXML.Parsing.EventReader {
|
||||
}
|
||||
reader.skipSpace()
|
||||
let value = try scanAttributeValue()
|
||||
guard attributeNamesSeen.insert(name.description).inserted else {
|
||||
guard seen.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.
|
||||
@ -58,7 +51,11 @@ extension PureXML.Parsing.EventReader {
|
||||
|
||||
mutating func scanAttributeValue() throws -> String {
|
||||
let mark = reader.mark
|
||||
let (quoteByte, quoteCharacter) = try consumeAttributeValueOpenQuote()
|
||||
guard let quote = reader.peek(), quote == "\"" || quote == "'" else {
|
||||
throw PureXML.Parsing.ParseError.unquotedAttributeValue(reader.mark)
|
||||
}
|
||||
reader.advance()
|
||||
let quoteByte: UInt8 = quote == "\"" ? 0x22 : 0x27
|
||||
var raw = ""
|
||||
var length = 0
|
||||
// Whether any '&' was seen: without one the value has no references, so
|
||||
@ -75,16 +72,7 @@ extension PureXML.Parsing.EventReader {
|
||||
raw += run
|
||||
continue
|
||||
}
|
||||
// Byte-level check before the Character peek: whenever
|
||||
// `attributeRunBytes` stopped because the next byte is the closing
|
||||
// quote (its only non-empty-input stopping reason besides '<', CR,
|
||||
// or a control/non-ASCII byte the Character path must inspect),
|
||||
// this avoids buffering that quote as a Character only to compare
|
||||
// and discard it a moment later.
|
||||
if reader.peekByte() == quoteByte {
|
||||
break
|
||||
}
|
||||
guard let character = reader.peek(), character != quoteCharacter else { break }
|
||||
guard let character = reader.peek(), character != quote else { break }
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
if character == "<" {
|
||||
@ -93,60 +81,18 @@ 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()
|
||||
}
|
||||
// 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)
|
||||
guard reader.consume(String(quote)) else {
|
||||
throw PureXML.Parsing.ParseError.unexpectedEndOfInput(reader.mark)
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,15 +31,9 @@ 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()
|
||||
}
|
||||
|
||||
@ -52,9 +46,7 @@ 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)
|
||||
}
|
||||
|
||||
@ -7,9 +7,7 @@ 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
|
||||
@ -33,9 +31,7 @@ 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()
|
||||
@ -106,9 +102,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@ -19,9 +19,7 @@ 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
|
||||
}
|
||||
|
||||
@ -49,9 +47,7 @@ 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: "&") {
|
||||
@ -70,9 +66,7 @@ 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
|
||||
@ -142,9 +136,7 @@ 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)
|
||||
}
|
||||
|
||||
@ -71,12 +71,8 @@ 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
|
||||
|
||||
@ -18,9 +18,7 @@ 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))
|
||||
@ -32,9 +30,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -19,9 +19,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -13,70 +13,41 @@ extension PureXML.Parsing {
|
||||
/// bound by a declaration.
|
||||
static let xmlnsNamespaceURI = "http://www.w3.org/2000/xmlns/"
|
||||
|
||||
/// 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] = [:]
|
||||
/// A stack of scopes; each maps a prefix ("" = default namespace) to a URI.
|
||||
private var scopes: [[String: String]] = []
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
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 changes: [(key: String, previous: String?)] = []
|
||||
var scope: [String: 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)
|
||||
changes.append((key: "", previous: current[""]))
|
||||
current[""] = attribute.value
|
||||
scope[""] = attribute.value
|
||||
} else if attribute.name.prefix == "xmlns" {
|
||||
try checkPrefixBinding(attribute.name.localName, uri: attribute.value, at: mark)
|
||||
let key = attribute.name.localName
|
||||
changes.append((key: key, previous: current[key]))
|
||||
current[key] = attribute.value
|
||||
scope[attribute.name.localName] = attribute.value
|
||||
}
|
||||
}
|
||||
undo.append(changes)
|
||||
scopes.append(scope)
|
||||
|
||||
let resolvedName = try resolveElement(name, 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
|
||||
let resolvedAttributes = try attributes.map { try resolveAttribute($0, at: mark) }
|
||||
try checkExpandedNames(resolvedAttributes, at: mark)
|
||||
return (resolvedName, resolvedAttributes)
|
||||
}
|
||||
|
||||
/// Closes an element, restoring the bindings it overwrote.
|
||||
/// Closes an element, popping its scope.
|
||||
mutating func leaveElement() {
|
||||
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
|
||||
if !scopes.isEmpty {
|
||||
scopes.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,16 +88,7 @@ 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 {
|
||||
// `":"` 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 }
|
||||
guard name.localName.contains(":") else { return }
|
||||
throw ParseError.namespaceConstraint(reason: "'\(name.description)' is not a legal qualified name", mark)
|
||||
}
|
||||
|
||||
@ -161,22 +123,6 @@ 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
|
||||
@ -187,8 +133,12 @@ extension PureXML.Parsing {
|
||||
}
|
||||
|
||||
private func lookup(_ key: String) -> String? {
|
||||
guard let uri = current[key], !uri.isEmpty else { return nil }
|
||||
return uri
|
||||
for scope in scopes.reversed() {
|
||||
if let uri = scope[key] {
|
||||
return uri.isEmpty ? nil : uri
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,9 +30,7 @@ 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
|
||||
}
|
||||
|
||||
@ -52,17 +52,13 @@ 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
|
||||
}
|
||||
|
||||
@ -64,9 +64,7 @@ private struct TagCursor {
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count {
|
||||
index += 1
|
||||
}
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
|
||||
private mutating func skipSpace() {
|
||||
@ -133,21 +131,11 @@ 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)
|
||||
}
|
||||
|
||||
@ -174,9 +162,7 @@ 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)
|
||||
}
|
||||
@ -215,9 +201,7 @@ 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 == ">" {
|
||||
|
||||
@ -12,22 +12,11 @@ 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
|
||||
@ -67,9 +56,7 @@ 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 }
|
||||
@ -119,7 +106,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 available < count, !exhausted {
|
||||
while buffer.count < count, !exhausted {
|
||||
if let character = normalizedPull() {
|
||||
buffer.append(character)
|
||||
} else {
|
||||
@ -135,14 +122,10 @@ 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}" {
|
||||
@ -164,7 +147,7 @@ extension PureXML.Parsing {
|
||||
|
||||
mutating func peek(_ ahead: Int = 0) -> Character? {
|
||||
ensure(ahead + 1)
|
||||
return ahead < available ? buffer[head + ahead] : nil
|
||||
return ahead < buffer.count ? buffer[ahead] : nil
|
||||
}
|
||||
|
||||
/// The raw byte `ahead` positions past the cursor, on the byte fast path
|
||||
@ -187,13 +170,8 @@ extension PureXML.Parsing {
|
||||
@discardableResult
|
||||
mutating func advance() -> Character? {
|
||||
ensure(1)
|
||||
guard available > 0 else { return nil }
|
||||
let character = buffer[head]
|
||||
head += 1
|
||||
if head == buffer.count {
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
head = 0
|
||||
}
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
let character = buffer.removeFirst()
|
||||
offset += 1
|
||||
if character == "\n" {
|
||||
line += 1
|
||||
@ -207,21 +185,17 @@ 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 available >= count else { return false }
|
||||
guard buffer.count >= count else { return false }
|
||||
var index = 0
|
||||
for character in literal {
|
||||
if buffer[head + index] != character {
|
||||
return false
|
||||
}
|
||||
if buffer[index] != character { return false }
|
||||
index += 1
|
||||
}
|
||||
return true
|
||||
@ -264,18 +238,10 @@ 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
|
||||
@ -307,9 +273,7 @@ extension PureXML.Parsing {
|
||||
}
|
||||
|
||||
mutating func skipSpace() {
|
||||
if skipSpaceBytes() {
|
||||
return
|
||||
}
|
||||
if skipSpaceBytes() { return }
|
||||
while let character = peek(), character.isXMLWhitespace {
|
||||
advance()
|
||||
}
|
||||
@ -345,19 +309,7 @@ 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) {
|
||||
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)
|
||||
}
|
||||
buffer.insert(contentsOf: text.unicodeScalars.map(Character.init), at: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,25 +22,19 @@ 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 {
|
||||
|
||||
@ -19,9 +19,7 @@ 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)
|
||||
@ -31,21 +29,15 @@ 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
|
||||
}
|
||||
|
||||
@ -61,9 +53,7 @@ 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]))
|
||||
}
|
||||
|
||||
@ -47,6 +47,136 @@ 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 {
|
||||
@ -56,9 +186,7 @@ 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}")
|
||||
}
|
||||
@ -101,7 +229,7 @@ extension PureXML.Parsing {
|
||||
return pull()
|
||||
}
|
||||
|
||||
mutating func nextUTF8() -> Character? {
|
||||
private mutating func nextUTF8() -> Character? {
|
||||
guard let first = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,7 @@ 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
|
||||
|
||||
@ -186,9 +186,7 @@ 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)
|
||||
|
||||
@ -67,9 +67,7 @@ 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, // "."
|
||||
@ -88,9 +86,7 @@ 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
|
||||
}
|
||||
|
||||
@ -22,9 +22,7 @@ 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
|
||||
}
|
||||
}
|
||||
@ -107,9 +105,7 @@ 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
|
||||
|
||||
@ -42,12 +42,8 @@ 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
|
||||
}
|
||||
|
||||
@ -64,17 +60,11 @@ 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)
|
||||
}
|
||||
|
||||
@ -126,9 +116,7 @@ extension PureXML.Pattern {
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count {
|
||||
index += 1
|
||||
}
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,9 +29,7 @@ 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
|
||||
}
|
||||
|
||||
@ -30,9 +30,7 @@ extension PureXML.Regex {
|
||||
next.insert(transition.target)
|
||||
}
|
||||
}
|
||||
if next.isEmpty {
|
||||
return false
|
||||
}
|
||||
if next.isEmpty { return false }
|
||||
current = closure(next)
|
||||
}
|
||||
return current.contains(accept)
|
||||
@ -106,11 +104,7 @@ 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())
|
||||
@ -133,9 +127,7 @@ 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
|
||||
|
||||
@ -23,9 +23,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@ -20,9 +20,7 @@ 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 }
|
||||
@ -43,9 +41,7 @@ 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)
|
||||
}
|
||||
|
||||
@ -79,9 +75,7 @@ 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)
|
||||
}
|
||||
@ -192,9 +186,7 @@ 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()
|
||||
@ -244,19 +236,13 @@ 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"))
|
||||
}
|
||||
@ -288,9 +274,7 @@ extension PureXML.Regex {
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count {
|
||||
index += 1
|
||||
}
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,11 +81,8 @@ extension PureXML.Regex {
|
||||
case .word:
|
||||
let code = XSDCategory.generalCategoryCode(scalar)
|
||||
return !(code.hasPrefix("P") || code.hasPrefix("Z") || code.hasPrefix("C"))
|
||||
// \i and \c are the XML 1.0 Second Edition name classes (Appendix B),
|
||||
// a fixed Unicode-2.0 repertoire, NOT the parser's Fifth Edition Name
|
||||
// production: the two genuinely differ (XSTS reZ005/reZ006).
|
||||
case .nameStart: return XSDNameCharacter.isNameStart(scalar)
|
||||
case .nameChar: return XSDNameCharacter.isNameChar(scalar)
|
||||
case .nameStart: return PureXML.Parsing.XMLCharacter.isNameStart(scalar)
|
||||
case .nameChar: return PureXML.Parsing.XMLCharacter.isNameChar(scalar)
|
||||
case .anyButLineBreak: return scalar != "\n" && scalar != "\r"
|
||||
}
|
||||
}
|
||||
@ -121,9 +118,7 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@ -20,13 +20,6 @@ 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",
|
||||
]
|
||||
|
||||
@ -47,9 +40,7 @@ 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"
|
||||
}
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
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,
|
||||
]
|
||||
}
|
||||
@ -92,9 +92,7 @@ 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
|
||||
@ -110,12 +108,8 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@ -121,15 +121,9 @@ 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
|
||||
@ -137,12 +131,8 @@ 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
|
||||
}
|
||||
|
||||
@ -201,12 +191,8 @@ 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
|
||||
}
|
||||
}
|
||||
@ -238,12 +224,6 @@ 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) }
|
||||
@ -255,16 +235,14 @@ public extension PureXML.Schema {
|
||||
set { occurrenceRange.maximum = OccurrenceUpper(newValue) }
|
||||
}
|
||||
|
||||
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term, declaredReference: PureXML.Model.QualifiedName? = nil) {
|
||||
public init(minOccurs: Int = 1, maxOccurs: Int? = 1, term: Term) {
|
||||
occurrenceRange = OccurrenceRange(minimum: minOccurs, maximum: maxOccurs)
|
||||
self.term = term
|
||||
self.declaredReference = declaredReference
|
||||
}
|
||||
|
||||
public init(occurrenceRange: OccurrenceRange, term: Term, declaredReference: PureXML.Model.QualifiedName? = nil) {
|
||||
public init(occurrenceRange: OccurrenceRange, term: Term) {
|
||||
self.occurrenceRange = occurrenceRange
|
||||
self.term = term
|
||||
self.declaredReference = declaredReference
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,20 +250,10 @@ 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], definitionID: Int? = nil) {
|
||||
public init(compositor: Compositor, particles: [Particle]) {
|
||||
self.compositor = compositor
|
||||
self.particles = particles
|
||||
self.definitionID = definitionID
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,9 +274,7 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@ -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"`.
|
||||
let nillableElements: Set<String>
|
||||
private let nillableElements: Set<String>
|
||||
/// The `default`/`fixed` value constraint declared on each element name.
|
||||
let elementConstraints: [String: ValueConstraint]
|
||||
private 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,21 +79,8 @@ 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: [XSDFinding] = []
|
||||
var errors: [XSDFailure] = []
|
||||
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.
|
||||
@ -103,15 +90,68 @@ 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(
|
||||
@ -119,16 +159,7 @@ public extension PureXML.Schema {
|
||||
as type: ElementType,
|
||||
at path: [PureXML.Validation.PathKey] = [],
|
||||
) -> [PureXML.Validation.ValidationError] {
|
||||
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] = []
|
||||
var errors: [XSDFailure] = []
|
||||
let namespaceBindings = Self.namespaceBindings(for: element)
|
||||
validateChild(element, against: type, at: path, namespaceBindings: namespaceBindings, into: &errors)
|
||||
return errors
|
||||
@ -141,21 +172,17 @@ public extension PureXML.Schema {
|
||||
_ content: ContentType,
|
||||
at path: XSDPath,
|
||||
namespaceBindings: [String: String],
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [XSDFailure],
|
||||
) {
|
||||
let children = element.children.compactMap(\.element)
|
||||
switch content {
|
||||
case .empty:
|
||||
if !children.isEmpty {
|
||||
errors.append(XSDFinding(.emptyContentForbidsChildren, reason: "element must be empty", at: path))
|
||||
}
|
||||
if !children.isEmpty { errors.append(XSDFailure(reason: "element must be empty", at: path)) }
|
||||
rejectText(element, at: path, into: &errors)
|
||||
case let .simpleContent(type):
|
||||
if !children.isEmpty {
|
||||
errors.append(XSDFinding(.simpleContentForbidsChildren, reason: "element must not have children", at: path))
|
||||
}
|
||||
if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) }
|
||||
if let error = type.validate(Self.rawTextContent(element)) {
|
||||
errors.append(XSDFinding(.simpleContentTypeValid, reason: "content: \(error)", at: path))
|
||||
errors.append(XSDFailure(reason: "content: \(error)", at: path))
|
||||
}
|
||||
case let .elementOnly(particle):
|
||||
rejectText(element, at: path, into: &errors)
|
||||
@ -165,9 +192,9 @@ public extension PureXML.Schema {
|
||||
}
|
||||
}
|
||||
|
||||
func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFinding]) {
|
||||
func rejectText(_ element: PureXML.Model.Element, at path: XSDPath, into errors: inout [XSDFailure]) {
|
||||
guard !Self.textContent(element).isEmpty else { return }
|
||||
errors.append(XSDFinding(.textForbidden, reason: "element must not contain text", at: path))
|
||||
errors.append(XSDFailure(reason: "element must not contain text", at: path))
|
||||
}
|
||||
|
||||
// MARK: Particles
|
||||
@ -177,7 +204,7 @@ public extension PureXML.Schema {
|
||||
children: [PureXML.Model.Element],
|
||||
at path: XSDPath,
|
||||
namespaceBindings: [String: String],
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [XSDFailure],
|
||||
) {
|
||||
// Locate each content-model violation at the offending child (or the
|
||||
// missing one), with a recovery hint, rather than one opaque failure,
|
||||
@ -191,7 +218,7 @@ public extension PureXML.Schema {
|
||||
matched = allMatchedParticles(group, children: children)
|
||||
} else {
|
||||
sequenceStructureErrors(particle, children: children, at: path, into: &errors)
|
||||
matched = PureXML.Schema.ContentRTNMatcher(PureXML.Schema.ContentRTNBuilder.build(particle)).matchedParticles(children.map(\.name))
|
||||
matched = PureXML.Schema.ContentNFABuilder.build(particle).matchedParticles(children.map(\.name))
|
||||
}
|
||||
validateChildren(
|
||||
children,
|
||||
@ -205,7 +232,7 @@ public extension PureXML.Schema {
|
||||
_ children: [PureXML.Model.Element],
|
||||
matched: [PureXML.Schema.MatchedParticle?],
|
||||
frame: XSDChildValidationFrame,
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [XSDFailure],
|
||||
) {
|
||||
let steps = Self.childSteps(children)
|
||||
for (index, child) in children.enumerated() {
|
||||
@ -227,13 +254,60 @@ public extension PureXML.Schema {
|
||||
}
|
||||
}
|
||||
|
||||
func validateChild(
|
||||
/// 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(
|
||||
_ child: PureXML.Model.Element,
|
||||
against declared: ElementType,
|
||||
at path: XSDPath,
|
||||
namespaceBindings: [String: String],
|
||||
particleConstraint: ValueConstraint? = nil,
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [XSDFailure],
|
||||
) {
|
||||
// An instance `xsi:type` overrides the declared type, provided the named
|
||||
// type exists in the schema. Resolve to the type itself rather than a
|
||||
@ -241,7 +315,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(XSDFinding(.xsiTypeSubstitutionValid, xsiError))
|
||||
errors.append(xsiError)
|
||||
return
|
||||
}
|
||||
guard let declared = overriddenType(declared, for: child, namespaceBindings: namespaceBindings, at: path, into: &errors) else { return }
|
||||
@ -254,30 +328,26 @@ 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(XSDFinding(
|
||||
.simpleTypeDisallowedAttribute,
|
||||
reason: "'\(child.name.localName)' has a simple type and must not carry attribute '\(attribute.name.localName)'",
|
||||
at: path,
|
||||
))
|
||||
errors.append(XSDFailure(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(XSDFinding(.simpleTypeForbidsChildren, reason: "'\(child.name.localName)' must not have children", at: path))
|
||||
errors.append(XSDFailure(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(XSDFinding(.simpleTypeValueValid, reason: "'\(child.name.localName)': \(error)", at: path))
|
||||
errors.append(XSDFailure(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: validateTagged(child, against: complex, at: path, namespaceBindings: namespaceBindings))
|
||||
errors.append(contentsOf: validate(child, against: complex, at: path, namespaceBindings: namespaceBindings))
|
||||
case .typeReference:
|
||||
validateResolvedReference(declared, child, at: path, namespaceBindings: namespaceBindings, into: &errors)
|
||||
}
|
||||
@ -290,13 +360,13 @@ public extension PureXML.Schema {
|
||||
_ child: PureXML.Model.Element,
|
||||
at path: XSDPath,
|
||||
namespaceBindings: [String: String],
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [XSDFailure],
|
||||
) {
|
||||
switch resolveReference(declared) {
|
||||
case let .unknown(name):
|
||||
errors.append(XSDFinding(.typeReferenceResolves, reason: "unknown type '\(name)'", at: path))
|
||||
errors.append(XSDFailure(reason: "unknown type '\(name)'", at: path))
|
||||
case let .circular(name):
|
||||
errors.append(XSDFinding(.typeReferenceResolves, reason: "circular type reference '\(name)'", at: path))
|
||||
errors.append(XSDFailure(reason: "circular type reference '\(name)'", at: path))
|
||||
case let .resolved(resolved):
|
||||
validateChild(child, against: resolved, at: path, namespaceBindings: namespaceBindings, into: &errors)
|
||||
}
|
||||
|
||||
@ -3,21 +3,21 @@ extension PureXML.Schema.ComplexValidator {
|
||||
_ element: PureXML.Model.Element,
|
||||
_ type: PureXML.Schema.ComplexType,
|
||||
at path: [PureXML.Validation.PathKey],
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [PureXML.Validation.ValidationError],
|
||||
) {
|
||||
let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) }
|
||||
for use in type.attributes where !use.prohibited {
|
||||
let match = present.first { Self.attributeMatches($0.name, declared: use.name, use: use, on: element.name) }
|
||||
if let match {
|
||||
if let error = use.type.validate(match.value) {
|
||||
errors.append(XSDFinding(.attributeTypeValid, reason: "attribute '\(use.name.localName)': \(error)", at: path))
|
||||
errors.append(PureXML.Validation.ValidationError(reason: "attribute '\(use.name.localName)': \(error)", at: path))
|
||||
}
|
||||
if let fixed = use.valueConstraint?.fixedValue, !use.type.valueMatches(match.value, literal: fixed) {
|
||||
errors.append(XSDFinding(.attributeFixedMatches, reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
|
||||
errors.append(PureXML.Validation.ValidationError(reason: "attribute '\(use.name.localName)' is fixed and must be '\(fixed)'", at: path))
|
||||
}
|
||||
recordIDs(use.type, value: match.value, at: path)
|
||||
} else if use.required {
|
||||
errors.append(XSDFinding(.attributeRequiredPresent, reason: "missing required attribute '\(use.name.localName)'", at: path))
|
||||
errors.append(PureXML.Validation.ValidationError(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,8 +28,7 @@ extension PureXML.Schema.ComplexValidator {
|
||||
}
|
||||
validateWildcardAttributes(present, element: element, type: type, at: path, into: &errors)
|
||||
if idTypedAttributeCount(present, type, on: element) > 1 {
|
||||
errors.append(XSDFinding(
|
||||
.attributeSingleID,
|
||||
errors.append(PureXML.Validation.ValidationError(
|
||||
reason: "an element may carry at most one attribute of type ID",
|
||||
at: path,
|
||||
))
|
||||
@ -46,9 +45,7 @@ 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,9 +73,7 @@ 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
|
||||
@ -91,7 +86,7 @@ extension PureXML.Schema.ComplexValidator {
|
||||
element: PureXML.Model.Element,
|
||||
type: PureXML.Schema.ComplexType,
|
||||
at path: [PureXML.Validation.PathKey],
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [PureXML.Validation.ValidationError],
|
||||
) {
|
||||
// A `use="prohibited"` use is excluded from the effective {attribute uses},
|
||||
// so a prohibited attribute is treated as undeclared and left to the
|
||||
@ -101,7 +96,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(XSDFinding(.attributeDeclaredOrPermitted, reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
|
||||
errors.append(PureXML.Validation.ValidationError(reason: "undeclared attribute '\(attribute.name.localName)'", at: path))
|
||||
continue
|
||||
}
|
||||
switch wildcard.processContents {
|
||||
@ -113,8 +108,7 @@ extension PureXML.Schema.ComplexValidator {
|
||||
}
|
||||
case .strict:
|
||||
guard let use = globalAttributeUse(for: attribute.name) else {
|
||||
errors.append(XSDFinding(
|
||||
.wildcardAttributeUndeclared,
|
||||
errors.append(PureXML.Validation.ValidationError(
|
||||
reason: "no declaration for wildcard-matched attribute '\(attribute.name.localName)'",
|
||||
at: path,
|
||||
))
|
||||
@ -133,13 +127,13 @@ extension PureXML.Schema.ComplexValidator {
|
||||
_ attribute: PureXML.Model.Attribute,
|
||||
use: PureXML.Schema.AttributeUse,
|
||||
at path: [PureXML.Validation.PathKey],
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [PureXML.Validation.ValidationError],
|
||||
) {
|
||||
if let error = use.type.validate(attribute.value) {
|
||||
errors.append(XSDFinding(.wildcardAttributeTypeValid, reason: "attribute '\(attribute.name.localName)': \(error)", at: path))
|
||||
errors.append(PureXML.Validation.ValidationError(reason: "attribute '\(attribute.name.localName)': \(error)", at: path))
|
||||
}
|
||||
if let fixed = use.valueConstraint?.fixedValue, !use.type.valueMatches(attribute.value, literal: fixed) {
|
||||
errors.append(XSDFinding(.wildcardAttributeFixedMatches, reason: "attribute '\(attribute.name.localName)' is fixed and must be '\(fixed)'", at: path))
|
||||
errors.append(PureXML.Validation.ValidationError(reason: "attribute '\(attribute.name.localName)' is fixed and must be '\(fixed)'", at: path))
|
||||
}
|
||||
recordIDs(use.type, value: attribute.value, at: path)
|
||||
}
|
||||
|
||||
@ -4,33 +4,29 @@
|
||||
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 [XSDFinding]) {
|
||||
let matcher = PureXML.Schema.ContentRTNMatcher(PureXML.Schema.ContentRTNBuilder.build(particle))
|
||||
func sequenceStructureErrors(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) {
|
||||
let nfa = PureXML.Schema.ContentNFABuilder.build(particle)
|
||||
let steps = Self.childSteps(children)
|
||||
// Advance one active state-set across the children rather than re-walking
|
||||
// the prefix per child (which is quadratic over the content model, #129).
|
||||
let inputLength = children.count
|
||||
var current = matcher.startStates(inputLength: inputLength)
|
||||
var current = nfa.startStates(inputLength: inputLength)
|
||||
for (index, child) in children.enumerated() {
|
||||
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]],
|
||||
))
|
||||
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]]))
|
||||
return
|
||||
}
|
||||
current = next
|
||||
}
|
||||
if !matcher.isComplete(current) {
|
||||
errors.append(XSDFinding(.contentModelStructure, reason: "content is incomplete\(Self.expectation(matcher.admissible(from: current)))", at: path))
|
||||
if !nfa.isComplete(current) {
|
||||
errors.append(XSDFailure(reason: "content is incomplete\(Self.expectation(nfa.admissible(from: current)))", at: path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Locates `all`-group violations: each child that is not an in-bounds member,
|
||||
/// recovering past it, then each required member that never appeared.
|
||||
func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], groupOptional: Bool = false, at path: XSDPath, into errors: inout [XSDFinding]) {
|
||||
func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], groupOptional: Bool = false, at path: XSDPath, into errors: inout [XSDFailure]) {
|
||||
var counts = [Int](repeating: 0, count: group.particles.count)
|
||||
let steps = Self.childSteps(children)
|
||||
for (index, child) in children.enumerated() {
|
||||
@ -39,7 +35,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(XSDFinding(.contentModelStructure, reason: "element '\(child.name.localName)' is not allowed here", at: path + [steps[index]]))
|
||||
errors.append(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here", at: path + [steps[index]]))
|
||||
continue
|
||||
}
|
||||
counts[position] += 1
|
||||
@ -48,12 +44,10 @@ 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(XSDFinding(.contentModelStructure, reason: "element '\(name.localName)' is required but missing", at: path))
|
||||
errors.append(XSDFailure(reason: "element '\(name.localName)' is required but missing", at: path))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -98,9 +92,7 @@ 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()
|
||||
|
||||
@ -14,24 +14,12 @@ 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
|
||||
}
|
||||
|
||||
@ -111,9 +99,7 @@ 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
|
||||
}
|
||||
|
||||
@ -171,9 +157,7 @@ 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
|
||||
}
|
||||
|
||||
@ -253,17 +237,13 @@ 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
|
||||
@ -345,12 +325,8 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,32 @@ 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? {
|
||||
@ -22,9 +48,7 @@ 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:
|
||||
@ -41,9 +65,7 @@ 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)
|
||||
@ -66,9 +88,7 @@ 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)
|
||||
@ -117,20 +137,17 @@ extension PureXML.Schema.ComplexValidator {
|
||||
return match?.value.trimmingXMLWhitespace()
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
/// Resolves an instance `xsi:type` attribute to a named type-table key.
|
||||
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)
|
||||
}
|
||||
return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: namespaceBindings[""])
|
||||
if let uri = namespaceBindings[""] {
|
||||
return PureXML.Schema.XSDParser.typeDeclarationKey(local, namespaceURI: uri)
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
/// The element's character content, trimmed: used to decide whether an
|
||||
@ -206,13 +223,7 @@ 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 } }
|
||||
}
|
||||
}
|
||||
|
||||
@ -248,9 +259,7 @@ 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
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
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)]
|
||||
}
|
||||
}
|
||||
@ -17,8 +17,6 @@ public extension PureXML.Schema {
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Schema.ComplexValidator: PureXML.Validation.Validatable {}
|
||||
|
||||
public extension PureXML.Schema.ComplexValidator {
|
||||
// MARK: Streaming support
|
||||
|
||||
@ -38,19 +36,6 @@ 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`).
|
||||
@ -124,9 +109,7 @@ 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 }
|
||||
@ -148,9 +131,7 @@ 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
|
||||
}
|
||||
@ -179,45 +160,28 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
|
||||
at path: [PureXML.Validation.PathKey] = [],
|
||||
) -> [PureXML.Validation.ValidationError] {
|
||||
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] = []
|
||||
var errors: [XSDFailure] = []
|
||||
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 [XSDFinding(.typeReferenceResolves, reason: "unknown type '\(name)'", at: path)]
|
||||
return [XSDFailure(reason: "unknown type '\(name)'", at: path)]
|
||||
case let .circular(name):
|
||||
return [XSDFinding(.typeReferenceResolves, reason: "circular type reference '\(name)'", at: path)]
|
||||
return [XSDFailure(reason: "circular type reference '\(name)'", at: path)]
|
||||
case let .resolved(resolved):
|
||||
return validateShallowTagged(element, as: resolved, valueConstraint: valueConstraint, at: path)
|
||||
return validateShallow(element, as: resolved, valueConstraint: valueConstraint, at: path)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
@ -228,21 +192,21 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
_ simple: PureXML.Schema.SimpleType,
|
||||
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
|
||||
at path: XSDPath,
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [XSDFailure],
|
||||
) {
|
||||
if let nilErrors = nilErrors(element, fixedValue: valueConstraint?.fixedValue, at: path) {
|
||||
errors += nilErrors
|
||||
return
|
||||
}
|
||||
if !element.children.compactMap(\.element).isEmpty {
|
||||
errors.append(XSDFinding(.simpleTypeForbidsChildren, reason: "'\(element.name.localName)' must not have children", at: path))
|
||||
errors.append(XSDFailure(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(XSDFinding(.simpleTypeValueValid, reason: "'\(element.name.localName)': \(error)", at: path))
|
||||
errors.append(XSDFailure(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"
|
||||
@ -251,21 +215,17 @@ 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 [XSDFinding]) {
|
||||
private func shallowContent(_ element: PureXML.Model.Element, _ content: PureXML.Schema.ContentType, at path: XSDPath, into errors: inout [XSDFailure]) {
|
||||
let children = element.children.compactMap(\.element)
|
||||
switch content {
|
||||
case .empty:
|
||||
if !children.isEmpty {
|
||||
errors.append(XSDFinding(.emptyContentForbidsChildren, reason: "element must be empty", at: path))
|
||||
}
|
||||
if !children.isEmpty { errors.append(XSDFailure(reason: "element must be empty", at: path)) }
|
||||
rejectText(element, at: path, into: &errors)
|
||||
case let .simpleContent(type):
|
||||
if !children.isEmpty {
|
||||
errors.append(XSDFinding(.simpleContentForbidsChildren, reason: "element must not have children", at: path))
|
||||
}
|
||||
if !children.isEmpty { errors.append(XSDFailure(reason: "element must not have children", at: path)) }
|
||||
let text = Self.rawTextContent(element)
|
||||
if let error = type.validate(text) {
|
||||
errors.append(XSDFinding(.simpleContentTypeValid, reason: "content: \(error)", at: path))
|
||||
errors.append(XSDFailure(reason: "content: \(error)", at: path))
|
||||
}
|
||||
recordIDs(type, value: text, at: path)
|
||||
case let .elementOnly(particle):
|
||||
@ -276,7 +236,7 @@ public extension PureXML.Schema.ComplexValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private func shallowStructure(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFinding]) {
|
||||
private func shallowStructure(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFailure]) {
|
||||
if case let .group(group) = particle.term, group.compositor == .all {
|
||||
allStructureErrors(group, children: children, groupOptional: particle.occurrenceRange.minimum.isZero, at: path, into: &errors)
|
||||
} else {
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -7,26 +7,20 @@ extension PureXML.Schema.ComplexValidator {
|
||||
for child: PureXML.Model.Element,
|
||||
namespaceBindings: [String: String] = [:],
|
||||
at path: XSDPath,
|
||||
into errors: inout [XSDFinding],
|
||||
into errors: inout [XSDFailure],
|
||||
) -> 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(XSDFinding(
|
||||
.xsiTypeOverrideResolves,
|
||||
reason: "xsi:type '\(overriding)' is not validly derived from the declared type of '\(child.name.localName)'",
|
||||
at: path,
|
||||
))
|
||||
errors.append(XSDFailure(reason: "xsi:type '\(overriding)' is not validly derived from the declared type of '\(child.name.localName)'", at: path))
|
||||
return nil
|
||||
}
|
||||
errors.append(XSDFinding(.xsiTypeOverrideResolves, reason: "unknown xsi:type '\(overriding)' on '\(child.name.localName)'", at: path))
|
||||
errors.append(XSDFailure(reason: "unknown xsi:type '\(overriding)' on '\(child.name.localName)'", at: path))
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -38,9 +32,7 @@ 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
|
||||
|
||||
@ -140,9 +140,7 @@ 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
|
||||
}
|
||||
@ -205,9 +203,7 @@ 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))
|
||||
}
|
||||
|
||||
|
||||
@ -63,12 +63,8 @@ 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)
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
/// 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). Module-internal so the
|
||||
/// conflict tests (``CompConflict``) can live in their own file.
|
||||
struct CompItem: Hashable {
|
||||
/// not label alone, decides conflict (per the UPA semantics). File-private.
|
||||
private struct CompItem: Hashable {
|
||||
let particle: ObjectIdentifier
|
||||
let label: PureXML.Schema.TermLabel
|
||||
|
||||
@ -44,20 +43,7 @@ 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)
|
||||
// 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: []) })
|
||||
let summary = walk.sequence(models.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).
|
||||
@ -65,13 +51,8 @@ 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 = CompConflict.overlap(set) {
|
||||
if let conflict = CompWalk.overlap(set) {
|
||||
return "ambiguous content model: '\(conflict)' can be matched by more than one particle"
|
||||
}
|
||||
}
|
||||
@ -87,110 +68,24 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"
|
||||
if set.count > 1 { decisionSets.append(set) }
|
||||
}
|
||||
|
||||
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: [])
|
||||
}
|
||||
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])))
|
||||
// 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) }
|
||||
}
|
||||
return looped
|
||||
return CompSummary(nullable: minimum == 0 || body.nullable, first: body.first, lastFollow: body.lastFollow)
|
||||
}
|
||||
|
||||
private mutating func bodySummary(_ node: PureXML.Model.TreeNode, visiting: Set<String>) -> CompSummary {
|
||||
@ -226,57 +121,37 @@ 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 suffix: [CompItem] = []
|
||||
var suffixFirst: [CompItem] = []
|
||||
for next in parts[(index + 1)...] {
|
||||
suffix.append(contentsOf: next.first)
|
||||
if !next.nullable {
|
||||
break
|
||||
}
|
||||
suffixFirst.append(contentsOf: next.first)
|
||||
if !next.nullable { break }
|
||||
}
|
||||
suffixFirst[index] = suffix
|
||||
}
|
||||
for index in parts.indices where !suffixFirst[index].isEmpty {
|
||||
guard !suffixFirst.isEmpty else { continue }
|
||||
for entry in parts[index].lastFollow {
|
||||
emit(merge(entry.follow, suffixFirst[index]))
|
||||
emit(entry.follow + suffixFirst)
|
||||
}
|
||||
}
|
||||
var prefixFirsts: [[CompItem]] = []
|
||||
var first: [CompItem] = []
|
||||
for part in parts {
|
||||
prefixFirsts.append(part.first)
|
||||
if !part.nullable {
|
||||
break
|
||||
}
|
||||
first.append(contentsOf: part.first)
|
||||
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
|
||||
}
|
||||
var lastFollow: [(item: CompItem, follow: [CompItem])] = []
|
||||
for part in parts.reversed() {
|
||||
lastFollow.append(contentsOf: part.lastFollow)
|
||||
if !part.nullable { break }
|
||||
}
|
||||
return CompSummary(nullable: parts.allSatisfy(\.nullable), first: mergeList(prefixFirsts), lastFollow: mergeLastFollow(trailing))
|
||||
return CompSummary(nullable: parts.allSatisfy(\.nullable), first: first, lastFollow: lastFollow)
|
||||
}
|
||||
|
||||
private mutating func choice(_ parts: [CompSummary]) -> CompSummary {
|
||||
let first = mergeList(parts.map(\.first))
|
||||
let first = parts.flatMap(\.first)
|
||||
emit(first)
|
||||
return CompSummary(
|
||||
nullable: parts.isEmpty || parts.contains(where: \.nullable),
|
||||
first: first,
|
||||
lastFollow: mergeLastFollow(parts.flatMap(\.lastFollow)),
|
||||
lastFollow: parts.flatMap(\.lastFollow),
|
||||
)
|
||||
}
|
||||
|
||||
@ -286,31 +161,27 @@ 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 = mergeList(parts.map(\.first))
|
||||
let first = parts.flatMap(\.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, merge(entry.follow, others)))
|
||||
lastFollow.append((entry.item, entry.follow + others))
|
||||
}
|
||||
}
|
||||
return CompSummary(
|
||||
nullable: parts.isEmpty || parts.allSatisfy(\.nullable),
|
||||
first: first,
|
||||
lastFollow: mergeLastFollow(lastFollow),
|
||||
lastFollow: 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
|
||||
@ -347,6 +218,31 @@ 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 {
|
||||
@ -367,23 +263,20 @@ private struct CompWalk {
|
||||
return PureXML.Model.QualifiedName(localName: qname, namespaceURI: bindings[""])
|
||||
}
|
||||
|
||||
// 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])
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,14 +3,10 @@ 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
|
||||
}
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
/// Orders the model groups reachable from a set of content models so each group
|
||||
/// comes after every group it references. The compositional determinism check
|
||||
/// summarizes groups in this order, so its recursive walk is always a cache hit at a
|
||||
/// `<group ref>` and never recurses through a reference chain. A chain is FLAT in the
|
||||
/// schema document (group definitions are siblings, not nested), so it is not bounded
|
||||
/// by the parser's `maxDepth`; ordering and scanning here are iterative so neither
|
||||
/// step recurses on the chain length either.
|
||||
enum GroupDependencyOrder {
|
||||
/// Reachable group names in dependency order (dependencies first), by an iterative
|
||||
/// post-order DFS from the groups the `models` reference. A name already on the
|
||||
/// current path is not re-entered, so a circular reference (illegal, rejected
|
||||
/// elsewhere) terminates.
|
||||
static func reachable(from models: [PureXML.Model.TreeNode], context: PureXML.Schema.XSDContext) -> [String] {
|
||||
var roots: [String] = []
|
||||
var seen: Set<String> = []
|
||||
for model in models {
|
||||
for ref in referencedGroupNames(in: model) where seen.insert(ref).inserted {
|
||||
roots.append(ref)
|
||||
}
|
||||
}
|
||||
var order: [String] = []
|
||||
var visited: Set<String> = []
|
||||
var onPath: Set<String> = []
|
||||
var work: [(name: String, exit: Bool)] = roots.reversed().map { ($0, false) }
|
||||
while let (name, exit) = work.popLast() {
|
||||
if exit {
|
||||
onPath.remove(name)
|
||||
order.append(name)
|
||||
continue
|
||||
}
|
||||
if visited.contains(name) {
|
||||
continue
|
||||
}
|
||||
visited.insert(name)
|
||||
onPath.insert(name)
|
||||
work.append((name, true))
|
||||
for ref in dependencies(of: name, context: context).reversed() where !visited.contains(ref) && !onPath.contains(ref) {
|
||||
work.append((ref, false))
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
/// The group names referenced within `node`, by an iterative tree walk (so scanning
|
||||
/// a deep model does not itself recurse on depth). It descends compositors but stops
|
||||
/// at an `<element>`, exactly as the determinism walk's `summarize` treats an element
|
||||
/// as terminal: a group inside an element's inline complex type belongs to THAT type's
|
||||
/// own content model (checked separately), not this one, and must not be drawn in.
|
||||
private static func referencedGroupNames(in node: PureXML.Model.TreeNode) -> [String] {
|
||||
var names: [String] = []
|
||||
var stack = [node]
|
||||
while let current = stack.popLast() {
|
||||
let local = PureXML.Schema.XSDNode.localName(current)
|
||||
if local == "group", let ref = PureXML.Schema.XSDNode.attribute(current, "ref") {
|
||||
names.append(PureXML.Schema.XSDNode.stripPrefix(ref))
|
||||
}
|
||||
if local != "element" {
|
||||
stack.append(contentsOf: PureXML.Schema.XSDNode.elementChildren(current))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/// The groups `name` references. A redefined group's base-model references are added
|
||||
/// ONLY when the redefinition actually self-references (`<group ref=name>` inside the
|
||||
/// redefinition of `name`): that reference expands to the base model
|
||||
/// (`CompWalk.redefineSelfReference`), making the base's groups part of the effective
|
||||
/// content model, so they must be ordered first. When the redefinition does NOT
|
||||
/// self-reference, the base is fully replaced and dead, and pulling its refs in would
|
||||
/// summarise unreachable groups: surfacing a conflict in a dead group is a false
|
||||
/// positive, so it is deliberately excluded. This keeps the reachable set identical
|
||||
/// to the lazy walk's.
|
||||
private static func dependencies(of name: String, context: PureXML.Schema.XSDContext) -> [String] {
|
||||
guard let definition = context.groups[name], let model = modelChild(definition) else { return [] }
|
||||
var refs = referencedGroupNames(in: model)
|
||||
if refs.contains(name), context.redefinedGroups.contains(name), let base = context.baseGroups[name], let baseModel = modelChild(base) {
|
||||
refs += referencedGroupNames(in: baseModel)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/// The `sequence`/`choice`/`all` model child of a group definition, if any.
|
||||
private static func modelChild(_ definition: PureXML.Model.TreeNode) -> PureXML.Model.TreeNode? {
|
||||
PureXML.Schema.XSDNode.elementChildren(definition)
|
||||
.first { ["sequence", "choice", "all"].contains(PureXML.Schema.XSDNode.localName($0) ?? "") }
|
||||
}
|
||||
}
|
||||
@ -108,11 +108,7 @@ extension PureXML.Schema {
|
||||
var nullable = true
|
||||
for member in particles {
|
||||
let part = particle(member)
|
||||
if let previous = last {
|
||||
addEpsilon(from: previous, to: part.start)
|
||||
} else {
|
||||
first = part.start
|
||||
}
|
||||
if let previous = last { addEpsilon(from: previous, to: part.start) } else { first = part.start }
|
||||
last = part.accept
|
||||
fragmentCounters.append(contentsOf: part.counters)
|
||||
nullable = nullable && part.nullable
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
extension PureXML.Schema {
|
||||
/// A call edge: enter the callee sub-program at `calleeStart`, returning to
|
||||
/// `returnTo` when the callee's return state is reached. The `actions` reset the
|
||||
/// callee's counter scopes on entry (so a shared fragment entered twice does not
|
||||
/// carry a stale count); `guards` are the calling occurrence's `belowMaximum`.
|
||||
struct ContentCallEdge: Sendable {
|
||||
var calleeStart: Int
|
||||
var returnTo: Int
|
||||
var guards: [ContentCounterGuard] = []
|
||||
var actions: [ContentCounterAction] = []
|
||||
}
|
||||
|
||||
/// One state of the counted Recursive Transition Network: like ``ContentState``
|
||||
/// but a state may also `call` a shared group fragment, and a group fragment's
|
||||
/// accept is a `return` (it pops the graph-structured stack to its caller).
|
||||
struct ContentRTNState: Sendable {
|
||||
var epsilon: [ContentEpsilonEdge] = []
|
||||
var calls: [ContentCallEdge] = []
|
||||
/// A group fragment's accept: pop the GSS and continue at the caller's
|
||||
/// `returnTo`. The top-level program accept is NOT a return.
|
||||
var isReturn: Bool = false
|
||||
var label: TermLabel?
|
||||
var target: Int?
|
||||
var elementType: ElementType?
|
||||
var valueConstraint: ValueConstraint?
|
||||
}
|
||||
|
||||
/// A counted RTN compiled from a ``Particle``: each `<xs:group>` definition
|
||||
/// (identified by ``Group/definitionID``) is compiled once and reached by a
|
||||
/// `call`, so a multiply-referenced group is shared, not inlined `2^K` times.
|
||||
/// `all` groups are validated separately and are not represented here, exactly
|
||||
/// as in ``ContentNFA``.
|
||||
struct ContentRTN: Sendable {
|
||||
var states: [ContentRTNState]
|
||||
var counters: [ContentCounterScope]
|
||||
var start: Int
|
||||
var accept: Int
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Schema {
|
||||
/// Builds a counted ``ContentRTN`` from a particle, sharing each `definitionID`
|
||||
/// group fragment. The occurrence-wrapper shape (reset/loop/exit/increment)
|
||||
/// matches ``ContentNFABuilder`` exactly; the only difference is that a group
|
||||
/// with a `definitionID` compiles to a `call` into its shared fragment instead
|
||||
/// of being inlined.
|
||||
struct ContentRTNBuilder {
|
||||
private var states: [ContentRTNState] = []
|
||||
private var counters: [ContentCounterScope] = []
|
||||
/// The compiled (start, accept, counters) of each shared group fragment.
|
||||
private var fragments: [Int: ContentNFAFragment] = [:]
|
||||
/// Definition ids whose fragment is mid-compilation, to detect a (malformed)
|
||||
/// cyclic group definition instead of recurring without bound.
|
||||
private var compiling: Set<Int> = []
|
||||
|
||||
static func build(_ particle: PureXML.Schema.Particle) -> ContentRTN {
|
||||
var builder = ContentRTNBuilder()
|
||||
let fragment = builder.particle(particle)
|
||||
return ContentRTN(
|
||||
states: builder.states,
|
||||
counters: builder.counters,
|
||||
start: fragment.start,
|
||||
accept: fragment.accept,
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func addState() -> Int {
|
||||
states.append(ContentRTNState())
|
||||
return states.count - 1
|
||||
}
|
||||
|
||||
private mutating func addEpsilon(
|
||||
from source: Int,
|
||||
to target: Int,
|
||||
guards: [ContentCounterGuard] = [],
|
||||
actions: [ContentCounterAction] = [],
|
||||
) {
|
||||
states[source].epsilon.append(ContentEpsilonEdge(target: target, guards: guards, actions: actions))
|
||||
}
|
||||
|
||||
private mutating func particle(_ particle: PureXML.Schema.Particle) -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let loop = addState()
|
||||
let accept = addState()
|
||||
let counter = counters.count
|
||||
let bodyNullable = termIsNullable(particle.term)
|
||||
counters.append(ContentCounterScope(range: particle.occurrenceRange, nullableBody: bodyNullable))
|
||||
let body = term(particle.term)
|
||||
let resetAll = [ContentCounterAction.reset(counter)] + body.counters.map(ContentCounterAction.reset)
|
||||
addEpsilon(from: start, to: loop, actions: resetAll)
|
||||
addEpsilon(
|
||||
from: loop,
|
||||
to: accept,
|
||||
guards: [.canExit(counter: counter, minimum: particle.occurrenceRange.minimum, nullableBody: bodyNullable)],
|
||||
)
|
||||
addEpsilon(
|
||||
from: loop,
|
||||
to: body.start,
|
||||
guards: [.belowMaximum(counter: counter, maximum: particle.occurrenceRange.maximum)],
|
||||
actions: body.counters.map(ContentCounterAction.reset),
|
||||
)
|
||||
addEpsilon(from: body.accept, to: loop, actions: [.increment(counter)])
|
||||
return ContentNFAFragment(start: start, accept: accept, counters: [counter] + body.counters, nullable: particleIsNullable(particle))
|
||||
}
|
||||
|
||||
private mutating func term(_ term: PureXML.Schema.Term) -> ContentNFAFragment {
|
||||
switch term {
|
||||
case let .element(name, type, _, constraint, _, _):
|
||||
let fragment = labeled(.name(name))
|
||||
states[fragment.start].elementType = type
|
||||
states[fragment.start].valueConstraint = constraint
|
||||
return fragment
|
||||
case let .wildcard(wildcard):
|
||||
return labeled(.wildcard(wildcard))
|
||||
case let .group(group):
|
||||
if let id = group.definitionID {
|
||||
return call(group, id: id)
|
||||
}
|
||||
return self.group(group)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to a shared group: a `call` state into the group's fragment
|
||||
/// (compiled once), returning to a fresh accept. The callee's counters are
|
||||
/// deliberately NOT propagated to the caller: every counter scope is reset on
|
||||
/// its own particle's entry edge, which is on the path from the callee's start,
|
||||
/// so the shared fragment resets itself on every (re-)entry without the caller
|
||||
/// re-listing it. Propagating them would re-list each shared fragment's counters
|
||||
/// at every reference, nesting to a `2^K` reset-action list even though the
|
||||
/// states stay `O(K)`, which is the whole point of sharing.
|
||||
private mutating func call(_ group: PureXML.Schema.Group, id: Int) -> ContentNFAFragment {
|
||||
let fragment = groupFragment(group, id: id)
|
||||
let callState = addState()
|
||||
let returnState = addState()
|
||||
states[callState].calls.append(ContentCallEdge(calleeStart: fragment.start, returnTo: returnState))
|
||||
return ContentNFAFragment(start: callState, accept: returnState, counters: [], nullable: fragment.nullable)
|
||||
}
|
||||
|
||||
/// The shared fragment for a group definition, compiled once and memoized.
|
||||
/// Its accept is marked `isReturn`.
|
||||
private mutating func groupFragment(_ group: PureXML.Schema.Group, id: Int) -> ContentNFAFragment {
|
||||
if let cached = fragments[id] {
|
||||
return cached
|
||||
}
|
||||
// `definitionID` groups are acyclic: XSD forbids a group referencing itself
|
||||
// except through an element, and the parser's expansion breaks every cycle at
|
||||
// an element boundary, so a fragment is never referenced during its own
|
||||
// compilation (the inlining `ContentNFABuilder` relies on the same property).
|
||||
// Guard it anyway: a cyclic id would otherwise recur without bound, the exact
|
||||
// interactive-safety hazard this matcher exists to remove, so bound the
|
||||
// recursion by degrading a (malformed, unreachable) cycle to a terminating
|
||||
// empty fragment rather than overflowing the stack.
|
||||
guard compiling.insert(id).inserted else { return labeledEmpty() }
|
||||
defer { compiling.remove(id) }
|
||||
let built = group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
|
||||
states[built.accept].isReturn = true
|
||||
fragments[id] = built
|
||||
return built
|
||||
}
|
||||
|
||||
private mutating func group(_ group: PureXML.Schema.Group) -> ContentNFAFragment {
|
||||
group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
|
||||
}
|
||||
|
||||
private mutating func labeled(_ label: TermLabel) -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
states[start].label = label
|
||||
states[start].target = accept
|
||||
return ContentNFAFragment(start: start, accept: accept, counters: [], nullable: false)
|
||||
}
|
||||
|
||||
private mutating func sequence(_ particles: [PureXML.Schema.Particle]) -> ContentNFAFragment {
|
||||
guard !particles.isEmpty else { return labeledEmpty() }
|
||||
var first: Int?
|
||||
var last: Int?
|
||||
var fragmentCounters: [Int] = []
|
||||
var nullable = true
|
||||
for member in particles {
|
||||
let part = particle(member)
|
||||
if let previous = last {
|
||||
addEpsilon(from: previous, to: part.start)
|
||||
} else {
|
||||
first = part.start
|
||||
}
|
||||
last = part.accept
|
||||
fragmentCounters.append(contentsOf: part.counters)
|
||||
nullable = nullable && part.nullable
|
||||
}
|
||||
return ContentNFAFragment(start: first ?? addState(), accept: last ?? addState(), counters: fragmentCounters, nullable: nullable)
|
||||
}
|
||||
|
||||
private mutating func choice(_ particles: [PureXML.Schema.Particle]) -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
var fragmentCounters: [Int] = []
|
||||
var nullable = false
|
||||
for member in particles {
|
||||
let part = particle(member)
|
||||
addEpsilon(from: start, to: part.start)
|
||||
addEpsilon(from: part.accept, to: accept)
|
||||
fragmentCounters.append(contentsOf: part.counters)
|
||||
nullable = nullable || part.nullable
|
||||
}
|
||||
return ContentNFAFragment(start: start, accept: accept, counters: fragmentCounters, nullable: nullable)
|
||||
}
|
||||
|
||||
private mutating func labeledEmpty() -> ContentNFAFragment {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
addEpsilon(from: start, to: accept)
|
||||
return ContentNFAFragment(start: start, accept: accept, counters: [], nullable: true)
|
||||
}
|
||||
|
||||
private func particleIsNullable(_ particle: PureXML.Schema.Particle) -> Bool {
|
||||
particle.occurrenceRange.minimum.isZero || termIsNullable(particle.term)
|
||||
}
|
||||
|
||||
private func termIsNullable(_ term: PureXML.Schema.Term) -> Bool {
|
||||
switch term {
|
||||
case .element, .wildcard:
|
||||
false
|
||||
case let .group(group):
|
||||
switch group.compositor {
|
||||
case .sequence, .all:
|
||||
group.particles.allSatisfy(particleIsNullable)
|
||||
case .choice:
|
||||
group.particles.contains(where: particleIsNullable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user