Add a canonical11 preset / mergeInheritedBase option. On a document subset, the omitted-ancestor xml:base chain is merged into the apex by RFC 3986 reference resolution (vs 1.0 nearest-wins), the apex's own relative xml:base is resolved against it, and xml:id is no longer inherited; xml:lang/xml:space still inherit nearest. A self-contained RFC 3986 resolver (parse, path merge, dot-segment removal) backs the merge, with tests covering the 1.1-vs-1.0 difference, dot segments, xml:id, and lang/space. Part of #105 (Tier 3)
203 lines
8.2 KiB
Swift
203 lines
8.2 KiB
Swift
extension PureXML.Canonical.Canonicalizer {
|
|
/// Canonical XML 1.1 inheritance: xml:lang and xml:space inherit nearest;
|
|
/// xml:base is the RFC 3986 resolution of the omitted-ancestor chain folded
|
|
/// with the apex's own; xml:id is not inherited.
|
|
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 base = mergedBase(above: node, apex: apex) {
|
|
result.append(PureXML.Model.Attribute("xml:base", base))
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// The nearest in-scope `xml:<local>` attribute above a node, or nil.
|
|
private static func nearestXMLAttribute(_ local: String, above node: PureXML.Model.TreeNode) -> PureXML.Model.Attribute? {
|
|
var current = node.parent
|
|
while let ancestor = current {
|
|
if let attribute = ancestor.attributes.first(where: { $0.name.prefix == "xml" && $0.name.localName == local }) {
|
|
return attribute
|
|
}
|
|
current = ancestor.parent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The merged `xml:base` for the apex: the omitted-ancestor `xml:base` values
|
|
/// (outermost first) resolved into each other, then the apex's own `xml:base`
|
|
/// resolved against them. Nil when no `xml:base` is in scope.
|
|
private static func mergedBase(above node: PureXML.Model.TreeNode, apex: PureXML.Model.Element) -> String? {
|
|
var nearestFirst: [String] = []
|
|
var current = node.parent
|
|
while let ancestor = current {
|
|
if let attribute = ancestor.attributes.first(where: { $0.name.prefix == "xml" && $0.name.localName == "base" }) {
|
|
nearestFirst.append(attribute.value)
|
|
}
|
|
current = ancestor.parent
|
|
}
|
|
var chain = Array(nearestFirst.reversed())
|
|
if let own = apex.attributes.first(where: { $0.name.prefix == "xml" && $0.name.localName == "base" }) {
|
|
chain.append(own.value)
|
|
}
|
|
guard var merged = chain.first else { return nil }
|
|
for reference in chain.dropFirst() {
|
|
merged = resolveURI(reference, against: merged)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
/// Resolves a URI `reference` against a `base` per RFC 3986 section 5
|
|
/// (strict). Used for Canonical XML 1.1 `xml:base` merging, where the
|
|
/// `xml:base` values of omitted ancestors are combined into the apex.
|
|
static func resolveURI(_ reference: String, against base: String) -> String {
|
|
let ref = URIParts(reference)
|
|
if ref.scheme != nil {
|
|
return URIParts(scheme: ref.scheme, authority: ref.authority, path: removeDotSegments(ref.path), query: ref.query, fragment: ref.fragment).recomposed
|
|
}
|
|
let baseParts = URIParts(base)
|
|
var target = URIParts(scheme: baseParts.scheme, authority: nil, path: "", query: nil, fragment: ref.fragment)
|
|
if ref.authority != nil {
|
|
target.authority = ref.authority
|
|
target.path = removeDotSegments(ref.path)
|
|
target.query = ref.query
|
|
} else {
|
|
target.authority = baseParts.authority
|
|
if ref.path.isEmpty {
|
|
target.path = baseParts.path
|
|
target.query = ref.query ?? baseParts.query
|
|
} else if ref.path.hasPrefix("/") {
|
|
target.path = removeDotSegments(ref.path)
|
|
target.query = ref.query
|
|
} else {
|
|
target.path = removeDotSegments(merge(base: baseParts, relativePath: ref.path))
|
|
target.query = ref.query
|
|
}
|
|
}
|
|
return target.recomposed
|
|
}
|
|
|
|
/// Merges a relative path onto a base per RFC 3986 section 5.3.
|
|
private static func merge(base: URIParts, relativePath: String) -> String {
|
|
if base.authority != nil, base.path.isEmpty {
|
|
return "/" + relativePath
|
|
}
|
|
if let slash = base.path.lastIndex(of: "/") {
|
|
return String(base.path[...slash]) + relativePath
|
|
}
|
|
return relativePath
|
|
}
|
|
|
|
/// Removes `.` and `..` segments per RFC 3986 section 5.2.4.
|
|
private static func removeDotSegments(_ path: String) -> String {
|
|
var input = Substring(path)
|
|
var output = ""
|
|
while !input.isEmpty {
|
|
if input.hasPrefix("../") { input = input.dropFirst(3)
|
|
continue
|
|
}
|
|
if input.hasPrefix("./") { input = input.dropFirst(2)
|
|
continue
|
|
}
|
|
if input.hasPrefix("/./") { input = "/" + input.dropFirst(3)
|
|
continue
|
|
}
|
|
if input == "/." { input = "/"
|
|
continue
|
|
}
|
|
if input.hasPrefix("/../") { input = "/" + input.dropFirst(4)
|
|
removeLastSegment(&output)
|
|
continue
|
|
}
|
|
if input == "/.." { input = "/"
|
|
removeLastSegment(&output)
|
|
continue
|
|
}
|
|
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]
|
|
input = input[next...]
|
|
}
|
|
return output
|
|
}
|
|
|
|
private static func removeLastSegment(_ output: inout String) {
|
|
if let slash = output.lastIndex(of: "/") {
|
|
output = String(output[..<slash])
|
|
} else {
|
|
output = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The five components of a URI reference (RFC 3986 section 3).
|
|
private struct URIParts {
|
|
var scheme: String?
|
|
var authority: String?
|
|
var path: String
|
|
var query: String?
|
|
var fragment: String?
|
|
|
|
init(scheme: String?, authority: String?, path: String, query: String?, fragment: String?) {
|
|
self.scheme = scheme
|
|
self.authority = authority
|
|
self.path = path
|
|
self.query = query
|
|
self.fragment = fragment
|
|
}
|
|
|
|
/// Parses a URI reference into its components.
|
|
init(_ text: String) {
|
|
var rest = Substring(text)
|
|
if let hash = rest.firstIndex(of: "#") {
|
|
fragment = String(rest[rest.index(after: hash)...])
|
|
rest = rest[..<hash]
|
|
} else {
|
|
fragment = nil
|
|
}
|
|
if let question = rest.firstIndex(of: "?") {
|
|
query = String(rest[rest.index(after: question)...])
|
|
rest = rest[..<question]
|
|
} else {
|
|
query = nil
|
|
}
|
|
let split = Self.splitScheme(rest)
|
|
scheme = split.scheme
|
|
rest = split.rest
|
|
if rest.hasPrefix("//") {
|
|
let afterSlashes = rest.dropFirst(2)
|
|
let end = afterSlashes.firstIndex(where: { $0 == "/" }) ?? afterSlashes.endIndex
|
|
authority = String(afterSlashes[..<end])
|
|
rest = afterSlashes[end...]
|
|
} else {
|
|
authority = nil
|
|
}
|
|
path = String(rest)
|
|
}
|
|
|
|
/// Splits a leading `scheme:` off a reference (scheme present only when a colon
|
|
/// precedes any path separator and the candidate is a valid scheme).
|
|
private static func splitScheme(_ text: Substring) -> (scheme: String?, rest: Substring) {
|
|
guard let colon = text.firstIndex(of: ":") else { return (nil, text) }
|
|
let candidate = text[..<colon]
|
|
let valid = candidate.allSatisfy { $0.isLetter || $0.isNumber || $0 == "+" || $0 == "-" || $0 == "." }
|
|
let startsWithLetter = candidate.first?.isLetter ?? false
|
|
let beforeSlash = text.firstIndex(of: "/").map { colon < $0 } ?? true
|
|
guard valid, startsWithLetter, beforeSlash else { return (nil, text) }
|
|
return (String(candidate), text[text.index(after: colon)...])
|
|
}
|
|
|
|
/// 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 }
|
|
result += path
|
|
if let query { result += "?" + query }
|
|
if let fragment { result += "#" + fragment }
|
|
return result
|
|
}
|
|
}
|