feat(xpointer): range(), range-to(), and string-range() schemes (#88)
This commit is contained in:
parent
47d071ab8e
commit
8871fee977
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- XPointer `range()` model (#88), completing the XPath/XPointer audit. `PureXML.XPointer.evaluateRanges(_:over:)` (and `Pointer.evaluateRanges(over:)`) evaluate the range forms inside an `xpointer()`/`xpath1()` scheme: `range(expr)` (the covering range of each location), `start/range-to(end)` (one range spanning from the first location of `start` to the first of `end`, the sibling run when they share a parent), and `string-range(location, "search"[, offset[, length]])` (a character range per occurrence, with the XPointer offset/length adjustment). Each `Range` materializes to the whole `nodes` it spans and the `text` it covers, and `xmlns()` bindings apply to the range parts that follow, so this also unblocks XInclude range inclusion (#87).
|
||||
- C14N node-subset canonicalization and Canonical XML 2.0 parameters (#85), completing the canonicalization audit. `Canonicalizer.canonicalize(_ subtree: TreeNode)` canonicalizes a fragment in place: the apex element renders the namespace context in scope from its omitted ancestors and inherits their `xml:base`/`xml:lang`/`xml:space` when it does not set them, so a signed fragment is independent of where it sat. `canonicalize(_:including:)` canonicalizes a node-set selected by a `TreeNode` predicate (XPath/position-based selection): an excluded element's tags are omitted but its selected descendants are kept and re-declare their in-scope namespaces. `Options.prefixRewrite = .sequential` rewrites every prefix to a canonical `n0`/`n1`/... in document order (eliminating the default namespace and placing declarations at first use), and `Options.qnameAwareLabels` extends that rewrite into QName-valued attributes and elements, so documents differing only in prefix spelling canonicalize to identical bytes.
|
||||
- Serialization `textEscaping` round-trip option (#91), completing the serialization audit. `Options.textEscaping` defaults to `.standard` (escape only `&`, `<`, `>`); `.roundTrip` also escapes a carriage return as `
` in text and CDATA-as-text output, so a literal `\r` survives the parser's end-of-line normalization and the document round-trips byte for byte. (The CDATA-section vs escaped-text toggle, `cdataAsText`, was already present.)
|
||||
- DOM tree: first-class doctype, entity-reference, and namespace nodes, plus `ownerDocument`, `adopt`, and `importNode` (#90), completing the tree-model audit. `TreeNodeKind` gains `.doctype`, `.entityReference`, and `.namespace`, each with a factory (`TreeNode.doctype(name:publicID:systemID:internalSubset:)`, `.entityReference(_:children:)`, `.namespace(prefix:uri:)`) and typed accessors; doctype and namespace nodes drop out of the content value-`Node` projection while an entity reference splices its replacement into place. `ownerDocument` derives the owning document from the parent chain; `adopt` and `importNode` move or copy a subtree into another document, re-declaring every namespace it relied on from an outer scope so the result is self-contained.
|
||||
|
||||
@ -77,9 +77,9 @@ flowchart TB
|
||||
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
|
||||
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
|
||||
Epic71["#71 Beyond-parity libxml2 audit"]:::epic
|
||||
AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90, serialization #91, C14N #85"]:::done
|
||||
AuditDone["Closed in main: DTD #72 #73, XSD #75 #76 #77 #78, parsing #95 #96 #98 #100, regex #89, catalog #86, XSLT #81 #82, RELAX NG #79, Schematron #80, DOM #90, serialization #91, C14N #85, XPath/XPointer #88"]:::done
|
||||
Epic74["#74 Parser-breadth sub-epic: standalone/decl #95, XML 1.1 #96, reader #98, recovery #100 done; encodings #97 #99 open"]:::epic
|
||||
Partial["Partial in main: XInclude #87, XPath/XPointer #88"]:::todo
|
||||
Partial["Partial in main: XInclude #87"]:::todo
|
||||
Open["Open: HTML5 #83 #84, encoding tables #97 #99, validator decomposition #101"]:::todo
|
||||
Epic71 --> AuditDone
|
||||
AuditDone --> Epic74
|
||||
@ -91,7 +91,7 @@ flowchart TB
|
||||
|
||||
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 **690 tests in 110 suites** (`swift test`).
|
||||
currently runs **697 tests in 111 suites** (`swift test`).
|
||||
|
||||
### Shipped (libxml2-aligned surface)
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
private enum PointerPart {
|
||||
case expression(String)
|
||||
case namespace(prefix: String, uri: String)
|
||||
case range(PureXML.XPointer.RangeForm)
|
||||
}
|
||||
|
||||
/// Scans an XPointer into its scheme parts, each already translated to an XPath
|
||||
@ -73,9 +74,11 @@ private struct PointerScanner {
|
||||
|
||||
private func translate(scheme: String, data: String) throws -> PointerPart {
|
||||
switch scheme {
|
||||
case "xpointer", "xpath1": .expression(data)
|
||||
case "element": .expression(Self.elementToXPath(data))
|
||||
case "xmlns": try Self.namespaceBinding(data)
|
||||
case "xpointer", "xpath1":
|
||||
if let form = PureXML.XPointer.RangeForm.parse(data) { return .range(form) }
|
||||
return .expression(data)
|
||||
case "element": return .expression(Self.elementToXPath(data))
|
||||
case "xmlns": return try Self.namespaceBinding(data)
|
||||
default: throw XPointerError.unknownScheme(scheme)
|
||||
}
|
||||
}
|
||||
@ -172,6 +175,31 @@ public extension PureXML.XPointer {
|
||||
guard let query = try? PureXML.XPath.Query(xpath) else { continue }
|
||||
let result = query.evaluate(over: node, namespaces: namespaces)
|
||||
if !result.isEmpty { return result }
|
||||
case .range:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/// Evaluates the pointer's range parts (`range()`, `range-to()`,
|
||||
/// `string-range()`), returning the first part's non-empty ranges in
|
||||
/// document order, or an empty list. An `xmlns()` part binds a prefix for
|
||||
/// the range parts that follow it. Use this for the XPointer range model
|
||||
/// (and XInclude range inclusion); ``evaluate(over:)`` covers the
|
||||
/// node-selecting schemes.
|
||||
public func evaluateRanges(over node: PureXML.Model.Node) -> [PureXML.XPointer.Range] {
|
||||
let root = PureXML.Model.TreeNode(node)
|
||||
var namespaces: [String: String] = [:]
|
||||
for part in parts {
|
||||
switch part {
|
||||
case let .namespace(prefix, uri):
|
||||
namespaces[prefix] = uri
|
||||
case let .range(form):
|
||||
let ranges = form.ranges(over: root, namespaces: namespaces)
|
||||
if !ranges.isEmpty { return ranges }
|
||||
case .expression:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return []
|
||||
@ -182,4 +210,9 @@ public extension PureXML.XPointer {
|
||||
static func evaluate(_ pointer: String, over node: PureXML.Model.Node) throws -> [PureXML.XPath.Selection] {
|
||||
try Pointer(pointer).evaluate(over: node)
|
||||
}
|
||||
|
||||
/// Compiles and evaluates an XPointer's range parts over a node in one step.
|
||||
static func evaluateRanges(_ pointer: String, over node: PureXML.Model.Node) throws -> [Range] {
|
||||
try Pointer(pointer).evaluateRanges(over: node)
|
||||
}
|
||||
}
|
||||
|
||||
194
Sources/XPointer/PointerRange.swift
Normal file
194
Sources/XPointer/PointerRange.swift
Normal file
@ -0,0 +1,194 @@
|
||||
public extension PureXML.XPointer {
|
||||
/// A contiguous range selected by an XPointer range form, materialized as the
|
||||
/// whole nodes it spans and the character content it covers. Reimplemented
|
||||
/// over the XPointer range model: `range()`, `range-to()`, and `string-range()`.
|
||||
struct Range: Sendable, Equatable {
|
||||
/// The whole nodes the range spans, in document order.
|
||||
public let nodes: [PureXML.Model.Node]
|
||||
/// The character content the range covers: the matched substring for a
|
||||
/// `string-range`, otherwise the spanned nodes' concatenated text.
|
||||
public let text: String
|
||||
}
|
||||
|
||||
/// A parsed XPointer range form, awaiting evaluation against a document. The
|
||||
/// three forms the XPointer range model defines for selecting between two
|
||||
/// points; an ordinary location path is not a range and parses to nil.
|
||||
enum RangeForm: Equatable, Sendable {
|
||||
/// `range(expr)`: the covering range of each location `expr` selects.
|
||||
case covering(expression: String)
|
||||
/// `start/range-to(end)`: one range from the first location of `start` to
|
||||
/// the first location of `end`.
|
||||
case rangeTo(start: String, end: String)
|
||||
/// `string-range(location, "search"[, offset[, length]])`: a character
|
||||
/// range per occurrence of `search` in each location's string value.
|
||||
case stringRange(location: String, search: String, offset: Int?, length: Int?)
|
||||
|
||||
/// Recognizes a range form in `xpointer()` data, or nil when the data is an
|
||||
/// ordinary location expression.
|
||||
static func parse(_ data: String) -> RangeForm? {
|
||||
let trimmed = data.trimmingXMLWhitespace()
|
||||
if let inner = inside(trimmed, scheme: "range") {
|
||||
return .covering(expression: inner)
|
||||
}
|
||||
if let inner = inside(trimmed, scheme: "string-range") {
|
||||
return stringRange(arguments: inner)
|
||||
}
|
||||
if let split = rangeToSplit(trimmed) {
|
||||
return .rangeTo(start: split.start, end: split.end)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The argument inside `scheme( ... )` when `data` is exactly that call.
|
||||
private static func inside(_ data: String, scheme: String) -> String? {
|
||||
let prefix = scheme + "("
|
||||
guard data.hasPrefix(prefix), data.hasSuffix(")") else { return nil }
|
||||
let start = data.index(data.startIndex, offsetBy: prefix.count)
|
||||
return String(data[start ..< data.index(before: data.endIndex)])
|
||||
}
|
||||
|
||||
private static func stringRange(arguments: String) -> RangeForm? {
|
||||
let parts = splitTopLevel(arguments, separator: ",").map { $0.trimmingXMLWhitespace() }
|
||||
guard parts.count >= 2 else { return nil }
|
||||
return .stringRange(
|
||||
location: parts[0],
|
||||
search: unquote(parts[1]),
|
||||
offset: parts.count > 2 ? Int(parts[2]) : nil,
|
||||
length: parts.count > 3 ? Int(parts[3]) : nil,
|
||||
)
|
||||
}
|
||||
|
||||
/// Splits `start/range-to(end)` at the top-level `/range-to(`.
|
||||
private static func rangeToSplit(_ data: String) -> (start: String, end: String)? {
|
||||
let marker = Array("/range-to(")
|
||||
let characters = Array(data)
|
||||
var depth = 0
|
||||
var quote: Character?
|
||||
var index = 0
|
||||
while index < characters.count {
|
||||
let character = characters[index]
|
||||
if let active = quote {
|
||||
if character == active { quote = nil }
|
||||
} else if character == "'" || character == "\"" {
|
||||
quote = character
|
||||
} else if character == "(" {
|
||||
depth += 1
|
||||
} else if character == ")" {
|
||||
depth -= 1
|
||||
} else if depth == 0, matches(characters, at: index, marker) {
|
||||
let start = String(characters[..<index])
|
||||
let endStart = index + marker.count
|
||||
guard characters.last == ")", endStart < characters.count else { return nil }
|
||||
return (start, String(characters[endStart ..< (characters.count - 1)]))
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func matches(_ characters: [Character], at index: Int, _ marker: [Character]) -> Bool {
|
||||
guard index + marker.count <= characters.count else { return false }
|
||||
return Array(characters[index ..< index + marker.count]) == marker
|
||||
}
|
||||
|
||||
private static func unquote(_ value: String) -> String {
|
||||
guard value.count >= 2, let first = value.first, first == "'" || first == "\"", value.last == first else { return value }
|
||||
return String(value.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
/// Splits on a separator that is not inside quotes or parentheses.
|
||||
private static func splitTopLevel(_ value: String, separator: Character) -> [String] {
|
||||
var parts: [String] = []
|
||||
var current = ""
|
||||
var depth = 0
|
||||
var quote: Character?
|
||||
for character in value {
|
||||
if let active = quote {
|
||||
if character == active { quote = nil }
|
||||
current.append(character)
|
||||
} else if character == "'" || character == "\"" {
|
||||
quote = character
|
||||
current.append(character)
|
||||
} else if character == "(" || character == "[" {
|
||||
depth += 1
|
||||
current.append(character)
|
||||
} else if character == ")" || character == "]" {
|
||||
depth -= 1
|
||||
current.append(character)
|
||||
} else if character == separator, depth == 0 {
|
||||
parts.append(current)
|
||||
current = ""
|
||||
} else {
|
||||
current.append(character)
|
||||
}
|
||||
}
|
||||
parts.append(current)
|
||||
return parts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureXML.XPointer.RangeForm {
|
||||
/// Evaluates this range form over a pre-built tree, returning the ranges it
|
||||
/// selects in document order.
|
||||
func ranges(over root: PureXML.Model.TreeNode, namespaces: [String: String]) -> [PureXML.XPointer.Range] {
|
||||
switch self {
|
||||
case let .covering(expression):
|
||||
Self.nodes(expression, over: root, namespaces: namespaces).map { node in
|
||||
PureXML.XPointer.Range(nodes: [node.node], text: node.stringValue)
|
||||
}
|
||||
case let .rangeTo(start, end):
|
||||
rangeTo(start: start, end: end, over: root, namespaces: namespaces)
|
||||
case let .stringRange(location, search, offset, length):
|
||||
Self.stringRanges(in: Self.nodes(location, over: root, namespaces: namespaces), search: search, offset: offset, length: length)
|
||||
}
|
||||
}
|
||||
|
||||
private func rangeTo(start: String, end: String, over root: PureXML.Model.TreeNode, namespaces: [String: String]) -> [PureXML.XPointer.Range] {
|
||||
guard let from = Self.nodes(start, over: root, namespaces: namespaces).first,
|
||||
let target = Self.nodes(end, over: root, namespaces: namespaces).first else { return [] }
|
||||
let spanned = Self.spannedNodes(from: from, to: target)
|
||||
return [PureXML.XPointer.Range(nodes: spanned.map(\.node), text: spanned.map(\.stringValue).joined())]
|
||||
}
|
||||
|
||||
private static func stringRanges(in nodes: [PureXML.Model.TreeNode], search: String, offset: Int?, length: Int?) -> [PureXML.XPointer.Range] {
|
||||
guard !search.isEmpty else { return [] }
|
||||
let needle = Array(search)
|
||||
return nodes.flatMap { node -> [PureXML.XPointer.Range] in
|
||||
let characters = Array(node.stringValue)
|
||||
var ranges: [PureXML.XPointer.Range] = []
|
||||
var index = 0
|
||||
while index + needle.count <= characters.count {
|
||||
guard Array(characters[index ..< index + needle.count]) == needle else { index += 1
|
||||
continue
|
||||
}
|
||||
let begin = min(index + (offset ?? 1) - 1, characters.count)
|
||||
let end = min(begin + (length ?? needle.count), characters.count)
|
||||
let text = String(characters[max(begin, 0) ..< max(end, begin)])
|
||||
ranges.append(PureXML.XPointer.Range(nodes: [.text(text)], text: text))
|
||||
index += needle.count
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole nodes spanned from `from` to `target`: the sibling run between
|
||||
/// them when they share a parent, otherwise the two boundary nodes in document
|
||||
/// order.
|
||||
private static func spannedNodes(from: PureXML.Model.TreeNode, to target: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
|
||||
let ordered = from.precedes(target) || from === target ? (from, target) : (target, from)
|
||||
guard let parent = ordered.0.parent, parent === ordered.1.parent,
|
||||
let lower = parent.children.firstIndex(where: { $0 === ordered.0 }),
|
||||
let upper = parent.children.firstIndex(where: { $0 === ordered.1 })
|
||||
else {
|
||||
return ordered.0 === ordered.1 ? [ordered.0] : [ordered.0, ordered.1]
|
||||
}
|
||||
return Array(parent.children[lower ... upper])
|
||||
}
|
||||
|
||||
private static func nodes(_ expression: String, over root: PureXML.Model.TreeNode, namespaces: [String: String]) -> [PureXML.Model.TreeNode] {
|
||||
guard let query = try? PureXML.XPath.Query(expression),
|
||||
let value = try? query.value(at: root, position: 1, size: 1, variables: [:], namespaces: namespaces) else { return [] }
|
||||
return (value.nodes ?? []).compactMap(\.treeNode)
|
||||
}
|
||||
}
|
||||
71
Tests/XPointerRangeTests.swift
Normal file
71
Tests/XPointerRangeTests.swift
Normal file
@ -0,0 +1,71 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XPointer range(), range-to(), string-range()")
|
||||
struct XPointerRangeTests {
|
||||
private func doc() throws -> PureXML.Model.Node {
|
||||
try PureXML.parse(
|
||||
"<book>"
|
||||
+ "<chapter id=\"intro\"><para>first</para><para>second</para></chapter>"
|
||||
+ "<chapter id=\"main\"><para>third</para></chapter>"
|
||||
+ "</book>",
|
||||
)
|
||||
}
|
||||
|
||||
private func ranges(_ pointer: String) throws -> [PureXML.XPointer.Range] {
|
||||
try PureXML.XPointer.evaluateRanges(pointer, over: doc())
|
||||
}
|
||||
|
||||
@Test("range() yields the covering range of each selected node")
|
||||
func test_covering() throws {
|
||||
let result = try ranges("xpointer(range(//chapter[@id='intro']))")
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.text == "firstsecond")
|
||||
}
|
||||
|
||||
@Test("range-to() spans the sibling run between two locations")
|
||||
func test_rangeToSiblings() throws {
|
||||
let result = try ranges("xpointer(//para[1]/range-to(//para[2]))")
|
||||
#expect(result.count == 1)
|
||||
// The two para siblings inside the intro chapter, in document order.
|
||||
#expect(result.first?.text == "firstsecond")
|
||||
#expect(result.first?.nodes.count == 2)
|
||||
}
|
||||
|
||||
@Test("range-to() across chapters falls back to the two boundary nodes")
|
||||
func test_rangeToBoundary() throws {
|
||||
let result = try ranges("xpointer(id('intro')/range-to(id('main')))")
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.nodes.count == 2)
|
||||
#expect(result.first?.text == "firstsecondthird")
|
||||
}
|
||||
|
||||
@Test("string-range() returns a character range per match")
|
||||
func test_stringRange() throws {
|
||||
let result = try ranges("xpointer(string-range(//para, \"ir\"))")
|
||||
// "first" and "third" both contain "ir".
|
||||
#expect(result.count == 2)
|
||||
#expect(result.allSatisfy { $0.text == "ir" })
|
||||
}
|
||||
|
||||
@Test("string-range() honors offset and length")
|
||||
func test_stringRangeOffsetLength() throws {
|
||||
// From the "ir" match in "first", start at offset 2 (the 'r') for length 1.
|
||||
let result = try ranges("xpointer(string-range(//chapter[@id='intro']/para[1], \"ir\", 2, 1))")
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.text == "r")
|
||||
}
|
||||
|
||||
@Test("An xmlns() binding applies to a following range part")
|
||||
func test_namespaceBoundRange() throws {
|
||||
let xml = try PureXML.parse("<d xmlns:x=\"urn:x\"><x:a>one</x:a><x:a>two</x:a></d>")
|
||||
let result = try PureXML.XPointer.evaluateRanges("xmlns(x=urn:x)xpointer(range(//x:a[1]))", over: xml)
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.text == "one")
|
||||
}
|
||||
|
||||
@Test("A non-range expression yields no ranges")
|
||||
func test_noRanges() throws {
|
||||
#expect(try ranges("xpointer(//para)").isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user