Compare commits

..

No commits in common. "main" and "subagent-Adversarial-Critic-self-c52a73f7" have entirely different histories.

790 changed files with 6875 additions and 49093 deletions

View File

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

View File

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

View File

@ -1,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:

View File

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

View File

@ -1 +0,0 @@
2.14.0

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -25,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"),
],

View File

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

View File

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

View File

@ -1,11 +1,10 @@
# PureXML
[![Style and namespacing](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/style.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=style.yml)
[![Swift macOS](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-macos.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-macos.yml)
[![Swift Linux](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-linux.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-linux.yml)
[![Swift Windows](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-windows.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-windows.yml)
[![Swift WASM](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-wasm.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-wasm.yml)
[![XSTS conformance](https://git.aleahim.com/NodetteHQ/PureXML/actions/workflows/swift-xsts.yml/badge.svg?branch=main)](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-xsts.yml)
[![Style and namespacing](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml)
[![Swift macOS](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml)
[![Swift Linux](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml)
[![Swift Windows](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml)
[![Swift WASM](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
@ -72,13 +71,9 @@ are all shipped (summarized above). The beyond-parity libxml2 conformance audit
(#71), its parser-breadth sub-epic (#74), and the parity-frontier epic (#105:
output encodings, streaming validation, the last encodings, the analysis
findings, and the first W3C-suite pass) are all **complete**. Feature parity is
done; the remaining distance is **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.
done; the remaining distance is corpus-clean parity, tracked under two epics:
**#121** (the rest of the W3C xmlconf archive, including the DTD-validity layer)
and **#122** (the official spec suites for the schema and transform stack).
```mermaid
flowchart TB
@ -103,22 +98,16 @@ flowchart TB
NSConstraints["#136 namespace constraints: complete"]:::done
EncodingMismatch["#137 encoding-declaration mismatches: complete"]:::done
BaseURI["#138 per-entity base URIs: complete"]:::done
Epic122["#122 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
Epic122["#122 official spec suites"]:::epic
XSTS["#129 W3C XSD test suite (XSTS): first measurement 2026-06-13 baselined (deviations 75/2467/604/582); occurrence-explosion and reversed-range crashes fixed, suite green in 8s"]:::review
XSLTTS["#130 XSLT 1.0 suite (xalan-test): runner + baselines in, burning down (125)"]:::review
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
CrossDoc161["#161 cross-document composition"]:::todo
Gates145["#4 / #5 XSTS gates: burn-down 8 and 4 to 0"]:::review
Loc169["located schema diagnostics (shipped)"]:::done
Epic195["#195 xsi:type and substitution-group derivation correctness"]:::epic
Epic105 --> Epic121
Epic105 --> Epic122
Epic121 --> Oasis
@ -135,21 +124,13 @@ flowchart TB
Epic122 --> XSLTTS
Epic122 --> RNGTS
Epic122 --> C14NTS
Epic122 --> Epic167
Epic167 --> Oracle171
Epic167 --> CrossDoc161
Epic167 --> Gates145
Epic167 --> Loc169
Epic167 --> Epic195
```
## Status
**Current focus:** production readiness for IDE use ([#3](https://git.aleahim.com/NodetteHQ/PureXML/issues/3)). XSTS settled baselines (valid schemas rejected / invalid schemas accepted / valid instances rejected / invalid instances accepted), enforced in `Tests/XSTSSuiteTests.swift`: **0 / 8 / 0 / 4**. See [`docs/roadmap.md`](docs/roadmap.md) for milestones and open tasks.
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **1448 tests in 258 suites** (`swift test`).
currently runs **986 tests in 169 suites** (`swift test`).
### Shipped (libxml2-aligned surface)
@ -162,10 +143,8 @@ currently runs **1448 tests in 258 suites** (`swift test`).
(libxml2 `xmlTextReader`). Handles elements, attributes, text, predefined and
numeric entities, comments, CDATA, and processing instructions. Namespace
bindings (`xmlns`) are resolved to URIs on parse.
- **Encoding**: UTF-8/16/32 (with BOM sniffing) from raw bytes or streaming byte
input, plus the ISO-8859 single-byte family, the Windows code pages
(1250-1258), KOI8-R/U, and the CJK multi-byte encodings (Shift-JIS,
EUC-JP/KR/TW, GBK, GB18030, Big5, ISO-2022-JP).
- **Encoding**: UTF-8/16/32 (with BOM sniffing), ISO-8859-1, and Windows-1252
from raw bytes or streaming byte input.
- **DTD (opt-in)**: `<!DOCTYPE>` is **off by default** (`Limits.allowDoctype:
false`) to keep XXE and entity-expansion closed. When enabled, the internal
subset is parsed; internal general entities expand with a bounded cap; external
@ -214,23 +193,25 @@ subset validation or internal entities, and wire a resolver only for trusted,
in-memory replacements. Network fetching (`nanohttp`/`nanoftp`) and libxml2's
threading/memory infrastructure are deliberate non-goals.
### Remaining work
### Remaining toward full libxml2 feature parity
Feature parity with libxml2's documented surface is **complete**: the core I/O
stack, the query and transform layers, all four schema languages, the validation
framework, editor integration, and the encoding set (the ISO-8859 family, the
Windows code pages, and the CJK multi-byte encodings) are shipped. The
beyond-parity audit epics (**#71**, its parser-breadth sub-epic **#74**) and the
parity-frontier epic (**#105**) are closed.
The original parity epics (the core I/O stack, query, transform, the schema
languages, the validation framework, and editor integration) are all shipped. The
remaining work is the depth gaps a read-only conformance audit surfaced, tracked
under epic **#71** (and its parser-breadth sub-epic **#74**):
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).
| Open work | Issues | What it adds |
|---|---|---|
| XSLT 1.0 breadth | #81 #82 | Missing top-level elements and functions, html output |
| HTML5 | #83 #84 | Full tree construction, tokenizer states, entity table |
| C14N | #85 | Node-subset canonicalization, `xml:*` inheritance, 2.0 prefix rewrite |
| Legacy encodings | #97 #99 #102 #103 | Single-byte, CJK multi-byte, full GB18030, and Big5 to-Unicode tables |
| Validator decomposition | #101 | Inner XSD/DTD validators as composable rules |
| Partial follow-ups | #79 #80 #86 #87 #88 #90 #91 | Remaining bullets on already-advanced areas |
Each open issue carries a checklist of done vs remaining bullets. The encoding
tables (#97, #99) are deferred until they can be vendored from authoritative
Unicode mapping files rather than transcribed.
## Usage
@ -293,9 +274,6 @@ That command expands to:
bash scripts/check-style.sh
bash scripts/check-namespacing.sh
bash scripts/check-forbidden-patterns.sh
bash scripts/check-validation-coverage.sh
bash scripts/check-validation-fields.sh
bash scripts/check-changelog-touched.sh
swiftformat . --config .swiftformat --lint
swiftlint --config .swiftlint.yml --strict
swift build

View File

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

View File

@ -2,8 +2,8 @@
Need help with PureXML? Here is where to go.
- **Questions and ideas**: open 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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -85,9 +85,9 @@ public extension PureXML.Emitting {
? Escaping.text(value, asciiOnly: options.asciiOnly, escapeCarriageReturn: options.textEscaping.escapesCarriageReturn)
: "<![CDATA[\(value)]]>"
case let .comment(value):
output += "<!--\(Escaping.comment(value))-->"
output += "<!--\(value)-->"
case let .processingInstruction(target, data):
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(Escaping.processingInstruction(data))?>"
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
}
}
@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,103 +5,57 @@ 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))-->"
"<!--\(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() {
return " \(name)"
}
let escaped = Elements.uriAttributes.contains(name.lowercased())
? escapeURI(attribute.value)
: escapeAttribute(attribute.value)
return " \(name)=\"\(escaped)\""
}
/// Escapes a URI-valued attribute value (XSLT 1.0 16.2, HTML 4.01 B.2.1,
/// the libxml2 HTML output model): each non-ASCII or control character, and
/// every space (leading ones included, where libxml2 skips them), is
/// percent-escaped as the uppercase hex of its UTF-8 bytes, while `"` and
/// `&` keep their entity form. A parse-then-serialize round-trip thus
/// normalizes a space or non-ASCII byte in these attributes to `%HH`; this
/// is idempotent, since a literal `%` is left as is.
private static func escapeURI(_ value: String) -> String {
var result = ""
for scalar in value.unicodeScalars {
if scalar.value > 0x20, scalar.value < 0x7F {
switch scalar {
case "\"": result += "&quot;"
case "&": result += "&amp;"
default: result.unicodeScalars.append(scalar)
}
} else {
for byte in String(scalar).utf8 {
result += "%" + hexByte(byte)
}
}
}
return result
}
/// A byte as two uppercase hex digits, without Foundation formatting.
private static func hexByte(_ byte: UInt8) -> String {
let digits = Array("0123456789ABCDEF")
return String([digits[Int(byte >> 4)], digits[Int(byte & 0x0F)]])
return " \(name)=\"\(escapeAttribute(attribute.value))\""
}
private static func escapeText(_ value: String) -> String {
@ -131,14 +85,11 @@ extension PureXML.HTML {
case "&":
previousWasAmpersand = true
case "\"": result += "&quot;"
// The html output method does not escape `<` (or `>`) in an
// attribute value, unlike the xml method (XSLT 1.0 16.2).
case "<": result += "&lt;"
default: result += latin1Entity(scalar) ?? String(Character(scalar))
}
}
if previousWasAmpersand {
result += "&amp;"
}
if previousWasAmpersand { result += "&amp;" }
return result
}
@ -190,12 +141,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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}
}
@ -223,46 +182,32 @@ private struct EntityExpander {
mutating func expand(_ raw: String, visiting: Set<String>, counts: Bool) throws {
var iterator = raw.startIndex
while iterator < raw.endIndex {
// Copy the literal run up to the next reference or markup character
// in bulk rather than one character at a time: only '&' (a
// reference) and '<' (a possible CDATA section) need per-character
// handling, and the text between them is the bulk of any run. The
// scan is at the byte level (both are ASCII, so a UTF-8 index is a
// valid string index), which avoids decoding every literal byte into
// a grapheme just to look for two markers.
guard let special = raw.utf8[iterator...].firstIndex(where: { $0 == 0x26 || $0 == 0x3C }) else {
try appendRun(raw[iterator...], counts: counts)
return
}
if special > iterator {
try appendRun(raw[iterator ..< special], counts: counts)
iterator = special
}
let character = raw[iterator]
if character == "<" {
// A CDATA section inside replacement text shields its content
// from reference recognition: copy it verbatim through the
// terminator. A '<' that does not open CDATA is literal content.
if raw[iterator...].hasPrefix("<![CDATA[") {
let openEnd = raw.index(iterator, offsetBy: 9)
var end = raw.endIndex
var cursor = openEnd
while cursor < raw.endIndex {
if raw[cursor...].hasPrefix("]]>") {
end = raw.index(cursor, offsetBy: 3)
break
}
cursor = raw.index(after: cursor)
// A CDATA section inside replacement text shields its content from
// reference recognition: copy it verbatim through the terminator.
if character == "<", raw[iterator...].hasPrefix("<![CDATA[") {
let openEnd = raw.index(iterator, offsetBy: 9)
var end = raw.endIndex
var cursor = openEnd
while cursor < raw.endIndex {
if raw[cursor...].hasPrefix("]]>") {
end = raw.index(cursor, offsetBy: 3)
break
}
try appendRun(raw[iterator ..< end], counts: counts)
iterator = end
continue
cursor = raw.index(after: cursor)
}
for verbatim in raw[iterator ..< end] {
try append(verbatim, counts: counts)
}
iterator = end
continue
}
guard character == "&" else {
try append(character, counts: counts)
iterator = raw.index(after: iterator)
continue
}
guard let semicolon = raw.utf8[iterator...].firstIndex(of: 0x3B) else {
guard let semicolon = raw[iterator...].firstIndex(of: ";") else {
throw PureXML.Parsing.ParseError.invalidReference(String(raw[iterator...]), mark)
}
let body = String(raw[raw.index(after: iterator) ..< semicolon])
@ -271,22 +216,6 @@ private struct EntityExpander {
}
}
/// Appends a verbatim run, charging its whole length against the
/// amplification budget at once when counting (the per-character
/// ``append(_:counts:)`` charges one at a time; for a run the effect is
/// identical, the budget reaching the same value and the same overrun
/// throwing, only without the per-character work).
private mutating func appendRun(_ run: Substring, counts: Bool) throws {
if counts {
let length = run.count
guard budget >= length else {
throw PureXML.Parsing.ParseError.amplificationLimitExceeded(mark)
}
budget -= length
}
result += run
}
private mutating func resolve(_ body: String, visiting: Set<String>, counts: Bool) throws {
if let predefined = Self.predefinedValue(body) {
try append(predefined, counts: counts)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
@ -96,11 +90,6 @@ extension PureXML.Parsing.EventReader {
/// (which is character data, not ignorable whitespace) inside element
/// content.
mutating func recordReferenceContentFindings(raw: String, decoded: String) {
// Element-content reference findings exist only against a declared
// content model. With no DTD (the common case) there is nothing to
// check, so return before scanning `raw` for '&' or rendering the
// element's qualified name.
guard !documentType.elementModels.isEmpty else { return }
guard raw.contains("&"), let current = open.last,
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description]
else { return }
@ -142,9 +131,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)
}

View File

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

View File

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

View File

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

View File

@ -1,8 +1,7 @@
public extension PureXML.Parsing {
/// A character encoding PureXML can decode from raw bytes. Covers the Unicode
/// transformation formats, the ISO-8859 single-byte family, the Windows code
/// pages (1250-1258), KOI8-R/U, and the CJK multi-byte encodings (Shift-JIS,
/// EUC-JP/KR/TW, GBK, GB18030, Big5, ISO-2022-JP), for libxml2 encoding parity.
/// A character encoding PureXML can decode from raw bytes. Covers the
/// Unicode transformation formats plus the single-byte encodings (ISO-8859-1,
/// ISO-8859-5/9/15, and Windows-1252), toward libxml2 encoding parity.
enum InputEncoding: String, Equatable, Sendable {
case utf8
case utf16BigEndian

View File

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

View File

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

View File

@ -30,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
}

View File

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

View File

@ -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 == ">" {

View File

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

View File

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

View File

@ -19,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]))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,20 +19,17 @@ extension PureXML.Regex {
var accept: Int
/// Whether the automaton matches the whole string (XSD patterns are
/// implicitly anchored at both ends). XSD regular expressions match a
/// sequence of Unicode code points, not Swift extended grapheme clusters.
/// implicitly anchored at both ends).
func matchesWhole(_ string: String) -> Bool {
var current = closure([start])
for scalar in string.unicodeScalars {
for character in string {
var next: Set<Int> = []
for state in current {
if let transition = states[state].transition, transition.charClass.matches(scalar) {
if let transition = states[state].transition, transition.charClass.matches(character) {
next.insert(transition.target)
}
}
if next.isEmpty {
return false
}
if next.isEmpty { return false }
current = closure(next)
}
return current.contains(accept)
@ -106,11 +103,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 +126,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
]
}

View File

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

View File

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

View File

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

View File

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

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