Compare commits
No commits in common. "main" and "subagent-Adversarial-Critic-self-c52a73f7" have entirely different histories.
main
...
subagent-A
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and ideas
|
||||
url: https://git.aleahim.com/NodetteHQ/PureXML/issues
|
||||
about: Ask questions, float ideas, or discuss design in an issue.
|
||||
url: https://github.com/mihaelamj/PureXML/discussions
|
||||
about: Ask questions, float ideas, or discuss design in Discussions.
|
||||
- name: Security issue
|
||||
url: https://git.aleahim.com/NodetteHQ/PureXML/src/branch/main/SECURITY.md
|
||||
url: https://github.com/mihaelamj/PureXML/blob/main/SECURITY.md
|
||||
about: Do not file security issues publicly. Follow the security policy instead.
|
||||
|
||||
27
.github/workflows/rules-swift.yml
vendored
27
.github/workflows/rules-swift.yml
vendored
@ -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
|
||||
7
.github/workflows/swift-windows.yml
vendored
7
.github/workflows/swift-windows.yml
vendored
@ -1,12 +1,9 @@
|
||||
name: Swift (Windows)
|
||||
|
||||
# Windows runs the full build and test suite but is the slowest CI job, so it
|
||||
# runs only when cutting a release (a `v*` tag push) or on demand, not on every
|
||||
# push and PR. This keeps per-PR CI fast; the badge reflects the Windows run as
|
||||
# of the last release.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
27
.github/workflows/swift-xsts.yml
vendored
27
.github/workflows/swift-xsts.yml
vendored
@ -1,27 +0,0 @@
|
||||
name: XSTS conformance
|
||||
|
||||
# Runs the W3C XML Schema Test Suite ratchet (Tests/XSTSSuiteTests.swift) on every
|
||||
# change, so a regression in the schema/instance validity baselines fails CI. The
|
||||
# corpus is extracted from the vendored archive (Tests/Fixtures/xsts), so this needs
|
||||
# no network. The gate is release-only because the debug run is far slower.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
xsts:
|
||||
name: XSTS conformance
|
||||
runs-on: ubuntu-latest
|
||||
container: swift:6.2
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Extract the vendored XSTS corpus
|
||||
run: XSTS_DEST=/tmp/xsts bash scripts/fetch-xsts.sh
|
||||
- name: Conformance ratchet
|
||||
run: XSTS_ROOT=/tmp/xsts/xmlschema2006-11-06 swift test -c release --filter XSTSSuiteTests
|
||||
@ -1 +0,0 @@
|
||||
2.14.0
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
43
AGENTS.md
43
AGENTS.md
@ -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
|
||||
|
||||
|
||||
845
CHANGELOG.md
845
CHANGELOG.md
File diff suppressed because one or more lines are too long
12
CLAUDE.md
12
CLAUDE.md
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -25,9 +25,6 @@ let package = Package(
|
||||
name: "PureXMLTests",
|
||||
dependencies: ["PureXML"],
|
||||
path: "Tests",
|
||||
// The vendored XSTS archive is consumed off-disk via XSTS_ROOT, never
|
||||
// from the test bundle, so keep it out of the copied resources.
|
||||
exclude: ["Fixtures/xsts"],
|
||||
resources: [
|
||||
.copy("Fixtures"),
|
||||
],
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
import PureXML
|
||||
import Testing
|
||||
|
||||
/// Exercises the public entry points in `API.swift` that the rest of the suite
|
||||
/// reaches only through subsystem types: the SAX and incremental-source parse
|
||||
/// overloads, the streaming `events` overloads, and HTML content-model validation.
|
||||
@Suite("Public entry points")
|
||||
struct PublicEntryPointTests {
|
||||
private let xml = #"<r a="1"><c>hi</c></r>"#
|
||||
|
||||
@Test("parse delivers SAX callbacks in document order")
|
||||
func test_parseSax() throws {
|
||||
var elements: [String] = []
|
||||
var text = ""
|
||||
let handler = PureXML.Parsing.SAXHandler(
|
||||
startElement: { name, _ in elements.append(name.localName) },
|
||||
characters: { text += $0 },
|
||||
)
|
||||
try PureXML.parse(xml, sax: handler)
|
||||
#expect(elements == ["r", "c"])
|
||||
#expect(text == "hi")
|
||||
}
|
||||
|
||||
@Test("parse from an incremental byte source yields the same tree as the string parse")
|
||||
func test_parsePullingBytes() throws {
|
||||
var bytes = Array(xml.utf8)[...]
|
||||
let streamed = try PureXML.parse(pullingBytes: { bytes.isEmpty ? nil : bytes.removeFirst() })
|
||||
#expect(try PureXML.serialize(streamed) == PureXML.serialize(PureXML.parse(xml)))
|
||||
}
|
||||
|
||||
@Test("every events overload streams the document to completion")
|
||||
func test_eventsOverloads() throws {
|
||||
var fromBytes = try PureXML.events(bytes: Array(xml.utf8))
|
||||
var byteEvents = 0
|
||||
while try fromBytes.next() != nil {
|
||||
byteEvents += 1
|
||||
}
|
||||
#expect(byteEvents > 0)
|
||||
|
||||
var chars = Array(xml)[...]
|
||||
var fromChars = PureXML.events(pulling: { chars.isEmpty ? nil : chars.removeFirst() })
|
||||
var charEvents = 0
|
||||
while try fromChars.next() != nil {
|
||||
charEvents += 1
|
||||
}
|
||||
#expect(charEvents == byteEvents)
|
||||
|
||||
var sourceBytes = Array(xml.utf8)[...]
|
||||
var fromPulledBytes = PureXML.events(pullingBytes: { sourceBytes.isEmpty ? nil : sourceBytes.removeFirst() })
|
||||
var pulledByteEvents = 0
|
||||
while try fromPulledBytes.next() != nil {
|
||||
pulledByteEvents += 1
|
||||
}
|
||||
#expect(pulledByteEvents == byteEvents)
|
||||
}
|
||||
|
||||
@Test("validateHTML flags a duplicate id")
|
||||
func test_validateHTML() throws {
|
||||
let duplicate = try PureXML.parse(#"<root><a id="d"></a><b id="d"></b></root>"#)
|
||||
#expect(!PureXML.validateHTML(duplicate).isEmpty)
|
||||
let unique = try PureXML.parse(#"<root><a id="d"></a><b id="e"></b></root>"#)
|
||||
#expect(PureXML.validateHTML(unique).allSatisfy { !$0.reason.lowercased().contains("id") })
|
||||
}
|
||||
}
|
||||
@ -39,9 +39,7 @@ struct PublicSurfaceTests {
|
||||
var chunks = ["<r><a>", "hello", "</a></r>"].makeIterator()
|
||||
var characters: [Character] = []
|
||||
var reader = PureXML.Parsing.EventReader(pulling: {
|
||||
if characters.isEmpty, let chunk = chunks.next() {
|
||||
characters = Array(chunk)
|
||||
}
|
||||
if characters.isEmpty, let chunk = chunks.next() { characters = Array(chunk) }
|
||||
return characters.isEmpty ? nil : characters.removeFirst()
|
||||
})
|
||||
var events = 0
|
||||
|
||||
88
README.md
88
README.md
@ -1,11 +1,10 @@
|
||||
# PureXML
|
||||
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=style.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-macos.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-linux.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-windows.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-wasm.yml)
|
||||
[](https://git.aleahim.com/NodetteHQ/PureXML/actions?workflow=swift-xsts.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml)
|
||||
[](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml)
|
||||
[](LICENSE)
|
||||
|
||||
PureXML is a dependency-free XML package written entirely in Swift.
|
||||
@ -20,7 +19,7 @@ intentionally strict about portability:
|
||||
- root Swift package layout
|
||||
- macOS, Linux, Windows, and WASI build gates
|
||||
|
||||
It is a sibling project to [PureYAML](https://git.aleahim.com/NodetteHQ/PureYAML) and
|
||||
It is a sibling project to [PureYAML](https://github.com/mihaelamj/PureYAML) and
|
||||
follows the same structure, rules, and verification gates.
|
||||
|
||||
## Roadmap
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
extension PureXML.Canonical.Canonicalizer {
|
||||
/// The apex element with its inherited `xml:*` attributes added, and (for
|
||||
/// inclusive mode) the namespaces in scope from omitted ancestors merged in
|
||||
/// as declarations so the apex renders its full namespace context.
|
||||
static func augmentedApex(
|
||||
_ apex: PureXML.Model.Element,
|
||||
inheritedXML: [PureXML.Model.Attribute],
|
||||
mergingNamespaces ancestorNamespaces: [String: String]?,
|
||||
stripApexBase: Bool,
|
||||
) -> PureXML.Model.Element {
|
||||
// Under 1.1, the apex's own xml:base is folded into the merged base in
|
||||
// inheritedXML, so drop the original to avoid emitting it twice.
|
||||
let own = stripApexBase
|
||||
? apex.attributes.filter { !($0.name.prefix == "xml" && $0.name.localName == "base") }
|
||||
: apex.attributes
|
||||
var attributes = own + inheritedXML
|
||||
if let ancestorNamespaces {
|
||||
let declared = Set(apex.attributes.map(\.name.description))
|
||||
for (prefix, uri) in ancestorNamespaces {
|
||||
let name = prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)"
|
||||
if !declared.contains(name) {
|
||||
attributes.append(PureXML.Model.Attribute(name, uri))
|
||||
}
|
||||
}
|
||||
}
|
||||
return PureXML.Model.Element(name: apex.name, attributes: attributes, children: apex.children)
|
||||
}
|
||||
|
||||
/// The namespace declarations in scope from a node's omitted ancestors,
|
||||
/// nearest ancestor winning (so an inner redeclaration is preserved).
|
||||
static func inScopeNamespaces(above node: PureXML.Model.TreeNode) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
var current = node.parent
|
||||
while let ancestor = current {
|
||||
for attribute in ancestor.attributes {
|
||||
guard let prefix = declaredPrefix(of: attribute.name), result[prefix] == nil else { continue }
|
||||
result[prefix] = attribute.value
|
||||
}
|
||||
current = ancestor.parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The `xml:*` attributes in scope from a node's omitted ancestors that the
|
||||
/// apex does not already set, nearest ancestor winning.
|
||||
static func inheritedXMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, mergeBase: Bool) -> [PureXML.Model.Attribute] {
|
||||
let present = Set(apex.attributes.map(\.name.description))
|
||||
if mergeBase {
|
||||
return inherited11XMLAttributes(above: node, apex: apex, present: present)
|
||||
}
|
||||
// Canonical XML 1.0: the nearest of every in-scope xml:* attribute the
|
||||
// apex does not already set.
|
||||
var seen: Set<String> = []
|
||||
var result: [PureXML.Model.Attribute] = []
|
||||
var current = node.parent
|
||||
while let ancestor = current {
|
||||
for attribute in ancestor.attributes where attribute.name.prefix == "xml" {
|
||||
let key = attribute.name.description
|
||||
guard !present.contains(key), seen.insert(key).inserted else { continue }
|
||||
result.append(attribute)
|
||||
}
|
||||
current = ancestor.parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The prefix a namespace declaration binds (empty for the default
|
||||
/// namespace), or nil when the attribute is not a namespace declaration.
|
||||
static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
|
||||
if name.prefix == nil, name.localName == "xmlns" {
|
||||
return ""
|
||||
}
|
||||
if name.prefix == "xmlns" {
|
||||
return name.localName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,3 @@
|
||||
/// A mutable bag of rebuilt nodes a branch's children gather into, so the
|
||||
/// canonical prefix rewrite's deferred build step assembles the parent once they
|
||||
/// are done.
|
||||
private final class CanonicalRewriteAccumulator {
|
||||
var nodes: [PureXML.Model.Node] = []
|
||||
}
|
||||
|
||||
/// One unit of canonical prefix-rewrite work: visit a source node (appending its
|
||||
/// rebuilt result to `target`), or, after a branch's children are gathered, build
|
||||
/// the element or document. File-scoped so the work stack is not a third level of
|
||||
/// type nesting.
|
||||
private enum CanonicalRewriteWork {
|
||||
case visit(PureXML.Model.Node, inScope: [String: String], isRoot: Bool, target: CanonicalRewriteAccumulator)
|
||||
case buildElement(PureXML.Model.Element, childInScope: [String: String], isRoot: Bool, gathered: CanonicalRewriteAccumulator, target: CanonicalRewriteAccumulator)
|
||||
case buildDocument(CanonicalRewriteAccumulator, CanonicalRewriteAccumulator)
|
||||
}
|
||||
|
||||
extension PureXML.Canonical {
|
||||
/// The Canonical XML 2.0 sequential prefix rewrite. Implemented as a pre-pass
|
||||
/// that rebuilds the tree using canonical prefixes (`n0`, `n1`, ... in
|
||||
@ -33,47 +16,41 @@ extension PureXML.Canonical {
|
||||
var assignment: [String: String] = [:]
|
||||
var order = 0
|
||||
assign(node, inScope: [:], labels: labels, &assignment, &order)
|
||||
return transform(node, assignment: assignment, labels: labels)
|
||||
return transform(node, inScope: [:], assignment: assignment, labels: labels, isRoot: true)
|
||||
}
|
||||
|
||||
// MARK: First pass: assign a canonical prefix per visibly-used namespace
|
||||
|
||||
private static func assign(
|
||||
_ root: PureXML.Model.Node,
|
||||
_ node: PureXML.Model.Node,
|
||||
inScope: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
_ assignment: inout [String: String],
|
||||
_ order: inout Int,
|
||||
) {
|
||||
// Iterative pre-order walk so a deeply-nested document does not overflow
|
||||
// the stack; children push reversed so namespaces are recorded in
|
||||
// document order, the order the canonical prefixes n0, n1, ... assign in.
|
||||
var stack: [(node: PureXML.Model.Node, inScope: [String: String])] = [(root, inScope)]
|
||||
while let (node, inScope) = stack.popLast() {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
for child in children.reversed() {
|
||||
stack.append((child, inScope))
|
||||
}
|
||||
case let .element(element):
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
record(element.name.namespaceURI, &assignment, &order)
|
||||
for attribute in element.attributes where attribute.name.prefix != nil {
|
||||
record(attribute.name.namespaceURI, &assignment, &order)
|
||||
}
|
||||
for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
|
||||
record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
|
||||
}
|
||||
record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
|
||||
for child in element.children.reversed() {
|
||||
stack.append((child, childInScope))
|
||||
}
|
||||
default:
|
||||
break
|
||||
switch node {
|
||||
case let .document(children):
|
||||
for child in children {
|
||||
assign(child, inScope: inScope, labels: labels, &assignment, &order)
|
||||
}
|
||||
case let .element(element):
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
record(element.name.namespaceURI, &assignment, &order)
|
||||
for attribute in element.attributes where attribute.name.prefix != nil {
|
||||
record(attribute.name.namespaceURI, &assignment, &order)
|
||||
}
|
||||
for attribute in element.attributes where isQNameAttribute(attribute.name, labels) {
|
||||
record(qnameNamespace(attribute.value, inScope: childInScope), &assignment, &order)
|
||||
}
|
||||
record(qnameNamespace(element.text(matching: labels), inScope: childInScope), &assignment, &order)
|
||||
for child in element.children {
|
||||
assign(child, inScope: childInScope, labels: labels, &assignment, &order)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,74 +73,48 @@ extension PureXML.Canonical {
|
||||
|
||||
// MARK: Second pass: rebuild with canonical prefixes
|
||||
|
||||
/// Rebuilds the tree with canonical prefixes without recursing on its depth.
|
||||
/// An explicit work stack threads the in-scope declarations and the root
|
||||
/// flag down per element, while each branch gathers its rebuilt children
|
||||
/// into a reference accumulator that a deferred build step assembles, so a
|
||||
/// deeply-nested document does not overflow the stack.
|
||||
private static func transform(
|
||||
_ root: PureXML.Model.Node,
|
||||
assignment: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
) -> PureXML.Model.Node {
|
||||
let result = CanonicalRewriteAccumulator()
|
||||
var stack: [CanonicalRewriteWork] = [.visit(root, inScope: [:], isRoot: true, target: result)]
|
||||
while let work = stack.popLast() {
|
||||
switch work {
|
||||
case let .visit(node, inScope, isRoot, target):
|
||||
visitForRewrite(node, inScope: inScope, isRoot: isRoot, target: target, stack: &stack)
|
||||
case let .buildElement(element, childInScope, isRoot, gathered, target):
|
||||
var attributes = isRoot ? canonicalDeclarations(assignment) : []
|
||||
for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
|
||||
attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
|
||||
}
|
||||
// A QName-labelled element's single text child is rewritten to a
|
||||
// canonical-prefixed QName, replacing its rebuilt children.
|
||||
let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
|
||||
target.nodes.append(.element(PureXML.Model.Element(
|
||||
name: rename(element.name, assignment: assignment),
|
||||
attributes: attributes,
|
||||
children: qualified.map { [PureXML.Model.Node.text($0)] } ?? gathered.nodes,
|
||||
)))
|
||||
case let .buildDocument(gathered, target):
|
||||
target.nodes.append(.document(gathered.nodes))
|
||||
}
|
||||
}
|
||||
return result.nodes.first ?? root
|
||||
}
|
||||
|
||||
/// Pushes a node's rebuild work: a document or element schedules its build
|
||||
/// step then its children (with the in-scope declarations extended), a leaf
|
||||
/// is appended to `target` unchanged.
|
||||
private static func visitForRewrite(
|
||||
_ node: PureXML.Model.Node,
|
||||
inScope: [String: String],
|
||||
assignment: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
isRoot: Bool,
|
||||
target: CanonicalRewriteAccumulator,
|
||||
stack: inout [CanonicalRewriteWork],
|
||||
) {
|
||||
) -> PureXML.Model.Node {
|
||||
switch node {
|
||||
case let .document(children):
|
||||
let gathered = CanonicalRewriteAccumulator()
|
||||
stack.append(.buildDocument(gathered, target))
|
||||
for child in children.reversed() {
|
||||
stack.append(.visit(child, inScope: inScope, isRoot: isRoot, target: gathered))
|
||||
}
|
||||
.document(children.map { transform($0, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot) })
|
||||
case let .element(element):
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
let gathered = CanonicalRewriteAccumulator()
|
||||
stack.append(.buildElement(element, childInScope: childInScope, isRoot: isRoot, gathered: gathered, target: target))
|
||||
for child in element.children.reversed() {
|
||||
stack.append(.visit(child, inScope: childInScope, isRoot: false, target: gathered))
|
||||
}
|
||||
.element(transform(element, inScope: inScope, assignment: assignment, labels: labels, isRoot: isRoot))
|
||||
default:
|
||||
target.nodes.append(node)
|
||||
node
|
||||
}
|
||||
}
|
||||
|
||||
private static func transform(
|
||||
_ element: PureXML.Model.Element,
|
||||
inScope: [String: String],
|
||||
assignment: [String: String],
|
||||
labels: [QNameAwareLabel],
|
||||
isRoot: Bool,
|
||||
) -> PureXML.Model.Element {
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
|
||||
var attributes = isRoot ? canonicalDeclarations(assignment) : []
|
||||
for attribute in element.attributes where declaredPrefix(of: attribute.name) == nil {
|
||||
attributes.append(rewrite(attribute, inScope: childInScope, assignment: assignment, labels: labels))
|
||||
}
|
||||
let children = element.children.map { transform($0, inScope: childInScope, assignment: assignment, labels: labels, isRoot: false) }
|
||||
let qualified = qname(element.text(matching: labels), inScope: childInScope, assignment: assignment)
|
||||
return PureXML.Model.Element(
|
||||
name: rename(element.name, assignment: assignment),
|
||||
attributes: attributes,
|
||||
children: qualified.map { [PureXML.Model.Node.text($0)] } ?? children,
|
||||
)
|
||||
}
|
||||
|
||||
/// One `xmlns:nK="uri"` declaration per assigned namespace, hoisted to the
|
||||
/// document element so every canonical prefix is in scope from the top.
|
||||
private static func canonicalDeclarations(_ assignment: [String: String]) -> [PureXML.Model.Attribute] {
|
||||
@ -208,12 +159,8 @@ extension PureXML.Canonical {
|
||||
}
|
||||
|
||||
private static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
|
||||
if name.prefix == nil, name.localName == "xmlns" {
|
||||
return ""
|
||||
}
|
||||
if name.prefix == "xmlns" {
|
||||
return name.localName
|
||||
}
|
||||
if name.prefix == nil, name.localName == "xmlns" { return "" }
|
||||
if name.prefix == "xmlns" { return name.localName }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
/// One step of the iterative canonical node-subset serialization (C14N over a
|
||||
/// selected subtree): a `TreeNode` to emit with its in-scope and already-rendered
|
||||
/// namespace context, or a deferred element close. The mutable-tree counterpart
|
||||
/// to ``CanonicalStep``.
|
||||
enum CanonicalSelectedStep {
|
||||
case open(PureXML.Model.TreeNode, inScope: [String: String], rendered: [String: String])
|
||||
case close(String)
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
/// One step of the iterative canonical serialization: a node to emit with its
|
||||
/// in-scope and already-rendered namespace context, or a deferred element close.
|
||||
enum CanonicalStep {
|
||||
case open(PureXML.Model.Node, inScope: [String: String], rendered: [String: String])
|
||||
case close(String)
|
||||
}
|
||||
@ -5,9 +5,7 @@ extension PureXML.Canonical.Canonicalizer {
|
||||
static func inherited11XMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, present: Set<String>) -> [PureXML.Model.Attribute] {
|
||||
var result: [PureXML.Model.Attribute] = []
|
||||
for local in ["lang", "space"] where !present.contains("xml:\(local)") {
|
||||
if let attribute = nearestXMLAttribute(local, above: node) {
|
||||
result.append(attribute)
|
||||
}
|
||||
if let attribute = nearestXMLAttribute(local, above: node) { result.append(attribute) }
|
||||
}
|
||||
if let base = mergedBase(above: node, apex: apex) {
|
||||
result.append(PureXML.Model.Attribute("xml:base", base))
|
||||
@ -96,35 +94,27 @@ extension PureXML.Canonical.Canonicalizer {
|
||||
var input = Substring(path)
|
||||
var output = ""
|
||||
while !input.isEmpty {
|
||||
if input.hasPrefix("../") {
|
||||
input = input.dropFirst(3)
|
||||
if input.hasPrefix("../") { input = input.dropFirst(3)
|
||||
continue
|
||||
}
|
||||
if input.hasPrefix("./") {
|
||||
input = input.dropFirst(2)
|
||||
if input.hasPrefix("./") { input = input.dropFirst(2)
|
||||
continue
|
||||
}
|
||||
if input.hasPrefix("/./") {
|
||||
input = "/" + input.dropFirst(3)
|
||||
if input.hasPrefix("/./") { input = "/" + input.dropFirst(3)
|
||||
continue
|
||||
}
|
||||
if input == "/." {
|
||||
input = "/"
|
||||
if input == "/." { input = "/"
|
||||
continue
|
||||
}
|
||||
if input.hasPrefix("/../") {
|
||||
input = "/" + input.dropFirst(4)
|
||||
if input.hasPrefix("/../") { input = "/" + input.dropFirst(4)
|
||||
removeLastSegment(&output)
|
||||
continue
|
||||
}
|
||||
if input == "/.." {
|
||||
input = "/"
|
||||
if input == "/.." { input = "/"
|
||||
removeLastSegment(&output)
|
||||
continue
|
||||
}
|
||||
if input == "." || input == ".." {
|
||||
break
|
||||
}
|
||||
if input == "." || input == ".." { break }
|
||||
let start = input.hasPrefix("/") ? input.index(after: input.startIndex) : input.startIndex
|
||||
let next = input[start...].firstIndex(of: "/") ?? input.endIndex
|
||||
output += input[..<next]
|
||||
@ -202,19 +192,11 @@ private struct URIParts {
|
||||
/// Recomposes the components into a URI string (RFC 3986 section 5.3).
|
||||
var recomposed: String {
|
||||
var result = ""
|
||||
if let scheme {
|
||||
result += scheme + ":"
|
||||
}
|
||||
if let authority {
|
||||
result += "//" + authority
|
||||
}
|
||||
if let scheme { result += scheme + ":" }
|
||||
if let authority { result += "//" + authority }
|
||||
result += path
|
||||
if let query {
|
||||
result += "?" + query
|
||||
}
|
||||
if let fragment {
|
||||
result += "#" + fragment
|
||||
}
|
||||
if let query { result += "?" + query }
|
||||
if let fragment { result += "#" + fragment }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,9 +68,7 @@ public extension PureXML.Canonical {
|
||||
for child in children {
|
||||
var rendering = ""
|
||||
emit(child, inScope: inScope, rendered: rendered, output: &rendering)
|
||||
if !rendering.isEmpty {
|
||||
renderings.append(rendering)
|
||||
}
|
||||
if !rendering.isEmpty { renderings.append(rendering) }
|
||||
}
|
||||
output += renderings.joined(separator: "\n")
|
||||
case let .element(element):
|
||||
@ -79,9 +77,7 @@ public extension PureXML.Canonical {
|
||||
let text = options.trimTextNodes ? value.trimmingXMLWhitespace() : value
|
||||
output += Self.escapeText(text)
|
||||
case let .comment(value):
|
||||
if options.includeComments {
|
||||
output += "<!--\(value)-->"
|
||||
}
|
||||
if options.includeComments { output += "<!--\(value)-->" }
|
||||
case let .processingInstruction(target, data):
|
||||
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
|
||||
}
|
||||
@ -93,35 +89,6 @@ public extension PureXML.Canonical {
|
||||
rendered: [String: String],
|
||||
output: inout String,
|
||||
) {
|
||||
// Deferred-close work stack so a deeply-nested element does not
|
||||
// overflow the stack; children push reversed to emit in document order.
|
||||
var stack: [CanonicalStep] = [.open(.element(element), inScope: inScope, rendered: rendered)]
|
||||
while let step = stack.popLast() {
|
||||
switch step {
|
||||
case let .close(name):
|
||||
output += "</\(name)>"
|
||||
case let .open(node, inScope, rendered):
|
||||
guard case let .element(element) = node else {
|
||||
emitLeaf(node, into: &output)
|
||||
continue
|
||||
}
|
||||
let child = emitOpenTag(element, inScope: inScope, rendered: rendered, into: &output)
|
||||
stack.append(.close(element.name.description))
|
||||
for childNode in element.children.reversed() {
|
||||
stack.append(.open(childNode, inScope: child.inScope, rendered: child.rendered))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits an element's start tag (namespaces then attributes, in canonical
|
||||
/// order) and returns the namespace context its children inherit.
|
||||
private func emitOpenTag(
|
||||
_ element: PureXML.Model.Element,
|
||||
inScope: [String: String],
|
||||
rendered: [String: String],
|
||||
into output: inout String,
|
||||
) -> (inScope: [String: String], rendered: [String: String]) {
|
||||
let declarations = Self.namespaceDeclarations(element)
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in declarations {
|
||||
@ -142,25 +109,10 @@ public extension PureXML.Canonical {
|
||||
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
|
||||
}
|
||||
output += ">"
|
||||
return (childInScope, childRendered)
|
||||
}
|
||||
|
||||
/// Emits a non-element node (text, CDATA, comment, or processing
|
||||
/// instruction) the same way the node-level `emit` does.
|
||||
private func emitLeaf(_ node: PureXML.Model.Node, into output: inout String) {
|
||||
switch node {
|
||||
case let .text(value), let .cdata(value):
|
||||
let text = options.trimTextNodes ? value.trimmingXMLWhitespace() : value
|
||||
output += Self.escapeText(text)
|
||||
case let .comment(value):
|
||||
if options.includeComments {
|
||||
output += "<!--\(value)-->"
|
||||
}
|
||||
case let .processingInstruction(target, data):
|
||||
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
|
||||
case .element, .document:
|
||||
break
|
||||
for child in element.children {
|
||||
emit(child, inScope: childInScope, rendered: childRendered, output: &output)
|
||||
}
|
||||
output += "</\(element.name.description)>"
|
||||
}
|
||||
|
||||
// MARK: Namespace selection
|
||||
@ -204,9 +156,7 @@ public extension PureXML.Canonical {
|
||||
for prefix in utilized {
|
||||
let uri = inScope[prefix] ?? ""
|
||||
if prefix.isEmpty {
|
||||
if uri != (rendered[""] ?? "") {
|
||||
result.append(("", uri))
|
||||
}
|
||||
if uri != (rendered[""] ?? "") { result.append(("", uri)) }
|
||||
} else if !uri.isEmpty, rendered[prefix] != uri {
|
||||
result.append((prefix, uri))
|
||||
}
|
||||
@ -223,21 +173,15 @@ public extension PureXML.Canonical {
|
||||
private static func attributeOrder(_ lhs: PureXML.Model.Attribute, _ rhs: PureXML.Model.Attribute) -> Bool {
|
||||
let leftURI = lhs.name.namespaceURI ?? ""
|
||||
let rightURI = rhs.name.namespaceURI ?? ""
|
||||
if leftURI != rightURI {
|
||||
return leftURI < rightURI
|
||||
}
|
||||
if leftURI != rightURI { return leftURI < rightURI }
|
||||
return lhs.name.localName < rhs.name.localName
|
||||
}
|
||||
|
||||
private static func namespaceDeclarations(_ element: PureXML.Model.Element) -> [(String, String)] {
|
||||
element.attributes.compactMap { attribute in
|
||||
let name = attribute.name
|
||||
if name.prefix == nil, name.localName == "xmlns" {
|
||||
return ("", attribute.value)
|
||||
}
|
||||
if name.prefix == "xmlns" {
|
||||
return (name.localName, attribute.value)
|
||||
}
|
||||
if name.prefix == nil, name.localName == "xmlns" { return ("", attribute.value) }
|
||||
if name.prefix == "xmlns" { return (name.localName, attribute.value) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -248,6 +192,77 @@ public extension PureXML.Canonical {
|
||||
return name.prefix != "xmlns" && !(name.prefix == nil && name.localName == "xmlns")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Node-subset context
|
||||
|
||||
/// The apex element with its inherited `xml:*` attributes added, and (for
|
||||
/// inclusive mode) the namespaces in scope from omitted ancestors merged in
|
||||
/// as declarations so the apex renders its full namespace context.
|
||||
private static func augmentedApex(
|
||||
_ apex: PureXML.Model.Element,
|
||||
inheritedXML: [PureXML.Model.Attribute],
|
||||
mergingNamespaces ancestorNamespaces: [String: String]?,
|
||||
stripApexBase: Bool,
|
||||
) -> PureXML.Model.Element {
|
||||
// Under 1.1, the apex's own xml:base is folded into the merged base in
|
||||
// inheritedXML, so drop the original to avoid emitting it twice.
|
||||
let own = stripApexBase
|
||||
? apex.attributes.filter { !($0.name.prefix == "xml" && $0.name.localName == "base") }
|
||||
: apex.attributes
|
||||
var attributes = own + inheritedXML
|
||||
if let ancestorNamespaces {
|
||||
let declared = Set(apex.attributes.map(\.name.description))
|
||||
for (prefix, uri) in ancestorNamespaces {
|
||||
let name = prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)"
|
||||
if !declared.contains(name) { attributes.append(PureXML.Model.Attribute(name, uri)) }
|
||||
}
|
||||
}
|
||||
return PureXML.Model.Element(name: apex.name, attributes: attributes, children: apex.children)
|
||||
}
|
||||
|
||||
/// The namespace declarations in scope from a node's omitted ancestors,
|
||||
/// nearest ancestor winning (so an inner redeclaration is preserved).
|
||||
private static func inScopeNamespaces(above node: PureXML.Model.TreeNode) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
var current = node.parent
|
||||
while let ancestor = current {
|
||||
for attribute in ancestor.attributes {
|
||||
guard let prefix = declaredPrefix(of: attribute.name), result[prefix] == nil else { continue }
|
||||
result[prefix] = attribute.value
|
||||
}
|
||||
current = ancestor.parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The `xml:*` attributes in scope from a node's omitted ancestors that the
|
||||
/// apex does not already set, nearest ancestor winning.
|
||||
private static func inheritedXMLAttributes(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element, mergeBase: Bool) -> [PureXML.Model.Attribute] {
|
||||
let present = Set(apex.attributes.map(\.name.description))
|
||||
if mergeBase { return inherited11XMLAttributes(above: node, apex: apex, present: present) }
|
||||
// Canonical XML 1.0: the nearest of every in-scope xml:* attribute the
|
||||
// apex does not already set.
|
||||
var seen: Set<String> = []
|
||||
var result: [PureXML.Model.Attribute] = []
|
||||
var current = node.parent
|
||||
while let ancestor = current {
|
||||
for attribute in ancestor.attributes where attribute.name.prefix == "xml" {
|
||||
let key = attribute.name.description
|
||||
guard !present.contains(key), seen.insert(key).inserted else { continue }
|
||||
result.append(attribute)
|
||||
}
|
||||
current = ancestor.parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// The prefix a namespace declaration binds (empty for the default
|
||||
/// namespace), or nil when the attribute is not a namespace declaration.
|
||||
private static func declaredPrefix(of name: PureXML.Model.QualifiedName) -> String? {
|
||||
if name.prefix == nil, name.localName == "xmlns" { return "" }
|
||||
if name.prefix == "xmlns" { return name.localName }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -266,54 +281,49 @@ public extension PureXML.Canonical.Canonicalizer {
|
||||
}
|
||||
|
||||
private func emitSelected(
|
||||
_ root: PureXML.Model.TreeNode,
|
||||
_ node: PureXML.Model.TreeNode,
|
||||
inScope: [String: String],
|
||||
rendered: [String: String],
|
||||
including predicate: (PureXML.Model.TreeNode) -> Bool,
|
||||
output: inout String,
|
||||
) {
|
||||
// Deferred-close work stack so a deeply-nested subtree does not overflow
|
||||
// the stack; children push reversed to emit in document order, threading
|
||||
// each element's in-scope and already-rendered namespace context.
|
||||
var stack: [CanonicalSelectedStep] = [.open(root, inScope: inScope, rendered: rendered)]
|
||||
while let step = stack.popLast() {
|
||||
switch step {
|
||||
case let .close(name):
|
||||
output += "</\(name)>"
|
||||
case let .open(node, inScope, rendered):
|
||||
switch node.kind {
|
||||
case .document:
|
||||
for child in node.children.reversed() {
|
||||
stack.append(.open(child, inScope: inScope, rendered: rendered))
|
||||
}
|
||||
case .element:
|
||||
stack.append(contentsOf: selectedElementSteps(node, inScope: inScope, rendered: rendered, including: predicate, into: &output))
|
||||
default:
|
||||
emitSelectedLeaf(node, including: predicate, into: &output)
|
||||
}
|
||||
switch node.kind {
|
||||
case .document:
|
||||
for child in node.children {
|
||||
emitSelected(child, inScope: inScope, rendered: rendered, including: predicate, output: &output)
|
||||
}
|
||||
case .element:
|
||||
emitSelected(element: node, inScope: inScope, rendered: rendered, including: predicate, output: &output)
|
||||
case .text, .cdata:
|
||||
if predicate(node) { output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value) }
|
||||
case .comment:
|
||||
if predicate(node), options.includeComments { output += "<!--\(node.value)-->" }
|
||||
case .processingInstruction:
|
||||
if predicate(node) { output += node.value.isEmpty ? "<?\(node.name?.description ?? "")?>" : "<?\(node.name?.description ?? "") \(node.value)?>" }
|
||||
case .doctype, .entityReference, .namespace:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a selected element's start tag and returns the steps to push for its
|
||||
/// subtree: its children to visit, preceded by a close step (so the close pops
|
||||
/// after them). An omitted element renders nothing and pushes no close, but its
|
||||
/// declarations still stay in scope for descendants, so a selected descendant
|
||||
/// re-declares them itself.
|
||||
private func selectedElementSteps(
|
||||
_ node: PureXML.Model.TreeNode,
|
||||
private func emitSelected(
|
||||
element node: PureXML.Model.TreeNode,
|
||||
inScope: [String: String],
|
||||
rendered: [String: String],
|
||||
including predicate: (PureXML.Model.TreeNode) -> Bool,
|
||||
into output: inout String,
|
||||
) -> [CanonicalSelectedStep] {
|
||||
output: inout String,
|
||||
) {
|
||||
let element = PureXML.Model.Element(name: node.name ?? .init(""), attributes: node.attributes, children: [])
|
||||
var childInScope = inScope
|
||||
for (prefix, uri) in Self.namespaceDeclarations(element) {
|
||||
childInScope[prefix] = uri
|
||||
}
|
||||
guard predicate(node) else {
|
||||
return node.children.reversed().map { .open($0, inScope: childInScope, rendered: rendered) }
|
||||
// Omitted element: its namespaces stay in scope for descendants, but
|
||||
// nothing is rendered, so a selected descendant renders them itself.
|
||||
for child in node.children {
|
||||
emitSelected(child, inScope: childInScope, rendered: rendered, including: predicate, output: &output)
|
||||
}
|
||||
return
|
||||
}
|
||||
let attributes = Self.plainAttributes(element)
|
||||
let toRender = selectedNamespaces(element, inScope: childInScope, attributes: attributes, rendered: rendered)
|
||||
@ -329,26 +339,10 @@ public extension PureXML.Canonical.Canonicalizer {
|
||||
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
|
||||
}
|
||||
output += ">"
|
||||
var steps: [CanonicalSelectedStep] = [.close(element.name.description)]
|
||||
steps.append(contentsOf: node.children.reversed().map { .open($0, inScope: childInScope, rendered: childRendered) })
|
||||
return steps
|
||||
}
|
||||
|
||||
/// Emits a selected non-element node (text, CDATA, comment, or PI).
|
||||
private func emitSelectedLeaf(_ node: PureXML.Model.TreeNode, including predicate: (PureXML.Model.TreeNode) -> Bool, into output: inout String) {
|
||||
guard predicate(node) else { return }
|
||||
switch node.kind {
|
||||
case .text, .cdata:
|
||||
output += Self.escapeText(options.trimTextNodes ? node.value.trimmingXMLWhitespace() : node.value)
|
||||
case .comment:
|
||||
if options.includeComments {
|
||||
output += "<!--\(node.value)-->"
|
||||
}
|
||||
case .processingInstruction:
|
||||
output += node.value.isEmpty ? "<?\(node.name?.description ?? "")?>" : "<?\(node.name?.description ?? "") \(node.value)?>"
|
||||
case .element, .document, .doctype, .entityReference, .namespace:
|
||||
break
|
||||
for child in node.children {
|
||||
emitSelected(child, inScope: childInScope, rendered: childRendered, including: predicate, output: &output)
|
||||
}
|
||||
output += "</\(element.name.description)>"
|
||||
}
|
||||
|
||||
/// The namespaces a selected element renders: in inclusive mode every in-scope
|
||||
|
||||
@ -27,9 +27,7 @@ private struct CatalogBuilder {
|
||||
insert(node, key: "systemId", base: base, into: &systemMap)
|
||||
case "public":
|
||||
insert(node, key: "publicId", base: base, into: &publicMap)
|
||||
if let id = attribute(node, "publicId") {
|
||||
publicPrefer[id] = prefer
|
||||
}
|
||||
if let id = attribute(node, "publicId") { publicPrefer[id] = prefer }
|
||||
case "uri":
|
||||
insert(node, key: "name", base: base, into: &uriMap)
|
||||
case "rewriteSystem":
|
||||
@ -64,9 +62,7 @@ private struct CatalogBuilder {
|
||||
case "delegateURI":
|
||||
appendDelegate(node, start: "uriStartString", base: base, into: &delegateURI)
|
||||
case "nextCatalog":
|
||||
if let catalog = attribute(node, "catalog") {
|
||||
nextCatalogs.append(resolve(catalog, base))
|
||||
}
|
||||
if let catalog = attribute(node, "catalog") { nextCatalogs.append(resolve(catalog, base)) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@ -163,22 +163,16 @@ public extension PureXML.Catalog {
|
||||
_ loadingCatalog: (String) -> String?,
|
||||
_ visited: inout Set<String>,
|
||||
) -> String? {
|
||||
if let local = pick(self) {
|
||||
return local
|
||||
}
|
||||
if let local = pick(self) { return local }
|
||||
let matching = delegates
|
||||
.filter { identifier.hasPrefix($0.startString) }
|
||||
.sorted { $0.startString.count > $1.startString.count }
|
||||
for rule in matching {
|
||||
if let sub = load(rule.catalog, loadingCatalog, &visited), let resolved = pick(sub) {
|
||||
return resolved
|
||||
}
|
||||
if let sub = load(rule.catalog, loadingCatalog, &visited), let resolved = pick(sub) { return resolved }
|
||||
}
|
||||
for next in nextCatalogs {
|
||||
guard let sub = load(next, loadingCatalog, &visited) else { continue }
|
||||
if let resolved = sub.chained(identifier, delegates: [], pick: pick, loadingCatalog, &visited) {
|
||||
return resolved
|
||||
}
|
||||
if let resolved = sub.chained(identifier, delegates: [], pick: pick, loadingCatalog, &visited) { return resolved }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -27,22 +27,16 @@ private struct SGMLBuilder {
|
||||
|
||||
mutating func apply(_ keyword: String, _ scanner: inout SGMLScanner) {
|
||||
let upper = keyword.uppercased()
|
||||
if applyPair(upper, &scanner) {
|
||||
return
|
||||
}
|
||||
if applyPair(upper, &scanner) { return }
|
||||
applySingle(upper, &scanner)
|
||||
}
|
||||
|
||||
private mutating func applyPair(_ keyword: String, _ scanner: inout SGMLScanner) -> Bool {
|
||||
switch keyword {
|
||||
case "PUBLIC":
|
||||
if let id = scanner.next(), let uri = scanner.next() {
|
||||
publicMap[id] = resolve(uri)
|
||||
}
|
||||
if let id = scanner.next(), let uri = scanner.next() { publicMap[id] = resolve(uri) }
|
||||
case "SYSTEM":
|
||||
if let id = scanner.next(), let uri = scanner.next() {
|
||||
systemMap[id] = resolve(uri)
|
||||
}
|
||||
if let id = scanner.next(), let uri = scanner.next() { systemMap[id] = resolve(uri) }
|
||||
case "DELEGATE":
|
||||
if let prefix = scanner.next(), let catalog = scanner.next() {
|
||||
delegatePublic.append(PureXML.Catalog.DelegateRule(startString: prefix, catalog: resolve(catalog)))
|
||||
@ -56,17 +50,11 @@ private struct SGMLBuilder {
|
||||
private mutating func applySingle(_ keyword: String, _ scanner: inout SGMLScanner) {
|
||||
switch keyword {
|
||||
case "CATALOG":
|
||||
if let catalog = scanner.next() {
|
||||
nextCatalogs.append(resolve(catalog))
|
||||
}
|
||||
if let catalog = scanner.next() { nextCatalogs.append(resolve(catalog)) }
|
||||
case "BASE":
|
||||
if let declared = scanner.next() {
|
||||
base = resolve(declared)
|
||||
}
|
||||
if let declared = scanner.next() { base = resolve(declared) }
|
||||
case "OVERRIDE":
|
||||
if let value = scanner.next() {
|
||||
preferPublic = value.uppercased() == "YES"
|
||||
}
|
||||
if let value = scanner.next() { preferPublic = value.uppercased() == "YES" }
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@ -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 += "&"
|
||||
case "<": result += "<"
|
||||
case ">": result += ">"
|
||||
case "\r" where escapeCarriageReturn: result += "
"
|
||||
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 "&": "&"
|
||||
case "<": "<"
|
||||
case ">": ">"
|
||||
case "\r" where escapeCarriageReturn: "
"
|
||||
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 == "'" ? "'" : """
|
||||
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 == "'" ? "'" : """
|
||||
}
|
||||
switch character {
|
||||
case "&": return "&"
|
||||
case "<": return "<"
|
||||
case ">": return ">"
|
||||
case "\t": return "	"
|
||||
case "\n": return " "
|
||||
case "\r": return " "
|
||||
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 += "&"
|
||||
case "<": result += "<"
|
||||
case ">": result += ">"
|
||||
case "\t": result += "	"
|
||||
case "\n": result += " "
|
||||
case "\r": result += " "
|
||||
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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -37,12 +37,8 @@ extension HTMLDocument {
|
||||
/// scope boundary.
|
||||
private func hasInScope(_ target: PureXML.Model.TreeNode) -> Bool {
|
||||
for node in openBody.reversed() {
|
||||
if node === target {
|
||||
return true
|
||||
}
|
||||
if Self.scopeBoundaries.contains(tagName(node)) {
|
||||
return false
|
||||
}
|
||||
if node === target { return true }
|
||||
if Self.scopeBoundaries.contains(tagName(node)) { return false }
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -52,9 +48,7 @@ extension HTMLDocument {
|
||||
/// re-wrapped (the `<b><i></b>X</i>` -> `<b><i></i></b><i>X</i>` behavior).
|
||||
func reconstructActiveFormatting() {
|
||||
guard var index = activeFormatting.indices.last else { return }
|
||||
if activeFormatting[index] == nil || isOpen(activeFormatting[index]) {
|
||||
return
|
||||
}
|
||||
if activeFormatting[index] == nil || isOpen(activeFormatting[index]) { return }
|
||||
while index > 0 {
|
||||
index -= 1
|
||||
if activeFormatting[index] == nil || isOpen(activeFormatting[index]) {
|
||||
@ -97,9 +91,7 @@ extension HTMLDocument {
|
||||
activeFormatting.remove(at: formattingIndexInList)
|
||||
return
|
||||
}
|
||||
if !hasInScope(formattingElement) {
|
||||
return
|
||||
}
|
||||
if !hasInScope(formattingElement) { return }
|
||||
guard let furthestBlockIndex = openBody[(formattingOpenIndex + 1)...].firstIndex(where: { Self.special.contains(tagName($0)) }) else {
|
||||
openBody.removeLast(openBody.count - formattingOpenIndex)
|
||||
activeFormatting.remove(at: formattingIndexInList)
|
||||
@ -112,9 +104,7 @@ extension HTMLDocument {
|
||||
private func lastFormatting(tag: String) -> Int? {
|
||||
for index in activeFormatting.indices.reversed() {
|
||||
guard let entry = activeFormatting[index] else { return nil } // a marker bounds the search
|
||||
if tagName(entry) == tag {
|
||||
return index
|
||||
}
|
||||
if tagName(entry) == tag { return index }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -134,9 +124,7 @@ extension HTMLDocument {
|
||||
inner += 1
|
||||
nodeIndex -= 1
|
||||
let node = openBody[nodeIndex]
|
||||
if node === formattingElement {
|
||||
break
|
||||
}
|
||||
if node === formattingElement { break }
|
||||
guard let activeIndex = formattingIndex(node) else {
|
||||
openBody.remove(at: nodeIndex)
|
||||
continue
|
||||
@ -144,9 +132,7 @@ extension HTMLDocument {
|
||||
let clone = node.shallowCopy()
|
||||
activeFormatting[activeIndex] = clone
|
||||
openBody[nodeIndex] = clone
|
||||
if lastNode === furthestBlock {
|
||||
bookmark = activeIndex + 1
|
||||
}
|
||||
if lastNode === furthestBlock { bookmark = activeIndex + 1 }
|
||||
clone.append(lastNode)
|
||||
lastNode = clone
|
||||
}
|
||||
@ -158,19 +144,13 @@ extension HTMLDocument {
|
||||
furthestBlock.append(formattingClone)
|
||||
if let removeIndex = formattingIndex(formattingElement) {
|
||||
activeFormatting.remove(at: removeIndex)
|
||||
if removeIndex < bookmark {
|
||||
bookmark -= 1
|
||||
}
|
||||
if removeIndex < bookmark { bookmark -= 1 }
|
||||
}
|
||||
activeFormatting.insert(formattingClone, at: min(bookmark, activeFormatting.count))
|
||||
if let removeOpen = openIndex(formattingElement) {
|
||||
openBody.remove(at: removeOpen)
|
||||
}
|
||||
if let removeOpen = openIndex(formattingElement) { openBody.remove(at: removeOpen) }
|
||||
// The clone becomes a child of the furthest block, so it sits immediately
|
||||
// above it on the open-elements stack; inserting below would re-find the
|
||||
// same furthest block and loop.
|
||||
if let blockIndex = openIndex(furthestBlock) {
|
||||
openBody.insert(formattingClone, at: blockIndex + 1)
|
||||
}
|
||||
if let blockIndex = openIndex(furthestBlock) { openBody.insert(formattingClone, at: blockIndex + 1) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
/// The foreign-content namespaces HTML5 switches into. File-scope and private.
|
||||
private enum ForeignNamespace {
|
||||
static let svg = "http://www.w3.org/2000/svg"
|
||||
static let mathml = "http://www.w3.org/1998/Math/MathML"
|
||||
}
|
||||
|
||||
/// The HTML5 tree-construction insertion modes this builder implements: the
|
||||
/// document-structure subset that establishes the implied `html`, `head`, and
|
||||
/// `body`. File-scope and private.
|
||||
@ -78,12 +84,8 @@ final class HTMLDocument {
|
||||
}
|
||||
|
||||
private func processInitial(_ token: Token) {
|
||||
if case .doctype = token {
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) {
|
||||
return
|
||||
}
|
||||
if case .doctype = token { return }
|
||||
if isIgnorableWhitespace(token) { return }
|
||||
mode = .beforeHtml
|
||||
process(token)
|
||||
}
|
||||
@ -94,17 +96,13 @@ final class HTMLDocument {
|
||||
mode = .beforeHead
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) {
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) { return }
|
||||
mode = .beforeHead
|
||||
process(token)
|
||||
}
|
||||
|
||||
private func processBeforeHead(_ token: Token) {
|
||||
if isIgnorableWhitespace(token) {
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) { return }
|
||||
if case let .startTag(name, attributes, _) = token, name == "head" {
|
||||
headAttributes = modelAttributes(attributes)
|
||||
mode = .inHead
|
||||
@ -142,9 +140,7 @@ final class HTMLDocument {
|
||||
}
|
||||
let element = PureXML.Model.TreeNode.element(name, attributes: modelAttributes(attributes))
|
||||
openHead.last?.append(element)
|
||||
if !(PureXML.HTML.Elements.void.contains(name) || selfClosing) {
|
||||
openHead.append(element)
|
||||
}
|
||||
if !(PureXML.HTML.Elements.void.contains(name) || selfClosing) { openHead.append(element) }
|
||||
}
|
||||
|
||||
private func headClose(_ name: String) {
|
||||
@ -172,9 +168,7 @@ final class HTMLDocument {
|
||||
process(token)
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) {
|
||||
return
|
||||
}
|
||||
if isIgnorableWhitespace(token) { return }
|
||||
mode = .inBody
|
||||
process(token)
|
||||
}
|
||||
@ -216,11 +210,7 @@ final class HTMLDocument {
|
||||
case let .startTag(name, attributes, _) where name == "noframes":
|
||||
framesetStack.last?.append(PureXML.Model.TreeNode.element("noframes", attributes: modelAttributes(attributes)))
|
||||
case let .endTag(name) where name == "frameset":
|
||||
if framesetStack.count > 1 {
|
||||
framesetStack.removeLast()
|
||||
} else {
|
||||
mode = .afterFrameset
|
||||
}
|
||||
if framesetStack.count > 1 { framesetStack.removeLast() } else { mode = .afterFrameset }
|
||||
case let .comment(value):
|
||||
framesetStack.last?.append(.comment(value))
|
||||
default:
|
||||
@ -233,12 +223,27 @@ final class HTMLDocument {
|
||||
node.name?.localName.lowercased() ?? ""
|
||||
}
|
||||
|
||||
/// Tags that close an open `<select>` (the "in select" rule): a nested select,
|
||||
/// or an input/keygen/textarea.
|
||||
private static let closesSelect: Set<String> = ["select", "input", "keygen", "textarea"]
|
||||
|
||||
/// Table-structural tags that close a select which is inside a table (the "in
|
||||
/// select in table" rule).
|
||||
private static let closesSelectInTable: Set<String> = ["caption", "table", "tbody", "tfoot", "thead", "tr", "td", "th"]
|
||||
|
||||
/// The open-stack index of a `<select>` that an opening table tag should close
|
||||
/// (a select inside a table), or nil when there is none.
|
||||
private func selectInTableToClose(for name: String) -> Int? {
|
||||
guard Self.closesSelectInTable.contains(name),
|
||||
let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1,
|
||||
openBody[..<selectIndex].contains(where: { tagName($0) == "table" }) else { return nil }
|
||||
return selectIndex
|
||||
}
|
||||
|
||||
private func bodyOpen(_ name: String, _ attributes: [(String, String)], selfClosing: Bool) {
|
||||
if Self.closesSelect.contains(name), let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1 {
|
||||
openBody.removeLast(openBody.count - selectIndex)
|
||||
if name == "select" {
|
||||
return
|
||||
} // a nested select just closes the open one
|
||||
if name == "select" { return } // a nested select just closes the open one
|
||||
}
|
||||
if let selectIndex = selectInTableToClose(for: name) {
|
||||
openBody.removeLast(openBody.count - selectIndex)
|
||||
@ -265,6 +270,51 @@ final class HTMLDocument {
|
||||
private func bodyInsert(_ node: PureXML.Model.TreeNode) {
|
||||
openBody.last?.append(node)
|
||||
}
|
||||
|
||||
private func bodyEnsureTableContext(for name: String) {
|
||||
let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"]
|
||||
guard let context = openBody.reversed().first(where: { structural.contains(tagName($0)) }).map(tagName) else { return }
|
||||
switch name {
|
||||
case "tr" where context == "table":
|
||||
bodyPushImplied("tbody")
|
||||
case "td", "th":
|
||||
if context == "table" { bodyPushImplied("tbody") }
|
||||
if context == "table" || ["tbody", "thead", "tfoot"].contains(context) { bodyPushImplied("tr") }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func bodyPushImplied(_ name: String) {
|
||||
let element = PureXML.Model.TreeNode.element(name)
|
||||
openBody.last?.append(element)
|
||||
openBody.append(element)
|
||||
}
|
||||
|
||||
/// Restores SVG attribute names to their canonical camel case.
|
||||
private func adjustedAttributes(_ attributes: [Attribute], namespace: String?) -> [Attribute] {
|
||||
guard namespace == ForeignNamespace.svg else { return attributes }
|
||||
return attributes.map { attribute in
|
||||
guard let adjusted = PureXML.HTML.ForeignNames.svgAttributes[attribute.name.localName.lowercased()] else { return attribute }
|
||||
return Attribute(adjusted, attribute.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The foreign-content namespace for a body element: SVG/MathML on entry, the
|
||||
/// nearest open foreign ancestor's, or HTML (nil) inside an integration point.
|
||||
private func bodyForeignNamespace(for name: String) -> String? {
|
||||
if name == "svg" { return ForeignNamespace.svg }
|
||||
if name == "math" { return ForeignNamespace.mathml }
|
||||
for node in openBody.reversed() {
|
||||
guard let namespace = node.name?.namespaceURI else { continue }
|
||||
if Self.svgIntegrationPoints.contains(tagName(node)) { return nil }
|
||||
return namespace
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The SVG HTML integration points: their content is parsed as HTML.
|
||||
private static let svgIntegrationPoints: Set<String> = ["foreignobject", "desc", "title"]
|
||||
}
|
||||
|
||||
extension HTMLDocument {
|
||||
@ -280,9 +330,7 @@ extension HTMLDocument {
|
||||
}
|
||||
|
||||
private func isIgnorableWhitespace(_ token: Token) -> Bool {
|
||||
if case let .text(value) = token {
|
||||
return value.trimmingXMLWhitespace().isEmpty
|
||||
}
|
||||
if case let .text(value) = token { return value.trimmingXMLWhitespace().isEmpty }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
/// Table-context and foreign-content (SVG/MathML) helpers, split out so
|
||||
/// HTMLDocument's own body stays within the type-body length budget.
|
||||
extension HTMLDocument {
|
||||
/// Tags that close an open `<select>` (the "in select" rule): a nested select,
|
||||
/// or an input/keygen/textarea.
|
||||
static let closesSelect: Set<String> = ["select", "input", "keygen", "textarea"]
|
||||
|
||||
/// Table-structural tags that close a select which is inside a table (the "in
|
||||
/// select in table" rule).
|
||||
private static let closesSelectInTable: Set<String> = ["caption", "table", "tbody", "tfoot", "thead", "tr", "td", "th"]
|
||||
|
||||
/// The SVG HTML integration points: their content is parsed as HTML.
|
||||
private static let svgIntegrationPoints: Set<String> = ["foreignobject", "desc", "title"]
|
||||
|
||||
/// The open-stack index of a `<select>` that an opening table tag should close
|
||||
/// (a select inside a table), or nil when there is none.
|
||||
func selectInTableToClose(for name: String) -> Int? {
|
||||
guard Self.closesSelectInTable.contains(name),
|
||||
let selectIndex = openBody.lastIndex(where: { tagName($0) == "select" }), selectIndex >= 1,
|
||||
openBody[..<selectIndex].contains(where: { tagName($0) == "table" }) else { return nil }
|
||||
return selectIndex
|
||||
}
|
||||
|
||||
func bodyEnsureTableContext(for name: String) {
|
||||
let structural: Set = ["table", "tbody", "thead", "tfoot", "tr"]
|
||||
guard let context = openBody.reversed().first(where: { structural.contains(tagName($0)) }).map(tagName) else { return }
|
||||
switch name {
|
||||
case "tr" where context == "table":
|
||||
bodyPushImplied("tbody")
|
||||
case "td", "th":
|
||||
if context == "table" {
|
||||
bodyPushImplied("tbody")
|
||||
}
|
||||
if context == "table" || ["tbody", "thead", "tfoot"].contains(context) {
|
||||
bodyPushImplied("tr")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func bodyPushImplied(_ name: String) {
|
||||
let element = PureXML.Model.TreeNode.element(name)
|
||||
openBody.last?.append(element)
|
||||
openBody.append(element)
|
||||
}
|
||||
|
||||
/// Restores SVG attribute names to their canonical camel case.
|
||||
func adjustedAttributes(_ attributes: [Attribute], namespace: String?) -> [Attribute] {
|
||||
guard namespace == ForeignNamespace.svg else { return attributes }
|
||||
return attributes.map { attribute in
|
||||
guard let adjusted = PureXML.HTML.ForeignNames.svgAttributes[attribute.name.localName.lowercased()] else { return attribute }
|
||||
return Attribute(adjusted, attribute.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The foreign-content namespace for a body element: SVG/MathML on entry, the
|
||||
/// nearest open foreign ancestor's, or HTML (nil) inside an integration point.
|
||||
func bodyForeignNamespace(for name: String) -> String? {
|
||||
if name == "svg" {
|
||||
return ForeignNamespace.svg
|
||||
}
|
||||
if name == "math" {
|
||||
return ForeignNamespace.mathml
|
||||
}
|
||||
for node in openBody.reversed() {
|
||||
guard let namespace = node.name?.namespaceURI else { continue }
|
||||
if Self.svgIntegrationPoints.contains(tagName(node)) {
|
||||
return nil
|
||||
}
|
||||
return namespace
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -59,17 +59,13 @@ extension PureXML.HTML.Tokenizer {
|
||||
private static func decodeNumeric(_ characters: [Character], at start: Int) -> (String, Int)? {
|
||||
var cursor = start + 2 // past "&#"
|
||||
let isHex = cursor < characters.count && (characters[cursor] == "x" || characters[cursor] == "X")
|
||||
if isHex {
|
||||
cursor += 1
|
||||
}
|
||||
if isHex { cursor += 1 }
|
||||
let digitsStart = cursor
|
||||
while cursor < characters.count, isDigit(characters[cursor], hex: isHex) {
|
||||
cursor += 1
|
||||
}
|
||||
guard cursor > digitsStart, let number = UInt32(String(characters[digitsStart ..< cursor]), radix: isHex ? 16 : 10) else { return nil }
|
||||
if cursor < characters.count, characters[cursor] == ";" {
|
||||
cursor += 1
|
||||
}
|
||||
if cursor < characters.count, characters[cursor] == ";" { cursor += 1 }
|
||||
return (String(numericCharacter(number)), cursor - start)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
/// The foreign-content namespaces HTML5 switches into.
|
||||
enum ForeignNamespace {
|
||||
static let svg = "http://www.w3.org/2000/svg"
|
||||
static let mathml = "http://www.w3.org/1998/Math/MathML"
|
||||
}
|
||||
@ -5,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 += """
|
||||
case "&": result += "&"
|
||||
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 += """
|
||||
// The html output method does not escape `<` (or `>`) in an
|
||||
// attribute value, unlike the xml method (XSLT 1.0 16.2).
|
||||
case "<": result += "<"
|
||||
default: result += latin1Entity(scalar) ?? String(Character(scalar))
|
||||
}
|
||||
}
|
||||
if previousWasAmpersand {
|
||||
result += "&"
|
||||
}
|
||||
if previousWasAmpersand { result += "&" }
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -27,18 +27,10 @@ extension PureXML.HTML {
|
||||
return rawText(until: raw)
|
||||
}
|
||||
guard peek() == "<" else { return text() }
|
||||
if matches("<!--") {
|
||||
return comment()
|
||||
}
|
||||
if matches("<!") {
|
||||
return doctype()
|
||||
}
|
||||
if matches("</") {
|
||||
return endTag()
|
||||
}
|
||||
if peek(1)?.isLetter == true {
|
||||
return startTag()
|
||||
}
|
||||
if matches("<!--") { return comment() }
|
||||
if matches("<!") { return doctype() }
|
||||
if matches("</") { return endTag() }
|
||||
if peek(1)?.isLetter == true { return startTag() }
|
||||
return text()
|
||||
}
|
||||
|
||||
@ -46,9 +38,7 @@ extension PureXML.HTML {
|
||||
|
||||
private mutating func text() -> Token {
|
||||
var value = ""
|
||||
if let first = advance() {
|
||||
value.append(first)
|
||||
}
|
||||
if let first = advance() { value.append(first) }
|
||||
while let character = peek(), character != "<" {
|
||||
value.append(character)
|
||||
advance()
|
||||
@ -63,21 +53,16 @@ extension PureXML.HTML {
|
||||
var selfClosing = false
|
||||
while !isAtEnd {
|
||||
skipSpace()
|
||||
if consume("/>") {
|
||||
selfClosing = true
|
||||
break
|
||||
}
|
||||
if consume(">") {
|
||||
if consume("/>") { selfClosing = true
|
||||
break
|
||||
}
|
||||
if consume(">") { break }
|
||||
guard let attribute = readAttribute() else { advance()
|
||||
continue
|
||||
}
|
||||
attributes.append(attribute)
|
||||
}
|
||||
if Elements.rawText.contains(name) {
|
||||
rawTextName = name
|
||||
}
|
||||
if Elements.rawText.contains(name) { rawTextName = name }
|
||||
return .startTag(name: name, attributes: attributes, selfClosing: selfClosing)
|
||||
}
|
||||
|
||||
@ -148,13 +133,9 @@ extension PureXML.HTML {
|
||||
}
|
||||
value = Self.replacingNull(value)
|
||||
// RCDATA decodes character references; raw text (script/style) does not.
|
||||
if Elements.rcdata.contains(name) {
|
||||
value = Self.decodeEntities(value)
|
||||
}
|
||||
if Elements.rcdata.contains(name) { value = Self.decodeEntities(value) }
|
||||
// A single leading newline after <textarea> is stripped (the HTML rule).
|
||||
if name == "textarea", value.first == "\n" {
|
||||
value.removeFirst()
|
||||
}
|
||||
if name == "textarea", value.first == "\n" { value.removeFirst() }
|
||||
return .text(value)
|
||||
}
|
||||
|
||||
|
||||
@ -1,41 +1,9 @@
|
||||
public extension PureXML.Model {
|
||||
/// An XML element: a qualified name, ordered attributes, and ordered child nodes.
|
||||
///
|
||||
/// A value type with full value semantics: copying an element copies its
|
||||
/// children (lazily, copy-on-write). The children are held behind a reference
|
||||
/// (``ElementStorage``) so a deeply-nested tree neither overflows the stack on
|
||||
/// release nor on equality and hashing, which walk the tree iteratively.
|
||||
struct Element: Sendable {
|
||||
struct Element: Equatable, Hashable, Sendable {
|
||||
public var name: QualifiedName
|
||||
public var attributes: [Attribute]
|
||||
/// Copy-on-write backing; `children` is the public, value-semantic view.
|
||||
var storage: ElementStorage
|
||||
|
||||
/// The element's child nodes. Reads share the backing storage; a write
|
||||
/// first copies the storage if it is shared, preserving value semantics.
|
||||
///
|
||||
/// The `_modify` accessor yields the backing array in place (after the
|
||||
/// copy-on-write check), so an in-place mutation such as
|
||||
/// `element.children.append(_:)` does not copy the whole array on every
|
||||
/// call. Without it a computed property would read, mutate a temporary,
|
||||
/// and write back, making repeated appends quadratic (which is exactly the
|
||||
/// pattern the parser uses while building an element).
|
||||
public var children: [Node] {
|
||||
get { storage.children }
|
||||
_modify {
|
||||
if !isKnownUniquelyReferenced(&storage) {
|
||||
storage = ElementStorage(storage.children)
|
||||
}
|
||||
yield &storage.children
|
||||
}
|
||||
set {
|
||||
if isKnownUniquelyReferenced(&storage) {
|
||||
storage.children = newValue
|
||||
} else {
|
||||
storage = ElementStorage(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
public var children: [Node]
|
||||
|
||||
public init(
|
||||
name: QualifiedName,
|
||||
@ -44,7 +12,7 @@ public extension PureXML.Model {
|
||||
) {
|
||||
self.name = name
|
||||
self.attributes = attributes
|
||||
storage = ElementStorage(children)
|
||||
self.children = children
|
||||
}
|
||||
|
||||
public init(
|
||||
@ -72,16 +40,3 @@ public extension PureXML.Model {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Model.Element: Equatable, Hashable {
|
||||
/// Value equality over the whole subtree, compared iteratively (see
|
||||
/// ``PureXML/Model/Node`` `==`) so a deeply-nested element does not recurse.
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
PureXML.Model.Node.treesEqual(.element(lhs), .element(rhs))
|
||||
}
|
||||
|
||||
/// Hashes the whole subtree iteratively, consistent with `==`.
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
PureXML.Model.Node.element(self).hash(into: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
extension PureXML.Model {
|
||||
/// Copy-on-write backing for an ``Element``'s children.
|
||||
///
|
||||
/// `Element` is a value type, but its children must live behind a reference so
|
||||
/// the tree's depth does not become part of any value's recursive layout: a
|
||||
/// chain of value-nested children would otherwise release one native frame per
|
||||
/// level and overflow the stack on a deeply-nested document. Holding children
|
||||
/// in this class gives the tree exactly one place to release them, where a
|
||||
/// custom `deinit` can flatten the subtree iteratively.
|
||||
///
|
||||
/// `@unchecked Sendable`: the class is mutable, but ``Element`` only ever
|
||||
/// mutates `children` after copying a shared box (the copy-on-write check in
|
||||
/// `Element.children`'s setter), so a box reachable from more than one value is
|
||||
/// never written. That is the same discipline the standard library's own
|
||||
/// copy-on-write containers rely on to be `Sendable`.
|
||||
final class ElementStorage: @unchecked Sendable {
|
||||
var children: [PureXML.Model.Node]
|
||||
|
||||
init(_ children: [PureXML.Model.Node]) {
|
||||
self.children = children
|
||||
}
|
||||
|
||||
/// Releases the subtree without recursing on depth. A naive release walks
|
||||
/// one native frame per level (each box releasing its children's boxes);
|
||||
/// instead each uniquely-owned descendant's children are moved into a flat
|
||||
/// heap worklist and cleared before that node is released, so every box
|
||||
/// frees with empty children and the teardown stays flat. A node whose
|
||||
/// storage is still shared (a copy-on-write sibling holds it) is left
|
||||
/// intact and tears down later through this same `deinit`.
|
||||
deinit {
|
||||
guard !children.isEmpty else { return }
|
||||
var pending = children
|
||||
children = []
|
||||
while !pending.isEmpty {
|
||||
var node = pending.removeLast()
|
||||
node.drainUniquelyOwnedChildren(into: &pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,7 @@ public extension PureXML.Model {
|
||||
/// A node in an XML tree. The model preserves document order and the
|
||||
/// distinction between text, CDATA, comments, and processing instructions
|
||||
/// so that emitting can round-trip a parsed document.
|
||||
///
|
||||
/// Not `indirect`: an element holds its children behind a reference
|
||||
/// (``ElementStorage``), so a `Node` is a fixed-size value and the tree's
|
||||
/// depth is not part of any value's layout. Equality, hashing, and release
|
||||
/// all walk the tree iteratively, so an arbitrarily deep document never
|
||||
/// overflows the call stack.
|
||||
enum Node: Sendable {
|
||||
indirect enum Node: Equatable, Hashable, Sendable {
|
||||
/// The root of a parsed document, holding its prolog and root element.
|
||||
case document([Node])
|
||||
/// An element with a name, attributes, and children.
|
||||
@ -29,105 +23,3 @@ public extension PureXML.Model {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Model.Node: Equatable, Hashable {
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
treesEqual(lhs, rhs)
|
||||
}
|
||||
|
||||
/// Whether two node trees are equal, compared iteratively through an explicit
|
||||
/// pair stack so a deeply-nested tree does not recurse one frame per level.
|
||||
static func treesEqual(_ lhs: Self, _ rhs: Self) -> Bool {
|
||||
var stack: [(Self, Self)] = [(lhs, rhs)]
|
||||
while let (left, right) = stack.popLast() {
|
||||
switch (left, right) {
|
||||
case let (.element(leftElement), .element(rightElement)):
|
||||
guard leftElement.name == rightElement.name,
|
||||
leftElement.attributes == rightElement.attributes,
|
||||
leftElement.children.count == rightElement.children.count
|
||||
else { return false }
|
||||
for pair in zip(leftElement.children, rightElement.children) {
|
||||
stack.append(pair)
|
||||
}
|
||||
case let (.document(leftChildren), .document(rightChildren)):
|
||||
guard leftChildren.count == rightChildren.count else { return false }
|
||||
for pair in zip(leftChildren, rightChildren) {
|
||||
stack.append(pair)
|
||||
}
|
||||
default:
|
||||
if !leavesEqual(left, right) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Equality for the non-branch node kinds (and a kind mismatch); the branch
|
||||
/// kinds are walked iteratively by ``treesEqual(_:_:)``.
|
||||
private static func leavesEqual(_ left: Self, _ right: Self) -> Bool {
|
||||
switch (left, right) {
|
||||
case let (.text(leftValue), .text(rightValue)): leftValue == rightValue
|
||||
case let (.cdata(leftValue), .cdata(rightValue)): leftValue == rightValue
|
||||
case let (.comment(leftValue), .comment(rightValue)): leftValue == rightValue
|
||||
case let (.processingInstruction(leftTarget, leftData), .processingInstruction(rightTarget, rightData)):
|
||||
leftTarget == rightTarget && leftData == rightData
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes the whole subtree iteratively (pre-order), consistent with
|
||||
/// ``treesEqual(_:_:)``: equal trees feed the hasher the same sequence.
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
var stack: [Self] = [self]
|
||||
while let node = stack.popLast() {
|
||||
switch node {
|
||||
case let .element(element):
|
||||
hasher.combine(0)
|
||||
hasher.combine(element.name)
|
||||
hasher.combine(element.attributes)
|
||||
hasher.combine(element.children.count)
|
||||
for child in element.children {
|
||||
stack.append(child)
|
||||
}
|
||||
case let .document(children):
|
||||
hasher.combine(1)
|
||||
hasher.combine(children.count)
|
||||
for child in children {
|
||||
stack.append(child)
|
||||
}
|
||||
case let .text(value):
|
||||
hasher.combine(2)
|
||||
hasher.combine(value)
|
||||
case let .cdata(value):
|
||||
hasher.combine(3)
|
||||
hasher.combine(value)
|
||||
case let .comment(value):
|
||||
hasher.combine(4)
|
||||
hasher.combine(value)
|
||||
case let .processingInstruction(target, data):
|
||||
hasher.combine(5)
|
||||
hasher.combine(target)
|
||||
hasher.combine(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.Model.Node {
|
||||
/// Used by ``ElementStorage`` teardown: if this is an element whose backing
|
||||
/// storage this node solely owns, move its children into `pending` and clear
|
||||
/// them so the node releases flat; otherwise leave it untouched. Setting
|
||||
/// `self` aside before the uniqueness check drops this node's own hold on the
|
||||
/// storage, so the check counts only references held elsewhere (shared
|
||||
/// copy-on-write siblings), which must keep their subtree.
|
||||
mutating func drainUniquelyOwnedChildren(into pending: inout [Self]) {
|
||||
guard case var .element(element) = self else { return }
|
||||
self = .text("")
|
||||
if isKnownUniquelyReferenced(&element.storage), !element.storage.children.isEmpty {
|
||||
pending.append(contentsOf: element.storage.children)
|
||||
element.storage.children = []
|
||||
}
|
||||
self = .element(element)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
extension PureXML.Model.Node {
|
||||
/// Rebuilds this tree bottom-up without recursing on the tree's depth, so a
|
||||
/// transform over a deeply-nested document cannot overflow the stack. Each
|
||||
/// node is passed to `transform` once its children have been rebuilt, together
|
||||
/// with those rebuilt children (empty for a leaf); the transform returns the
|
||||
/// replacement node. An explicit work stack drives a post-order walk, so a
|
||||
/// parent is transformed only after all its descendants.
|
||||
///
|
||||
/// This is the shared spine for the tree-to-tree transforms (DTD default
|
||||
/// application, canonical prefix rewriting, and the like) that previously
|
||||
/// recursed one native frame per level.
|
||||
func rebuildingBottomUp(
|
||||
_ transform: (_ node: PureXML.Model.Node, _ rebuiltChildren: [PureXML.Model.Node]) -> PureXML.Model.Node,
|
||||
) -> PureXML.Model.Node {
|
||||
guard let rootChildren = Self.branchChildren(self) else {
|
||||
return transform(self, [])
|
||||
}
|
||||
let rootFrame = RebuildFrame(self, rootChildren)
|
||||
var stack: [RebuildFrame] = [rootFrame]
|
||||
while let frame = stack.last {
|
||||
guard frame.next < frame.children.count else {
|
||||
stack.removeLast()
|
||||
let rebuilt = transform(frame.node, frame.built)
|
||||
if let parent = stack.last {
|
||||
parent.built.append(rebuilt)
|
||||
} else {
|
||||
return rebuilt
|
||||
}
|
||||
continue
|
||||
}
|
||||
let child = frame.children[frame.next]
|
||||
frame.next += 1
|
||||
if let grandchildren = Self.branchChildren(child) {
|
||||
stack.append(RebuildFrame(child, grandchildren))
|
||||
} else {
|
||||
frame.built.append(transform(child, []))
|
||||
}
|
||||
}
|
||||
return transform(self, [])
|
||||
}
|
||||
|
||||
/// The children of a branch node (document or element), or nil for a leaf.
|
||||
private static func branchChildren(_ node: PureXML.Model.Node) -> [PureXML.Model.Node]? {
|
||||
switch node {
|
||||
case let .document(children): children
|
||||
case let .element(element): element.children
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A node being rebuilt bottom-up: its source children, the cursor into them, and
|
||||
/// the rebuilt children gathered so far.
|
||||
private final class RebuildFrame {
|
||||
let node: PureXML.Model.Node
|
||||
let children: [PureXML.Model.Node]
|
||||
var next = 0
|
||||
var built: [PureXML.Model.Node] = []
|
||||
|
||||
init(_ node: PureXML.Model.Node, _ children: [PureXML.Model.Node]) {
|
||||
self.node = node
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,9 +45,7 @@ public extension PureXML.Model.TreeNode {
|
||||
/// comparable only when they share a root; an unrelated node returns false. An
|
||||
/// ancestor precedes its descendants.
|
||||
func precedes(_ other: PureXML.Model.TreeNode) -> Bool {
|
||||
if self === other {
|
||||
return false
|
||||
}
|
||||
if self === other { return false }
|
||||
let mine = Self.pathFromRoot(self)
|
||||
let theirs = Self.pathFromRoot(other)
|
||||
guard mine.first === theirs.first else { return false }
|
||||
@ -55,12 +53,8 @@ public extension PureXML.Model.TreeNode {
|
||||
while depth < mine.count, depth < theirs.count, mine[depth] === theirs[depth] {
|
||||
depth += 1
|
||||
}
|
||||
if depth == mine.count {
|
||||
return true
|
||||
}
|
||||
if depth == theirs.count {
|
||||
return false
|
||||
}
|
||||
if depth == mine.count { return true }
|
||||
if depth == theirs.count { return false }
|
||||
let parent = mine[depth - 1]
|
||||
let mineIndex = parent.children.firstIndex { $0 === mine[depth] } ?? 0
|
||||
let theirsIndex = parent.children.firstIndex { $0 === theirs[depth] } ?? 0
|
||||
|
||||
@ -75,9 +75,7 @@ public extension PureXML.Model.TreeNode {
|
||||
private func isDescendant(of ancestor: PureXML.Model.TreeNode) -> Bool {
|
||||
var current: PureXML.Model.TreeNode? = self
|
||||
while let node = current {
|
||||
if node === ancestor {
|
||||
return true
|
||||
}
|
||||
if node === ancestor { return true }
|
||||
current = node.parent
|
||||
}
|
||||
return false
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append(contentsOf: sequence)
|
||||
} else {
|
||||
scalars.append("\u{FFFD}")
|
||||
if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
}
|
||||
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
|
||||
}
|
||||
} else if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
@ -28,9 +26,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
}
|
||||
if lead != 0 {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
if lead != 0 { scalars.append("\u{FFFD}") }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
|
||||
@ -166,9 +166,7 @@ extension PureXML.Parsing {
|
||||
/// BOM is still a contradiction, only utf-8 itself agrees; the generic
|
||||
/// utf-16/utf-32 names agree with either byte order.
|
||||
private static func sameFamily(_ declared: InputEncoding, as sniffed: InputEncoding) -> Bool {
|
||||
if declared == sniffed {
|
||||
return true
|
||||
}
|
||||
if declared == sniffed { return true }
|
||||
switch (sniffed, declared) {
|
||||
case (.utf16BigEndian, .utf16LittleEndian), (.utf16LittleEndian, .utf16BigEndian),
|
||||
(.utf32BigEndian, .utf32LittleEndian), (.utf32LittleEndian, .utf32BigEndian):
|
||||
@ -193,9 +191,7 @@ extension PureXML.Parsing {
|
||||
while index < body.endIndex, body.index(after: index) < body.endIndex {
|
||||
let high = sniffed == .utf16BigEndian ? body[index] : body[body.index(after: index)]
|
||||
let low = sniffed == .utf16BigEndian ? body[body.index(after: index)] : body[index]
|
||||
if high == 0 {
|
||||
ascii.append(low)
|
||||
}
|
||||
if high == 0 { ascii.append(low) }
|
||||
index = body.index(index, offsetBy: 2)
|
||||
}
|
||||
return declaredEncoding(in: ascii)
|
||||
|
||||
@ -90,12 +90,8 @@ public extension PureXML.Parsing {
|
||||
var map: [UInt32: UInt8] = [:]
|
||||
for byte in UInt8.min ... UInt8.max {
|
||||
let value = forward(byte).value
|
||||
if value == 0xFFFD {
|
||||
continue
|
||||
}
|
||||
if map[value] == nil {
|
||||
map[value] = byte
|
||||
}
|
||||
if value == 0xFFFD { continue }
|
||||
if map[value] == nil { map[value] = byte }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
@ -104,9 +104,7 @@ extension PureXML.Parsing.ByteEncoder {
|
||||
func record(_ bytes: [UInt8]) {
|
||||
let scalars = Array(decode(bytes[...]).unicodeScalars)
|
||||
guard scalars.count == 1, scalars[0].value != 0xFFFD, scalars[0].value >= 0x80 else { return }
|
||||
if map[scalars[0].value] == nil {
|
||||
map[scalars[0].value] = bytes
|
||||
}
|
||||
if map[scalars[0].value] == nil { map[scalars[0].value] = bytes }
|
||||
}
|
||||
for byte in 0x80 ... 0xFF {
|
||||
record([UInt8(byte)])
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -25,17 +25,13 @@ extension PureXML.Parsing {
|
||||
// Each definition starts with required whitespace (the body begins
|
||||
// right after the element name).
|
||||
guard scanner.requiredSpace() else { return nil }
|
||||
if scanner.isAtEnd {
|
||||
return defaults
|
||||
} // trailing whitespace
|
||||
if scanner.isAtEnd { return defaults } // trailing whitespace
|
||||
guard scanner.name() else { return nil }
|
||||
guard scanner.requiredSpace() else { return nil }
|
||||
guard attType(&scanner) else { return nil }
|
||||
guard scanner.requiredSpace() else { return nil }
|
||||
guard let value = defaultDecl(&scanner) else { return nil }
|
||||
if let value {
|
||||
defaults.append(value)
|
||||
}
|
||||
if let value { defaults.append(value) }
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
@ -45,9 +41,7 @@ extension PureXML.Parsing {
|
||||
return scanner.nameGroup(nmtokens: true)
|
||||
}
|
||||
guard let keyword = scanner.keyword() else { return false }
|
||||
if tokenizedTypes.contains(keyword) {
|
||||
return true
|
||||
}
|
||||
if tokenizedTypes.contains(keyword) { return true }
|
||||
if keyword == "NOTATION" {
|
||||
guard scanner.requiredSpace() else { return false }
|
||||
return scanner.nameGroup(nmtokens: false)
|
||||
|
||||
@ -8,9 +8,7 @@ extension PureXML.Parsing {
|
||||
enum DTDContentModelGrammar {
|
||||
static func isValid(_ model: String) -> Bool {
|
||||
let text = model.trimmingXMLWhitespace()
|
||||
if text == "EMPTY" || text == "ANY" {
|
||||
return true
|
||||
}
|
||||
if text == "EMPTY" || text == "ANY" { return true }
|
||||
var scanner = ModelScanner(text)
|
||||
guard scanner.peek() == "(" else { return false }
|
||||
// Mixed and children both start with '('; Mixed is identified by
|
||||
@ -76,9 +74,7 @@ private struct ModelScanner {
|
||||
var sawAlternative = false
|
||||
while true {
|
||||
skipSpace()
|
||||
if consume(")") {
|
||||
break
|
||||
}
|
||||
if consume(")") { break }
|
||||
guard consume("|") else { return false }
|
||||
sawAlternative = true
|
||||
skipSpace()
|
||||
@ -108,9 +104,7 @@ private struct ModelScanner {
|
||||
var connector: Character?
|
||||
while !consume(")") {
|
||||
guard let next = advance(), next == "|" || next == "," else { return false }
|
||||
if let connector, connector != next {
|
||||
return false
|
||||
}
|
||||
if let connector, connector != next { return false }
|
||||
connector = next
|
||||
skipSpace()
|
||||
guard particle() else { return false }
|
||||
|
||||
@ -83,9 +83,7 @@ struct DTDScanner {
|
||||
/// a VC (deferred finding), since the unread external declarations might
|
||||
/// have declared the entity (production 68).
|
||||
var entityDeclaredIsWellFormedness: Bool {
|
||||
if standalone {
|
||||
return true
|
||||
}
|
||||
if standalone { return true }
|
||||
return doctype.externalSubset == nil && doctype.parameterEntities.isEmpty && unresolvedParameterEntities.isEmpty
|
||||
}
|
||||
|
||||
@ -300,6 +298,22 @@ struct DTDScanner {
|
||||
}
|
||||
|
||||
extension DTDScanner {
|
||||
private func parseExternalID(_ reader: inout Reader) -> ExternalID? {
|
||||
if reader.consume("SYSTEM") {
|
||||
reader.skipSpace()
|
||||
guard let systemID = scanLiteral(&reader) else { return nil }
|
||||
return ExternalID(systemID: systemID)
|
||||
}
|
||||
if reader.consume("PUBLIC") {
|
||||
reader.skipSpace()
|
||||
guard let publicID = scanLiteral(&reader) else { return nil }
|
||||
reader.skipSpace()
|
||||
let systemID = scanLiteral(&reader) ?? ""
|
||||
return ExternalID(publicID: publicID, systemID: systemID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanLiteral(_ reader: inout Reader) -> String? {
|
||||
guard let quote = reader.peek(), quote == "\"" || quote == "'" else { return nil }
|
||||
reader.advance()
|
||||
|
||||
@ -50,9 +50,7 @@ extension DTDScanner {
|
||||
} else if reader.matches("]]>") {
|
||||
reader.consume("]]>")
|
||||
nesting -= 1
|
||||
if nesting == 0 {
|
||||
return body
|
||||
}
|
||||
if nesting == 0 { return body }
|
||||
body += "]]>"
|
||||
} else if let character = reader.peek() {
|
||||
body.append(character)
|
||||
@ -170,9 +168,7 @@ extension DTDScanner {
|
||||
while index < text.endIndex {
|
||||
let character = text[index]
|
||||
if let open = quote {
|
||||
if character == open {
|
||||
quote = nil
|
||||
}
|
||||
if character == open { quote = nil }
|
||||
index = text.index(after: index)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -95,9 +95,7 @@ extension DTDScanner {
|
||||
if character == "&" || character == "%" {
|
||||
var probe = index + 1
|
||||
let isCharacterReference = character == "&" && probe < characters.count && characters[probe] == "#"
|
||||
if isCharacterReference {
|
||||
probe += 1
|
||||
}
|
||||
if isCharacterReference { probe += 1 }
|
||||
let bodyStart = probe
|
||||
let stops: Set<Character> = [";", "&", "%"]
|
||||
while probe < characters.count, !stops.contains(characters[probe]), !characters[probe].isWhitespace {
|
||||
@ -239,9 +237,7 @@ extension DTDScanner {
|
||||
var systemID = ""
|
||||
if reader.peek()?.isWhitespace == true {
|
||||
reader.skipSpace()
|
||||
if let literal = scanLiteral(&reader) {
|
||||
systemID = literal
|
||||
}
|
||||
if let literal = scanLiteral(&reader) { systemID = literal }
|
||||
}
|
||||
return ExternalID(publicID: publicID, systemID: systemID, base: currentBase)
|
||||
}
|
||||
|
||||
@ -26,9 +26,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append(scalar)
|
||||
} else {
|
||||
scalars.append("\u{FFFD}")
|
||||
if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
}
|
||||
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
|
||||
}
|
||||
} else if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
@ -38,9 +36,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
}
|
||||
if lead != 0 {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
if lead != 0 { scalars.append("\u{FFFD}") }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
|
||||
@ -17,9 +17,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append(scalar)
|
||||
} else {
|
||||
scalars.append("\u{FFFD}")
|
||||
if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
}
|
||||
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
|
||||
}
|
||||
} else if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
@ -29,9 +27,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
}
|
||||
if lead != 0 {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
if lead != 0 { scalars.append("\u{FFFD}") }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
|
||||
@ -18,14 +18,7 @@ extension PureXML.Parsing {
|
||||
budget: inout Int,
|
||||
at mark: Mark,
|
||||
) throws -> String {
|
||||
// A `Character` literal here resolves to `Sequence.contains(_:)`,
|
||||
// walking extended grapheme clusters to find one ASCII byte; called
|
||||
// once per ampersand-bearing text node or attribute value in the
|
||||
// whole document. `utf8.contains(0x26)` finds the same `&` via the
|
||||
// UTF-8 byte view, since 0x26 is never a lead or continuation byte
|
||||
// of a multi-byte sequence (matches the reasoning already applied
|
||||
// to `NamespaceContext.checkNameShape`'s colon check).
|
||||
guard raw.utf8.contains(0x26) else { return raw }
|
||||
guard raw.contains("&") else { return raw }
|
||||
var expander = EntityExpander(entities: entities, mark: mark, budget: budget)
|
||||
try expander.expand(raw, visiting: [], counts: false)
|
||||
budget = expander.budget
|
||||
@ -44,9 +37,7 @@ extension PureXML.Parsing {
|
||||
at mark: Mark,
|
||||
undeclared: inout [String],
|
||||
) throws -> String {
|
||||
// See `decode(_:entities:budget:at:)`'s matching guard: byte-level
|
||||
// scan, not a grapheme walk, for the same reason.
|
||||
guard raw.utf8.contains(0x26) else { return raw }
|
||||
guard raw.contains("&") else { return raw }
|
||||
var expander = EntityExpander(entities: entities, mark: mark, budget: budget, undeclared: [])
|
||||
try expander.expand(raw, visiting: [], counts: false)
|
||||
budget = expander.budget
|
||||
@ -78,12 +69,11 @@ extension PureXML.Parsing.EntityDecoder {
|
||||
/// plain decoding suffices.
|
||||
static func splitAtMarkupEntity(_ raw: String, entities: [String: String]) -> MarkupSplit? {
|
||||
guard !entities.isEmpty else { return nil }
|
||||
var markupCache: [String: Bool] = [:]
|
||||
var index = raw.startIndex
|
||||
while index < raw.endIndex, let amp = raw[index...].firstIndex(of: "&") {
|
||||
guard let semicolon = raw[amp...].firstIndex(of: ";") else { return nil }
|
||||
let name = String(raw[raw.index(after: amp) ..< semicolon])
|
||||
if !name.hasPrefix("#"), !predefined.contains(name), producesMarkup(name, entities: entities, cache: &markupCache) {
|
||||
if !name.hasPrefix("#"), !predefined.contains(name), producesMarkup(name, entities: entities, visiting: []) {
|
||||
return MarkupSplit(
|
||||
prefix: String(raw[..<amp]),
|
||||
name: name,
|
||||
@ -120,24 +110,12 @@ extension PureXML.Parsing.EntityDecoder {
|
||||
}
|
||||
|
||||
/// Whether `name`'s replacement contains markup, directly or transitively.
|
||||
/// Memoized per entity name: the property depends only on the (fixed) entity
|
||||
/// graph, so a deeply-nested reference chain resolves in O(entities) rather
|
||||
/// than O(references^depth), which a billion-laughs document otherwise forces.
|
||||
/// A tentative `false` recorded before the recursion breaks any cycle (a
|
||||
/// cyclic entity is an error caught when the reference is expanded).
|
||||
private static func producesMarkup(_ name: String, entities: [String: String], cache: inout [String: Bool]) -> Bool {
|
||||
if let cached = cache[name] {
|
||||
return cached
|
||||
private static func producesMarkup(_ name: String, entities: [String: String], visiting: Set<String>) -> Bool {
|
||||
guard !visiting.contains(name), let replacement = entities[name] else { return false }
|
||||
if replacement.contains("<") { return true }
|
||||
return declaredReferences(in: replacement).contains {
|
||||
producesMarkup($0, entities: entities, visiting: visiting.union([name]))
|
||||
}
|
||||
guard let replacement = entities[name] else {
|
||||
cache[name] = false
|
||||
return false
|
||||
}
|
||||
cache[name] = false
|
||||
let result = replacement.contains("<")
|
||||
|| declaredReferences(in: replacement).contains { producesMarkup($0, entities: entities, cache: &cache) }
|
||||
cache[name] = result
|
||||
return result
|
||||
}
|
||||
|
||||
private static func checkRecursion(
|
||||
@ -145,32 +123,13 @@ extension PureXML.Parsing.EntityDecoder {
|
||||
entities: [String: String],
|
||||
visiting: Set<String>,
|
||||
at mark: PureXML.Parsing.Mark,
|
||||
) throws {
|
||||
var verified: Set<String> = []
|
||||
try checkRecursion(name, entities: entities, visiting: visiting, verified: &verified, at: mark)
|
||||
}
|
||||
|
||||
/// Three-colour DFS for the No-Recursion WFC. `visiting` is the grey set
|
||||
/// (entities on the current reference chain; revisiting one is the cycle).
|
||||
/// `verified` is the black set: an entity is added only after its whole
|
||||
/// reachable subgraph returns acyclic, so it can never reach a chain node
|
||||
/// that reaches it. Skipping a black entity is therefore sound and collapses
|
||||
/// the otherwise O(references^depth) revisits of a billion-laughs graph to
|
||||
/// one pass over the distinct edges.
|
||||
private static func checkRecursion(
|
||||
_ name: String,
|
||||
entities: [String: String],
|
||||
visiting: Set<String>,
|
||||
verified: inout Set<String>,
|
||||
at mark: PureXML.Parsing.Mark,
|
||||
) throws {
|
||||
guard let replacement = entities[name] else { return }
|
||||
for reference in declaredReferences(in: replacement) where !verified.contains(reference) {
|
||||
for reference in declaredReferences(in: replacement) {
|
||||
guard !visiting.contains(reference) else {
|
||||
throw PureXML.Parsing.ParseError.recursiveEntity(name: reference, mark)
|
||||
}
|
||||
try checkRecursion(reference, entities: entities, visiting: visiting.union([reference]), verified: &verified, at: mark)
|
||||
verified.insert(reference)
|
||||
try checkRecursion(reference, entities: entities, visiting: visiting.union([reference]), at: mark)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -57,13 +57,9 @@ extension PureXML.Parsing {
|
||||
while let character = scanner.advance() {
|
||||
switch character {
|
||||
case "<":
|
||||
if let reason = scanner.markup(depth: &depth) {
|
||||
return reason
|
||||
}
|
||||
if let reason = scanner.markup(depth: &depth) { return reason }
|
||||
case "&":
|
||||
if !scanner.completeReference() {
|
||||
return "incomplete reference"
|
||||
}
|
||||
if !scanner.completeReference() { return "incomplete reference" }
|
||||
default:
|
||||
break
|
||||
}
|
||||
@ -104,9 +100,7 @@ private struct ReplacementScanner {
|
||||
|
||||
private mutating func skip(past terminator: String) -> Bool {
|
||||
while index < characters.count {
|
||||
if consume(terminator) {
|
||||
return true
|
||||
}
|
||||
if consume(terminator) { return true }
|
||||
index += 1
|
||||
}
|
||||
return false
|
||||
@ -132,12 +126,8 @@ private struct ReplacementScanner {
|
||||
depth -= 1
|
||||
return depth < 0 ? "an end tag closes an element opened outside the entity" : nil
|
||||
}
|
||||
if let reason = attributes() {
|
||||
return reason
|
||||
}
|
||||
if consume("/>") {
|
||||
return nil
|
||||
}
|
||||
if let reason = attributes() { return reason }
|
||||
if consume("/>") { return nil }
|
||||
guard consume(">") else { return "malformed start tag" }
|
||||
depth += 1
|
||||
return nil
|
||||
@ -156,16 +146,12 @@ private struct ReplacementScanner {
|
||||
while true {
|
||||
skipSpace()
|
||||
guard let next = peek() else { return "unterminated start tag" }
|
||||
if next == ">" || next == "/" {
|
||||
return nil
|
||||
}
|
||||
if next == ">" || next == "/" { return nil }
|
||||
guard name() else { return "malformed attribute name" }
|
||||
skipSpace()
|
||||
guard consume("=") else { return "attribute without a value" }
|
||||
skipSpace()
|
||||
if let reason = attributeValue() {
|
||||
return reason
|
||||
}
|
||||
if let reason = attributeValue() { return reason }
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,14 +160,10 @@ private struct ReplacementScanner {
|
||||
guard let quote = peek(), quote == "\"" || quote == "'" else { return "unquoted attribute value" }
|
||||
index += 1
|
||||
while let character = peek(), character != quote {
|
||||
if character == "<" {
|
||||
return "'<' in an attribute value"
|
||||
}
|
||||
if character == "<" { return "'<' in an attribute value" }
|
||||
if character == "&" {
|
||||
index += 1
|
||||
if !completeReference() {
|
||||
return "incomplete reference in an attribute value"
|
||||
}
|
||||
if !completeReference() { return "incomplete reference in an attribute value" }
|
||||
continue
|
||||
}
|
||||
index += 1
|
||||
@ -192,9 +174,7 @@ private struct ReplacementScanner {
|
||||
/// After a consumed `&`: a complete `name;` or `#digits;`.
|
||||
mutating func completeReference() -> Bool {
|
||||
var sawBody = false
|
||||
if peek() == "#" {
|
||||
index += 1
|
||||
}
|
||||
if peek() == "#" { index += 1 }
|
||||
while let character = peek(), character != ";" {
|
||||
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameChar) else { return false }
|
||||
sawBody = true
|
||||
|
||||
@ -26,13 +26,6 @@ public extension PureXML.Parsing {
|
||||
var entityBudget: Int
|
||||
|
||||
var pending: [Event] = []
|
||||
/// Scratch storage for `scanAttributes`' duplicate-name check, hoisted
|
||||
/// to a stored property so one element's check reuses the previous
|
||||
/// element's `Set` storage (`removeAll(keepingCapacity: true)`) instead
|
||||
/// of allocating fresh native storage per element with attributes.
|
||||
/// Safe to share because `scanAttributes` is not reentrant: nothing
|
||||
/// inside an attribute scan can start another start-tag scan.
|
||||
var attributeNamesSeen: Set<String> = []
|
||||
private var sawRoot = false
|
||||
private var primed = false
|
||||
/// When true, ``next()`` repairs malformed input in place instead of
|
||||
@ -88,16 +81,12 @@ public extension PureXML.Parsing {
|
||||
guard recovering else {
|
||||
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
|
||||
}
|
||||
if reachedEnd {
|
||||
return nil
|
||||
}
|
||||
if reachedEnd { return nil }
|
||||
let start = offset
|
||||
do {
|
||||
return open.isEmpty ? try nextAtTopLevel() : try nextInContent()
|
||||
} catch let error as ParseError {
|
||||
if let event = recoveredEvent(from: error, startingAt: start) {
|
||||
return event
|
||||
}
|
||||
if let event = recoveredEvent(from: error, startingAt: start) { return event }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -7,9 +7,7 @@ extension PureXML.Parsing.EventReader {
|
||||
throw PureXML.Parsing.ParseError.malformedDeclaration(mark)
|
||||
}
|
||||
xmlDeclaration = declaration
|
||||
if declaration.version == "1.1" {
|
||||
reader.xml11 = true
|
||||
}
|
||||
if declaration.version == "1.1" { reader.xml11 = true }
|
||||
}
|
||||
|
||||
/// The current source offset, used to tell whether a failing ``next()`` made
|
||||
@ -33,9 +31,7 @@ extension PureXML.Parsing.EventReader {
|
||||
mutating func recover(from offset: Int) -> Bool {
|
||||
pending.removeAll()
|
||||
guard reader.peek() != nil else { return false }
|
||||
if reader.mark.offset > offset {
|
||||
return true
|
||||
}
|
||||
if reader.mark.offset > offset { return true }
|
||||
reader.advance()
|
||||
while let character = reader.peek(), character != "<" {
|
||||
reader.advance()
|
||||
@ -106,9 +102,7 @@ extension PureXML.Parsing.EventReader {
|
||||
case let .mismatchedEndTag(_, found, _), let .unexpectedEndTag(found, _):
|
||||
return closeTo(found)
|
||||
default:
|
||||
if !recover(from: start) {
|
||||
reachedEnd = true
|
||||
}
|
||||
if !recover(from: start) { reachedEnd = true }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
/// Text and processing-instruction scanning, split from the reader body to
|
||||
/// keep it under the length caps. Behavior is part of EventReader proper.
|
||||
extension PureXML.Parsing.EventReader {
|
||||
/// Returns the next text event, or nil after splicing: a reference to
|
||||
/// an entity whose replacement (directly or transitively) contains
|
||||
/// markup is validated, budgeted, and pushed back into the reader so
|
||||
/// it is reparsed as content (4.4.2 Included), elements in replacement
|
||||
/// text become elements in the stream, not character data.
|
||||
mutating func scanText() throws -> Event? {
|
||||
let mark = reader.mark
|
||||
var raw = ""
|
||||
var length = 0
|
||||
// Whether any '&' was seen: without one the text has no references
|
||||
// and no markup-entity boundary, so the whole decode/split/findings
|
||||
// pass is skipped. The scanners visit every byte already, so this
|
||||
// costs nothing beyond a compare they were doing anyway.
|
||||
var sawAmpersand = false
|
||||
while true {
|
||||
// Bulk byte runs first: plain ASCII content (the bulk of any
|
||||
// document) skips the per-character machinery entirely; the
|
||||
// run never spans '<', CR, ']', or invalid bytes, so the
|
||||
// character path below keeps exact error marks.
|
||||
if let run = reader.contentRunBytes(sawAmpersand: &sawAmpersand) {
|
||||
length += run.utf8.count
|
||||
try checkContent(length, mark)
|
||||
raw += run
|
||||
continue
|
||||
}
|
||||
// Detect the closing '<' on the byte path so the buffer stays
|
||||
// empty: a buffered '<' would force the next element's dispatch,
|
||||
// matches(), and name scan onto the slow Character path.
|
||||
if reader.peekByte() == 0x3C {
|
||||
break
|
||||
}
|
||||
guard let character = reader.peek(), character != "<" else { break }
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
if character == ">", raw.hasSuffix("]]") {
|
||||
throw ParseError.cdataCloseInContent(reader.mark)
|
||||
}
|
||||
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else {
|
||||
throw ParseError.invalidCharacter(reader.mark)
|
||||
}
|
||||
if character == "&" {
|
||||
sawAmpersand = true
|
||||
}
|
||||
raw.append(character)
|
||||
reader.advance()
|
||||
}
|
||||
guard sawAmpersand else { return .characters(raw) }
|
||||
let split = EntityDecoder.splitAtMarkupEntity(raw, entities: referencableEntities)
|
||||
if let split {
|
||||
let replacement = try EntityDecoder.includeForContent(
|
||||
split.name,
|
||||
entities: referencableEntities,
|
||||
budget: &entityBudget,
|
||||
at: mark,
|
||||
)
|
||||
reader.inject(replacement + split.remainder)
|
||||
let prefix = try decodeReferences(split.prefix, at: mark)
|
||||
return prefix.isEmpty ? nil : .characters(prefix)
|
||||
}
|
||||
let decoded = try decodeReferences(raw, at: mark)
|
||||
recordReferenceContentFindings(raw: raw, decoded: decoded)
|
||||
return .characters(decoded)
|
||||
}
|
||||
|
||||
mutating func scanProcessingInstruction() throws -> (target: String, data: String) {
|
||||
let mark = reader.mark
|
||||
reader.consume("<?")
|
||||
let target = try scanName()
|
||||
// A PI target is an NCName under namespaces (no colon).
|
||||
if target.description.contains(":") {
|
||||
throw ParseError.namespaceConstraint(reason: "PI target '\(target.description)' is not a legal NCName", mark)
|
||||
}
|
||||
// Production 16: the target and the data must be separated by
|
||||
// whitespace, so `<?target+++?>` is not well-formed.
|
||||
if let next = reader.peek(), !next.isWhitespace, !reader.matches("?>") {
|
||||
throw ParseError.unexpectedCharacter(next, reader.mark)
|
||||
}
|
||||
reader.skipSpace()
|
||||
var data = ""
|
||||
var length = 0
|
||||
while !reader.matches("?>") {
|
||||
guard let character = reader.advance() else {
|
||||
throw ParseError.unterminatedTag(mark)
|
||||
}
|
||||
length += 1
|
||||
try checkContent(length, mark)
|
||||
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else {
|
||||
throw ParseError.invalidCharacter(reader.mark)
|
||||
}
|
||||
data.append(character)
|
||||
}
|
||||
reader.consume("?>")
|
||||
return (target.description, data)
|
||||
}
|
||||
}
|
||||
@ -19,9 +19,7 @@ extension PureXML.Parsing.EventReader {
|
||||
/// it is a WFC when the document is standalone or could not have
|
||||
/// external declarations, and a validity finding otherwise.
|
||||
var undeclaredEntityIsFatal: Bool {
|
||||
if xmlDeclaration?.standalone == true {
|
||||
return true
|
||||
}
|
||||
if xmlDeclaration?.standalone == true { return true }
|
||||
return documentType.externalSubset == nil && documentType.parameterEntities.isEmpty
|
||||
}
|
||||
|
||||
@ -49,9 +47,7 @@ extension PureXML.Parsing.EventReader {
|
||||
/// an entity whose stored replacement does (the errata E15h shape: the
|
||||
/// double-escaped reference surfaces when the replacement is reparsed).
|
||||
mutating func referencesCharacter(_ raw: String) -> Bool {
|
||||
if containsCharacterReference(raw) {
|
||||
return true
|
||||
}
|
||||
if containsCharacterReference(raw) { return true }
|
||||
let entities = referencableEntities
|
||||
var index = raw.startIndex
|
||||
while index < raw.endIndex, let amp = raw[index...].firstIndex(of: "&") {
|
||||
@ -70,9 +66,7 @@ extension PureXML.Parsing.EventReader {
|
||||
func containsCharacterReference(_ raw: String) -> Bool {
|
||||
var sawAmp = false
|
||||
for character in raw {
|
||||
if sawAmp, character == "#" {
|
||||
return true
|
||||
}
|
||||
if sawAmp, character == "#" { return true }
|
||||
sawAmp = character == "&"
|
||||
}
|
||||
return false
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -71,12 +71,8 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
/// The scalar for a four-byte pointer through the linear ranges, or nil for
|
||||
/// the unmapped gap and the out-of-range tail.
|
||||
static func rangeScalar(_ pointer: Int) -> Unicode.Scalar? {
|
||||
if pointer > 39419, pointer < 189_000 {
|
||||
return nil
|
||||
}
|
||||
if pointer > 1_237_575 {
|
||||
return nil
|
||||
}
|
||||
if pointer > 39419, pointer < 189_000 { return nil }
|
||||
if pointer > 1_237_575 { return nil }
|
||||
let target = UInt32(pointer)
|
||||
var low = 0
|
||||
var high = gb18030RangePointers.count - 1
|
||||
|
||||
@ -18,9 +18,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append(scalar)
|
||||
} else {
|
||||
scalars.append("\u{FFFD}")
|
||||
if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
}
|
||||
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) }
|
||||
}
|
||||
} else if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
@ -32,9 +30,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
}
|
||||
if lead != 0 {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
if lead != 0 { scalars.append("\u{FFFD}") }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
|
||||
@ -19,9 +19,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
}
|
||||
decodeByte(byte, mode: mode, lead: &lead, into: &scalars)
|
||||
}
|
||||
if lead != nil {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
if lead != nil { scalars.append("\u{FFFD}") }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,9 +30,7 @@ extension PureXML.Parsing.XMLDeclaration {
|
||||
// Pseudo-attributes must be separated by whitespace.
|
||||
let before = index
|
||||
skipSpace()
|
||||
if index == before, index < chars.count {
|
||||
return nil
|
||||
}
|
||||
if index == before, index < chars.count { return nil }
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
@ -52,17 +52,13 @@ public extension PureXML.Parsing {
|
||||
case let .close(name):
|
||||
close(name)
|
||||
case let .text(value):
|
||||
if !value.isEmpty {
|
||||
handler.characters?(value)
|
||||
}
|
||||
if !value.isEmpty { handler.characters?(value) }
|
||||
case let .comment(value):
|
||||
handler.comment?(value)
|
||||
case let .cdata(value):
|
||||
handler.cdata?(value)
|
||||
case let .processingInstruction(target, data):
|
||||
if target.lowercased() != "xml" {
|
||||
handler.processingInstruction?(target, data)
|
||||
}
|
||||
if target.lowercased() != "xml" { handler.processingInstruction?(target, data) }
|
||||
case .ignorable:
|
||||
break
|
||||
}
|
||||
|
||||
@ -64,9 +64,7 @@ private struct TagCursor {
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count {
|
||||
index += 1
|
||||
}
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
|
||||
private mutating func skipSpace() {
|
||||
@ -133,21 +131,11 @@ extension PureXML.Parsing {
|
||||
if isPrefix(buffer, ofAny: ["<!--", "<![CDATA[", "<!", "</", "<?"], decidedAt: 2), !final {
|
||||
return .needMore
|
||||
}
|
||||
if startsWith(buffer, "<!--") {
|
||||
return delimited(buffer, open: 4, close: "-->") { .comment(String($0)) }
|
||||
}
|
||||
if startsWith(buffer, "<![CDATA[") {
|
||||
return delimited(buffer, open: 9, close: "]]>") { .cdata(String($0)) }
|
||||
}
|
||||
if startsWith(buffer, "<!") {
|
||||
return until(buffer, terminator: ">") { _ in .ignorable }
|
||||
}
|
||||
if startsWith(buffer, "</") {
|
||||
return scanEndTag(buffer)
|
||||
}
|
||||
if startsWith(buffer, "<?") {
|
||||
return scanProcessingInstruction(buffer)
|
||||
}
|
||||
if startsWith(buffer, "<!--") { return delimited(buffer, open: 4, close: "-->") { .comment(String($0)) } }
|
||||
if startsWith(buffer, "<![CDATA[") { return delimited(buffer, open: 9, close: "]]>") { .cdata(String($0)) } }
|
||||
if startsWith(buffer, "<!") { return until(buffer, terminator: ">") { _ in .ignorable } }
|
||||
if startsWith(buffer, "</") { return scanEndTag(buffer) }
|
||||
if startsWith(buffer, "<?") { return scanProcessingInstruction(buffer) }
|
||||
return scanStartTag(buffer, final: final)
|
||||
}
|
||||
|
||||
@ -174,9 +162,7 @@ extension PureXML.Parsing {
|
||||
guard let end = tagEnd(buffer) else { return final ? .token(.text("<"), consumed: 1) : .needMore }
|
||||
var inner = Array(buffer[1 ..< end])
|
||||
let selfClosing = inner.last == "/"
|
||||
if selfClosing {
|
||||
inner.removeLast()
|
||||
}
|
||||
if selfClosing { inner.removeLast() }
|
||||
let (name, attributes) = parseTag(inner)
|
||||
return .token(.open(name: name, attributes: attributes, selfClosing: selfClosing), consumed: end + 1)
|
||||
}
|
||||
@ -215,9 +201,7 @@ extension PureXML.Parsing {
|
||||
for index in 1 ..< buffer.count {
|
||||
let character = buffer[index]
|
||||
if let active = quote {
|
||||
if character == active {
|
||||
quote = nil
|
||||
}
|
||||
if character == active { quote = nil }
|
||||
} else if character == "\"" || character == "'" {
|
||||
quote = character
|
||||
} else if character == ">" {
|
||||
|
||||
@ -12,22 +12,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -19,9 +19,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append(scalar)
|
||||
} else {
|
||||
scalars.append("\u{FFFD}")
|
||||
if byte <= 0x7F {
|
||||
scalars.append(Unicode.Scalar(byte))
|
||||
} // reprocess ASCII trail
|
||||
if byte <= 0x7F { scalars.append(Unicode.Scalar(byte)) } // reprocess ASCII trail
|
||||
}
|
||||
} else if let single = singleByteScalar(byte) {
|
||||
scalars.append(single)
|
||||
@ -31,21 +29,15 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
}
|
||||
if lead != 0 {
|
||||
scalars.append("\u{FFFD}")
|
||||
}
|
||||
if lead != 0 { scalars.append("\u{FFFD}") }
|
||||
return String(scalars)
|
||||
}
|
||||
|
||||
/// The scalar for a byte that is not part of a two-byte sequence: ASCII
|
||||
/// (`0x00`-`0x80`) or half-width katakana (`0xA1`-`0xDF`), else nil.
|
||||
static func singleByteScalar(_ byte: UInt8) -> Unicode.Scalar? {
|
||||
if byte <= 0x80 {
|
||||
return Unicode.Scalar(byte)
|
||||
}
|
||||
if (0xA1 ... 0xDF).contains(byte) {
|
||||
return Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1)
|
||||
}
|
||||
if byte <= 0x80 { return Unicode.Scalar(byte) }
|
||||
if (0xA1 ... 0xDF).contains(byte) { return Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) }
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -61,9 +53,7 @@ extension PureXML.Parsing.ByteDecoder {
|
||||
let offset = lead < 0xA0 ? 0x81 : 0xC1
|
||||
let trailOffset = trail < 0x7F ? 0x40 : 0x41
|
||||
let pointer = (Int(lead) - offset) * 188 + Int(trail) - trailOffset
|
||||
if (8836 ... 10715).contains(pointer) {
|
||||
return Unicode.Scalar(0xE000 + pointer - 8836)
|
||||
} // EUDC private use
|
||||
if (8836 ... 10715).contains(pointer) { return Unicode.Scalar(0xE000 + pointer - 8836) } // EUDC private use
|
||||
guard pointer >= 0, pointer < jis0208.count, jis0208[pointer] != 0xFFFF else { return nil }
|
||||
return Unicode.Scalar(UInt32(jis0208[pointer]))
|
||||
}
|
||||
|
||||
@ -47,6 +47,136 @@ extension PureXML.Parsing {
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams one character from a multi-byte CJK encoding.
|
||||
private mutating func nextCJK(_ encoding: InputEncoding) -> Character? {
|
||||
switch encoding {
|
||||
case .shiftJIS: nextShiftJIS()
|
||||
case .eucJP: nextEUCJP()
|
||||
case .eucKR: nextEUCKR()
|
||||
case .gbk: nextGBK()
|
||||
case .gb18030: nextGB18030()
|
||||
case .big5: nextBig5()
|
||||
case .iso2022jp: nextISO2022JP()
|
||||
case .eucTW: nextEUCTW()
|
||||
default: nextUTF8()
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams one GBK character: ASCII, `0x80` (euro), or a lead+trail.
|
||||
private mutating func nextGBK() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
|
||||
if lead == 0x80 { return "\u{20AC}" }
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one GB18030 character: ASCII, `0x80` (euro), a four-byte
|
||||
/// sequence when the lead is followed by a digit, or a two-byte GBK pair.
|
||||
private mutating func nextGB18030() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
|
||||
if lead == 0x80 { return "\u{20AC}" }
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let second = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
if (0x30 ... 0x39).contains(second) {
|
||||
guard let third = nextByte(), let fourth = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard (0x81 ... 0xFE).contains(third), (0x30 ... 0x39).contains(fourth) else { return Self.replacement }
|
||||
let pointer = PureXML.Parsing.ByteDecoder.GB18030.fourBytePointer(lead, second, third, fourth)
|
||||
return PureXML.Parsing.ByteDecoder.GB18030.rangeScalar(pointer).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: second).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one Big5 character: ASCII, or a lead+trail through the index
|
||||
/// (a two-scalar combining sequence stays one grapheme, one `Character`).
|
||||
private mutating func nextBig5() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard let sequence = PureXML.Parsing.ByteDecoder.Big5.mapping(lead: lead, trail: trail) else {
|
||||
return Self.replacement
|
||||
}
|
||||
return Character(String(String.UnicodeScalarView(sequence)))
|
||||
}
|
||||
|
||||
/// Streams one EUC-KR character: ASCII, or a lead+trail through CP949.
|
||||
private mutating func nextEUCKR() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.EUCKR.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one EUC-JP character: ASCII, `0x8E`+byte (half-width katakana),
|
||||
/// `0x8F`+two bytes (JIS X 0212), or a lead+byte through JIS X 0208.
|
||||
private mutating func nextEUCJP() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
|
||||
if lead == 0x8E {
|
||||
guard let byte = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard (0xA1 ... 0xDF).contains(byte) else { return Self.replacement }
|
||||
return Character(Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) ?? "\u{FFFD}")
|
||||
}
|
||||
if lead == 0x8F {
|
||||
guard let mid = nextByte(), let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: mid, trail: trail, useJis0212: true).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: lead, trail: trail, useJis0212: false).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one Shift-JIS character: a single byte (ASCII or half-width
|
||||
/// katakana) or a lead+trail two-byte sequence resolved through JIS X 0208.
|
||||
private mutating func nextShiftJIS() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if let single = PureXML.Parsing.ByteDecoder.ShiftJIS.singleByteScalar(lead) { return Character(single) }
|
||||
guard PureXML.Parsing.ByteDecoder.ShiftJIS.isLead(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else {
|
||||
finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard let scalar = PureXML.Parsing.ByteDecoder.ShiftJIS.twoByteScalar(lead: lead, trail: trail) else { return Self.replacement }
|
||||
return Character(scalar)
|
||||
}
|
||||
|
||||
private mutating func nextUTF32(bigEndian: Bool) -> Character? {
|
||||
var ordered: [UInt32] = []
|
||||
for _ in 0 ..< 4 {
|
||||
@ -56,9 +186,7 @@ extension PureXML.Parsing {
|
||||
}
|
||||
ordered.append(UInt32(byte))
|
||||
}
|
||||
if !bigEndian {
|
||||
ordered.reverse()
|
||||
}
|
||||
if !bigEndian { ordered.reverse() }
|
||||
let value = ordered.reduce(UInt32(0)) { ($0 << 8) | $1 }
|
||||
return Character(Unicode.Scalar(value) ?? "\u{FFFD}")
|
||||
}
|
||||
@ -101,7 +229,7 @@ extension PureXML.Parsing {
|
||||
return pull()
|
||||
}
|
||||
|
||||
mutating func nextUTF8() -> Character? {
|
||||
private mutating func nextUTF8() -> Character? {
|
||||
guard let first = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
extension PureXML.Parsing.StreamingDecoder {
|
||||
/// Streams one character from a multi-byte CJK encoding.
|
||||
mutating func nextCJK(_ encoding: PureXML.Parsing.InputEncoding) -> Character? {
|
||||
switch encoding {
|
||||
case .shiftJIS: nextShiftJIS()
|
||||
case .eucJP: nextEUCJP()
|
||||
case .eucKR: nextEUCKR()
|
||||
case .gbk: nextGBK()
|
||||
case .gb18030: nextGB18030()
|
||||
case .big5: nextBig5()
|
||||
case .iso2022jp: nextISO2022JP()
|
||||
case .eucTW: nextEUCTW()
|
||||
default: nextUTF8()
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams one GBK character: ASCII, `0x80` (euro), or a lead+trail.
|
||||
private mutating func nextGBK() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F {
|
||||
return Character(Unicode.Scalar(lead))
|
||||
}
|
||||
if lead == 0x80 {
|
||||
return "\u{20AC}"
|
||||
}
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one GB18030 character: ASCII, `0x80` (euro), a four-byte
|
||||
/// sequence when the lead is followed by a digit, or a two-byte GBK pair.
|
||||
private mutating func nextGB18030() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F {
|
||||
return Character(Unicode.Scalar(lead))
|
||||
}
|
||||
if lead == 0x80 {
|
||||
return "\u{20AC}"
|
||||
}
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let second = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
if (0x30 ... 0x39).contains(second) {
|
||||
guard let third = nextByte(), let fourth = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard (0x81 ... 0xFE).contains(third), (0x30 ... 0x39).contains(fourth) else { return Self.replacement }
|
||||
let pointer = PureXML.Parsing.ByteDecoder.GB18030.fourBytePointer(lead, second, third, fourth)
|
||||
return PureXML.Parsing.ByteDecoder.GB18030.rangeScalar(pointer).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.GBK.twoByteScalar(lead: lead, trail: second).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one Big5 character: ASCII, or a lead+trail through the index
|
||||
/// (a two-scalar combining sequence stays one grapheme, one `Character`).
|
||||
private mutating func nextBig5() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F {
|
||||
return Character(Unicode.Scalar(lead))
|
||||
}
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard let sequence = PureXML.Parsing.ByteDecoder.Big5.mapping(lead: lead, trail: trail) else {
|
||||
return Self.replacement
|
||||
}
|
||||
return Character(String(String.UnicodeScalarView(sequence)))
|
||||
}
|
||||
|
||||
/// Streams one EUC-KR character: ASCII, or a lead+trail through CP949.
|
||||
private mutating func nextEUCKR() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F {
|
||||
return Character(Unicode.Scalar(lead))
|
||||
}
|
||||
guard (0x81 ... 0xFE).contains(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.EUCKR.twoByteScalar(lead: lead, trail: trail).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one EUC-JP character: ASCII, `0x8E`+byte (half-width katakana),
|
||||
/// `0x8F`+two bytes (JIS X 0212), or a lead+byte through JIS X 0208.
|
||||
private mutating func nextEUCJP() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F {
|
||||
return Character(Unicode.Scalar(lead))
|
||||
}
|
||||
if lead == 0x8E {
|
||||
guard let byte = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard (0xA1 ... 0xDF).contains(byte) else { return Self.replacement }
|
||||
return Character(Unicode.Scalar(0xFF61 + UInt32(byte) - 0xA1) ?? "\u{FFFD}")
|
||||
}
|
||||
if lead == 0x8F {
|
||||
guard let mid = nextByte(), let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: mid, trail: trail, useJis0212: true).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
guard let trail = nextByte() else { finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
return PureXML.Parsing.ByteDecoder.EUCJP.twoByteScalar(lead: lead, trail: trail, useJis0212: false).map(Character.init) ?? Self.replacement
|
||||
}
|
||||
|
||||
/// Streams one Shift-JIS character: a single byte (ASCII or half-width
|
||||
/// katakana) or a lead+trail two-byte sequence resolved through JIS X 0208.
|
||||
private mutating func nextShiftJIS() -> Character? {
|
||||
guard let lead = nextByte() else {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if let single = PureXML.Parsing.ByteDecoder.ShiftJIS.singleByteScalar(lead) {
|
||||
return Character(single)
|
||||
}
|
||||
guard PureXML.Parsing.ByteDecoder.ShiftJIS.isLead(lead) else { return Self.replacement }
|
||||
guard let trail = nextByte() else {
|
||||
finished = true
|
||||
return Self.replacement
|
||||
}
|
||||
guard let scalar = PureXML.Parsing.ByteDecoder.ShiftJIS.twoByteScalar(lead: lead, trail: trail) else { return Self.replacement }
|
||||
return Character(scalar)
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,7 @@ extension PureXML.Parsing.StreamingDecoder {
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
if lead <= 0x7F {
|
||||
return Character(Unicode.Scalar(lead))
|
||||
}
|
||||
if lead <= 0x7F { return Character(Unicode.Scalar(lead)) }
|
||||
if lead == 0x8E {
|
||||
guard let plane = nextByte(), let byte1 = nextByte(), let byte2 = nextByte() else {
|
||||
finished = true
|
||||
|
||||
@ -186,9 +186,7 @@ public extension PureXML.Parsing {
|
||||
applyStartElement(name: name, attributes: attributes)
|
||||
case let .endElement(name):
|
||||
currentDepth -= 1
|
||||
if !langStack.isEmpty {
|
||||
langStack.removeLast()
|
||||
}
|
||||
if !langStack.isEmpty { langStack.removeLast() }
|
||||
currentName = name
|
||||
xmlLang = langStack.last ?? nil
|
||||
set(.endElement, name: name.description, depth: currentDepth)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -22,9 +22,7 @@ extension PureXML.Pattern {
|
||||
case let .prefixWildcard(prefix):
|
||||
return name.prefix == prefix
|
||||
case let .name(text):
|
||||
if text.contains(":") {
|
||||
return name.description == text
|
||||
}
|
||||
if text.contains(":") { return name.description == text }
|
||||
return name.description == text || name.localName == text
|
||||
}
|
||||
}
|
||||
@ -107,9 +105,7 @@ public extension PureXML.Pattern {
|
||||
private func ancestorMatches(_ stepIndex: Int, below pathIndex: Int, _ path: [PureXML.Model.QualifiedName]) -> Bool {
|
||||
var candidate = pathIndex - 1
|
||||
while candidate >= 0 {
|
||||
if matchUpTo(stepIndex, candidate, path) {
|
||||
return true
|
||||
}
|
||||
if matchUpTo(stepIndex, candidate, path) { return true }
|
||||
candidate -= 1
|
||||
}
|
||||
return false
|
||||
|
||||
@ -42,12 +42,8 @@ extension PureXML.Pattern {
|
||||
}
|
||||
|
||||
private mutating func leadingGap() -> Gap {
|
||||
if consume("//") {
|
||||
return .descendant
|
||||
}
|
||||
if consume("/") {
|
||||
return .root
|
||||
}
|
||||
if consume("//") { return .descendant }
|
||||
if consume("/") { return .root }
|
||||
return .descendant
|
||||
}
|
||||
|
||||
@ -64,17 +60,11 @@ extension PureXML.Pattern {
|
||||
|
||||
private mutating func parseStep(gap: Gap) throws -> RawStep {
|
||||
guard let character = peek() else { throw PatternError.expectedStep }
|
||||
if character == "[" {
|
||||
throw PatternError.unsupported("predicate")
|
||||
}
|
||||
if character == "." {
|
||||
throw PatternError.unsupported(".")
|
||||
}
|
||||
if character == "[" { throw PatternError.unsupported("predicate") }
|
||||
if character == "." { throw PatternError.unsupported(".") }
|
||||
let isAttribute = consume("@")
|
||||
let test = try parseTest()
|
||||
if peek() == "[" {
|
||||
throw PatternError.unsupported("predicate")
|
||||
}
|
||||
if peek() == "[" { throw PatternError.unsupported("predicate") }
|
||||
return RawStep(gap: gap, isAttribute: isAttribute, test: test)
|
||||
}
|
||||
|
||||
@ -126,9 +116,7 @@ extension PureXML.Pattern {
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count {
|
||||
index += 1
|
||||
}
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,9 +29,7 @@ public extension PureXML.Pattern.Matcher {
|
||||
stack.append(name)
|
||||
collect(stack: stack, attributes: attributes, into: &results)
|
||||
case .endElement:
|
||||
if !stack.isEmpty {
|
||||
stack.removeLast()
|
||||
}
|
||||
if !stack.isEmpty { stack.removeLast() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
extension PureXML.Regex {
|
||||
/// The XML 1.0 name-character classes that back the XSD regular-expression
|
||||
/// escapes `\i` and `\c` (XSD 1.0 Datatypes Appendix F). XSD 1.0 defines
|
||||
/// `\i` as the characters that may begin an XML Name (`Letter | '_' | ':'`)
|
||||
/// and `\c` as the characters that may appear in one (`NameChar`), citing the
|
||||
/// productions of XML 1.0 Second Edition. Those are the enumerated character
|
||||
/// classes of XML 1.0 Appendix B (BaseChar, Ideographic, CombiningChar, Digit,
|
||||
/// Extender), a fixed Unicode-2.0-era repertoire, not the broad ranges XML 1.0
|
||||
/// Fifth Edition later substituted.
|
||||
///
|
||||
/// This is deliberately distinct from ``PureXML/Parsing/XMLCharacter``, which
|
||||
/// implements the Fifth Edition Name production used to parse live XML: the two
|
||||
/// definitions genuinely differ (Fifth Edition admits whole blocks where Second
|
||||
/// Edition has gaps, e.g. the combining range is `[#x0300-#x0345]` here but the
|
||||
/// entire `0x300...0x36F` block in the parser). Using the parser's check for
|
||||
/// `\c` over-accepted, e.g. the combining mark U+0346 (XSTS reZ006i).
|
||||
///
|
||||
/// The ranges are transcribed clean-room from the XML 1.0 Appendix B grammar
|
||||
/// (identical across editions one through four; only the fifth replaced it) and
|
||||
/// stored sorted and non-overlapping so membership is a binary search. They are
|
||||
/// verified to reproduce the W3C XSTS `\i`/`\c` enumerations (reZ005, reZ006)
|
||||
/// exactly, in both directions, by ``XSDNameCharacterTests``.
|
||||
enum XSDNameCharacter {
|
||||
/// Whether `scalar` may begin an XML Name per XSD `\i` (`Letter | '_' | ':'`).
|
||||
static func isNameStart(_ scalar: Unicode.Scalar) -> Bool {
|
||||
contains(scalar.value, in: nameStartRanges)
|
||||
}
|
||||
|
||||
/// Whether `scalar` may appear in an XML Name per XSD `\c` (`NameChar`).
|
||||
static func isNameChar(_ scalar: Unicode.Scalar) -> Bool {
|
||||
contains(scalar.value, in: nameCharRanges)
|
||||
}
|
||||
|
||||
/// Binary search over a sorted, non-overlapping `[lo, hi, lo, hi, ...]`
|
||||
/// table; each step compares one range's inclusive bounds.
|
||||
private static func contains(_ value: UInt32, in ranges: [UInt32]) -> Bool {
|
||||
var low = 0
|
||||
var high = ranges.count / 2
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if value < ranges[mid * 2] {
|
||||
high = mid
|
||||
} else if value > ranges[mid * 2 + 1] {
|
||||
low = mid + 1
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PureXML.Regex.XSDNameCharacter {
|
||||
/// Inclusive `[lo, hi]` bounds of the XSD `\i` set: XML 1.0 Appendix B
|
||||
/// `Letter` (`BaseChar | Ideographic`) plus `'_'` and `':'`.
|
||||
static let nameStartRanges: [UInt32] = [
|
||||
0x3A, 0x3A, 0x41, 0x5A, 0x5F, 0x5F, 0x61, 0x7A, 0xC0, 0xD6, 0xD8, 0xF6,
|
||||
0xF8, 0x131, 0x134, 0x13E, 0x141, 0x148, 0x14A, 0x17E, 0x180, 0x1C3, 0x1CD, 0x1F0,
|
||||
0x1F4, 0x1F5, 0x1FA, 0x217, 0x250, 0x2A8, 0x2BB, 0x2C1, 0x386, 0x386, 0x388, 0x38A,
|
||||
0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3CE, 0x3D0, 0x3D6, 0x3DA, 0x3DA, 0x3DC, 0x3DC,
|
||||
0x3DE, 0x3DE, 0x3E0, 0x3E0, 0x3E2, 0x3F3, 0x401, 0x40C, 0x40E, 0x44F, 0x451, 0x45C,
|
||||
0x45E, 0x481, 0x490, 0x4C4, 0x4C7, 0x4C8, 0x4CB, 0x4CC, 0x4D0, 0x4EB, 0x4EE, 0x4F5,
|
||||
0x4F8, 0x4F9, 0x531, 0x556, 0x559, 0x559, 0x561, 0x586, 0x5D0, 0x5EA, 0x5F0, 0x5F2,
|
||||
0x621, 0x63A, 0x641, 0x64A, 0x671, 0x6B7, 0x6BA, 0x6BE, 0x6C0, 0x6CE, 0x6D0, 0x6D3,
|
||||
0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x905, 0x939, 0x93D, 0x93D, 0x958, 0x961, 0x985, 0x98C,
|
||||
0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9DC, 0x9DD,
|
||||
0x9DF, 0x9E1, 0x9F0, 0x9F1, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30,
|
||||
0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA72, 0xA74,
|
||||
0xA85, 0xA8B, 0xA8D, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3,
|
||||
0xAB5, 0xAB9, 0xABD, 0xABD, 0xAE0, 0xAE0, 0xB05, 0xB0C, 0xB0F, 0xB10, 0xB13, 0xB28,
|
||||
0xB2A, 0xB30, 0xB32, 0xB33, 0xB36, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61,
|
||||
0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E, 0xB9F,
|
||||
0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB5, 0xBB7, 0xBB9, 0xC05, 0xC0C, 0xC0E, 0xC10,
|
||||
0xC12, 0xC28, 0xC2A, 0xC33, 0xC35, 0xC39, 0xC60, 0xC61, 0xC85, 0xC8C, 0xC8E, 0xC90,
|
||||
0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCDE, 0xCDE, 0xCE0, 0xCE1, 0xD05, 0xD0C,
|
||||
0xD0E, 0xD10, 0xD12, 0xD28, 0xD2A, 0xD39, 0xD60, 0xD61, 0xE01, 0xE2E, 0xE30, 0xE30,
|
||||
0xE32, 0xE33, 0xE40, 0xE45, 0xE81, 0xE82, 0xE84, 0xE84, 0xE87, 0xE88, 0xE8A, 0xE8A,
|
||||
0xE8D, 0xE8D, 0xE94, 0xE97, 0xE99, 0xE9F, 0xEA1, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEA7,
|
||||
0xEAA, 0xEAB, 0xEAD, 0xEAE, 0xEB0, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4,
|
||||
0xF40, 0xF47, 0xF49, 0xF69, 0x10A0, 0x10C5, 0x10D0, 0x10F6, 0x1100, 0x1100, 0x1102, 0x1103,
|
||||
0x1105, 0x1107, 0x1109, 0x1109, 0x110B, 0x110C, 0x110E, 0x1112, 0x113C, 0x113C, 0x113E, 0x113E,
|
||||
0x1140, 0x1140, 0x114C, 0x114C, 0x114E, 0x114E, 0x1150, 0x1150, 0x1154, 0x1155, 0x1159, 0x1159,
|
||||
0x115F, 0x1161, 0x1163, 0x1163, 0x1165, 0x1165, 0x1167, 0x1167, 0x1169, 0x1169, 0x116D, 0x116E,
|
||||
0x1172, 0x1173, 0x1175, 0x1175, 0x119E, 0x119E, 0x11A8, 0x11A8, 0x11AB, 0x11AB, 0x11AE, 0x11AF,
|
||||
0x11B7, 0x11B8, 0x11BA, 0x11BA, 0x11BC, 0x11C2, 0x11EB, 0x11EB, 0x11F0, 0x11F0, 0x11F9, 0x11F9,
|
||||
0x1E00, 0x1E9B, 0x1EA0, 0x1EF9, 0x1F00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48, 0x1F4D,
|
||||
0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
|
||||
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB,
|
||||
0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2126, 0x2126, 0x212A, 0x212B, 0x212E, 0x212E,
|
||||
0x2180, 0x2182, 0x3007, 0x3007, 0x3021, 0x3029, 0x3041, 0x3094, 0x30A1, 0x30FA, 0x3105, 0x312C,
|
||||
0x4E00, 0x9FA5, 0xAC00, 0xD7A3,
|
||||
]
|
||||
|
||||
/// Inclusive `[lo, hi]` bounds of the XSD `\c` set: XML 1.0 Appendix B
|
||||
/// `NameChar` (`Letter | Digit | '.' | '-' | '_' | ':' | CombiningChar | Extender`).
|
||||
static let nameCharRanges: [UInt32] = [
|
||||
0x2D, 0x2E, 0x30, 0x3A, 0x41, 0x5A, 0x5F, 0x5F, 0x61, 0x7A, 0xB7, 0xB7,
|
||||
0xC0, 0xD6, 0xD8, 0xF6, 0xF8, 0x131, 0x134, 0x13E, 0x141, 0x148, 0x14A, 0x17E,
|
||||
0x180, 0x1C3, 0x1CD, 0x1F0, 0x1F4, 0x1F5, 0x1FA, 0x217, 0x250, 0x2A8, 0x2BB, 0x2C1,
|
||||
0x2D0, 0x2D1, 0x300, 0x345, 0x360, 0x361, 0x386, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1,
|
||||
0x3A3, 0x3CE, 0x3D0, 0x3D6, 0x3DA, 0x3DA, 0x3DC, 0x3DC, 0x3DE, 0x3DE, 0x3E0, 0x3E0,
|
||||
0x3E2, 0x3F3, 0x401, 0x40C, 0x40E, 0x44F, 0x451, 0x45C, 0x45E, 0x481, 0x483, 0x486,
|
||||
0x490, 0x4C4, 0x4C7, 0x4C8, 0x4CB, 0x4CC, 0x4D0, 0x4EB, 0x4EE, 0x4F5, 0x4F8, 0x4F9,
|
||||
0x531, 0x556, 0x559, 0x559, 0x561, 0x586, 0x591, 0x5A1, 0x5A3, 0x5B9, 0x5BB, 0x5BD,
|
||||
0x5BF, 0x5BF, 0x5C1, 0x5C2, 0x5C4, 0x5C4, 0x5D0, 0x5EA, 0x5F0, 0x5F2, 0x621, 0x63A,
|
||||
0x640, 0x652, 0x660, 0x669, 0x670, 0x6B7, 0x6BA, 0x6BE, 0x6C0, 0x6CE, 0x6D0, 0x6D3,
|
||||
0x6D5, 0x6E8, 0x6EA, 0x6ED, 0x6F0, 0x6F9, 0x901, 0x903, 0x905, 0x939, 0x93C, 0x94D,
|
||||
0x951, 0x954, 0x958, 0x963, 0x966, 0x96F, 0x981, 0x983, 0x985, 0x98C, 0x98F, 0x990,
|
||||
0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9BC, 0x9BC, 0x9BE, 0x9C4,
|
||||
0x9C7, 0x9C8, 0x9CB, 0x9CD, 0x9D7, 0x9D7, 0x9DC, 0x9DD, 0x9DF, 0x9E3, 0x9E6, 0x9F1,
|
||||
0xA02, 0xA02, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33,
|
||||
0xA35, 0xA36, 0xA38, 0xA39, 0xA3C, 0xA3C, 0xA3E, 0xA42, 0xA47, 0xA48, 0xA4B, 0xA4D,
|
||||
0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA74, 0xA81, 0xA83, 0xA85, 0xA8B, 0xA8D, 0xA8D,
|
||||
0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, 0xAB5, 0xAB9, 0xABC, 0xAC5,
|
||||
0xAC7, 0xAC9, 0xACB, 0xACD, 0xAE0, 0xAE0, 0xAE6, 0xAEF, 0xB01, 0xB03, 0xB05, 0xB0C,
|
||||
0xB0F, 0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB36, 0xB39, 0xB3C, 0xB43,
|
||||
0xB47, 0xB48, 0xB4B, 0xB4D, 0xB56, 0xB57, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66, 0xB6F,
|
||||
0xB82, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C,
|
||||
0xB9E, 0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB5, 0xBB7, 0xBB9, 0xBBE, 0xBC2,
|
||||
0xBC6, 0xBC8, 0xBCA, 0xBCD, 0xBD7, 0xBD7, 0xBE7, 0xBEF, 0xC01, 0xC03, 0xC05, 0xC0C,
|
||||
0xC0E, 0xC10, 0xC12, 0xC28, 0xC2A, 0xC33, 0xC35, 0xC39, 0xC3E, 0xC44, 0xC46, 0xC48,
|
||||
0xC4A, 0xC4D, 0xC55, 0xC56, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC82, 0xC83, 0xC85, 0xC8C,
|
||||
0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBE, 0xCC4, 0xCC6, 0xCC8,
|
||||
0xCCA, 0xCCD, 0xCD5, 0xCD6, 0xCDE, 0xCDE, 0xCE0, 0xCE1, 0xCE6, 0xCEF, 0xD02, 0xD03,
|
||||
0xD05, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD28, 0xD2A, 0xD39, 0xD3E, 0xD43, 0xD46, 0xD48,
|
||||
0xD4A, 0xD4D, 0xD57, 0xD57, 0xD60, 0xD61, 0xD66, 0xD6F, 0xE01, 0xE2E, 0xE30, 0xE3A,
|
||||
0xE40, 0xE4E, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE87, 0xE88, 0xE8A, 0xE8A,
|
||||
0xE8D, 0xE8D, 0xE94, 0xE97, 0xE99, 0xE9F, 0xEA1, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEA7,
|
||||
0xEAA, 0xEAB, 0xEAD, 0xEAE, 0xEB0, 0xEB9, 0xEBB, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6,
|
||||
0xEC8, 0xECD, 0xED0, 0xED9, 0xF18, 0xF19, 0xF20, 0xF29, 0xF35, 0xF35, 0xF37, 0xF37,
|
||||
0xF39, 0xF39, 0xF3E, 0xF47, 0xF49, 0xF69, 0xF71, 0xF84, 0xF86, 0xF8B, 0xF90, 0xF95,
|
||||
0xF97, 0xF97, 0xF99, 0xFAD, 0xFB1, 0xFB7, 0xFB9, 0xFB9, 0x10A0, 0x10C5, 0x10D0, 0x10F6,
|
||||
0x1100, 0x1100, 0x1102, 0x1103, 0x1105, 0x1107, 0x1109, 0x1109, 0x110B, 0x110C, 0x110E, 0x1112,
|
||||
0x113C, 0x113C, 0x113E, 0x113E, 0x1140, 0x1140, 0x114C, 0x114C, 0x114E, 0x114E, 0x1150, 0x1150,
|
||||
0x1154, 0x1155, 0x1159, 0x1159, 0x115F, 0x1161, 0x1163, 0x1163, 0x1165, 0x1165, 0x1167, 0x1167,
|
||||
0x1169, 0x1169, 0x116D, 0x116E, 0x1172, 0x1173, 0x1175, 0x1175, 0x119E, 0x119E, 0x11A8, 0x11A8,
|
||||
0x11AB, 0x11AB, 0x11AE, 0x11AF, 0x11B7, 0x11B8, 0x11BA, 0x11BA, 0x11BC, 0x11C2, 0x11EB, 0x11EB,
|
||||
0x11F0, 0x11F0, 0x11F9, 0x11F9, 0x1E00, 0x1E9B, 0x1EA0, 0x1EF9, 0x1F00, 0x1F15, 0x1F18, 0x1F1D,
|
||||
0x1F20, 0x1F45, 0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D,
|
||||
0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC,
|
||||
0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x20D0, 0x20DC,
|
||||
0x20E1, 0x20E1, 0x2126, 0x2126, 0x212A, 0x212B, 0x212E, 0x212E, 0x2180, 0x2182, 0x3005, 0x3005,
|
||||
0x3007, 0x3007, 0x3021, 0x302F, 0x3031, 0x3035, 0x3041, 0x3094, 0x3099, 0x309A, 0x309D, 0x309E,
|
||||
0x30A1, 0x30FA, 0x30FC, 0x30FE, 0x3105, 0x312C, 0x4E00, 0x9FA5, 0xAC00, 0xD7A3,
|
||||
]
|
||||
}
|
||||
@ -92,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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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: ", "))"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user