* fix(xslt): resolve string-form document() against the stylesheet element's base URI
A string-form document() reference resolves against the base URI of the
stylesheet element holding it (XSLT 1.0 12.1). For a declaration in an
included or imported file that is the included file's URI, not the top
stylesheet's, so document('data.xml') (or document('')) in an included file
must resolve against the included file.
The parser already tracked each top-level declaration's source-file base but
discarded it after collection. That base is now carried onto the compiled
templates and global variables, into the runtime context, and through to the
function table, so the document() string form resolves against it. Templates
and globals both carry it, so the rule holds wherever the call appears.
Closes Apache Xalan conformance cases mdocs12 and mdocs13.
* fix(xslt): carry base URI into for-each and sort contexts; keep document('') self-referential
Follow-up to the string-form document() base URI work. Three coverage gaps:
- xsl:for-each and xsl:sort evaluate their body and key expressions in a
context derived from, not equal to, the enclosing template's, so a
document() call there resolved against the empty base when the template came
from an included file. Both now inherit the enclosing context's base URI.
- A non-empty base URI made document('') in the top stylesheet resolve to that
base and route through the loader, rather than returning the stylesheet's own
parsed document as before. The parsed stylesheet is now registered under its
base URI, so document('') reaches it without the loader; document('') in an
included file (a different base) still loads that file.
394 lines
21 KiB
Swift
394 lines
21 KiB
Swift
private typealias DecimalFormat = PureXML.XSLT.DecimalFormat
|
|
|
|
/// The digit-place counts, grouping, affixes, and multiplier derived from a
|
|
/// format-number subpattern. File scope and private.
|
|
private struct NumberLayout {
|
|
var prefix = ""
|
|
var suffix = ""
|
|
var minInteger = 0
|
|
var minFraction = 0
|
|
var maxFraction = 0
|
|
var groupingSize = 0
|
|
var multiplier = 1.0
|
|
}
|
|
|
|
extension PureXML.XSLT {
|
|
/// The XSLT `format-number` function. Supports `0`/`#` digit places, the
|
|
/// decimal point, grouping, and a percent suffix, honoring the symbols of the
|
|
/// chosen `xsl:decimal-format`. Pure Swift, no Foundation.
|
|
enum FormatNumber {
|
|
static func format(_ value: Double, _ picture: String, _ symbols: DecimalFormat = DecimalFormat()) -> String {
|
|
guard !value.isNaN else { return symbols.notANumber }
|
|
guard !picture.isEmpty else { return PureXML.XPath.Value.format(value) }
|
|
let subpatterns = picture.split(separator: symbols.patternSeparator, maxSplits: 1, omittingEmptySubsequences: false)
|
|
let positive = parse(String(subpatterns[0]), symbols)
|
|
// The negative subpattern contributes only its affixes; digit
|
|
// places, grouping, and multiplier come from the positive one.
|
|
// Without one the default negative prefix is the minus sign.
|
|
var layout = positive
|
|
if value < 0 {
|
|
let negative = subpatterns.count > 1 ? parse(String(subpatterns[1]), symbols) : nil
|
|
if let negative, !(negative.prefix.isEmpty && negative.suffix.isEmpty) {
|
|
layout.prefix = negative.prefix
|
|
layout.suffix = negative.suffix
|
|
} else {
|
|
// No negative subpattern, or one with no distinguishing
|
|
// affixes: the default negative prefix is the minus sign.
|
|
layout.prefix = String(symbols.minusSign) + layout.prefix
|
|
}
|
|
}
|
|
if value.isInfinite { return layout.prefix + symbols.infinity + layout.suffix }
|
|
let magnitude = Swift.abs(value) * positive.multiplier
|
|
return layout.prefix + render(magnitude, layout, symbols) + layout.suffix
|
|
}
|
|
|
|
/// Splits one subpattern into prefix, number part, and suffix, reading
|
|
/// the digit places, the grouping size (digits after the last grouping
|
|
/// separator), and the percent/per-mille multiplier from it.
|
|
private static func parse(_ subpattern: String, _ symbols: DecimalFormat) -> NumberLayout {
|
|
var layout = NumberLayout()
|
|
// When the decimal and grouping separators collide, every
|
|
// occurrence reads as grouping (the Xalan resolution).
|
|
let decimal: Character? = symbols.decimalSeparator == symbols.groupingSeparator ? nil : symbols.decimalSeparator
|
|
let numberCharacters = Set([symbols.zeroDigit, symbols.digit, symbols.groupingSeparator, symbols.decimalSeparator])
|
|
var phase = 0 // 0 prefix, 1 number, 2 suffix
|
|
var inFraction = false
|
|
var sinceGrouping: Int?
|
|
for character in subpattern {
|
|
if character == symbols.percent || character == symbols.perMille {
|
|
layout.multiplier = character == symbols.percent ? 100 : 1000
|
|
if phase == 1 { phase = 2 }
|
|
appendAffix(character, phase, &layout)
|
|
continue
|
|
}
|
|
if numberCharacters.contains(character), phase < 2 {
|
|
phase = 1
|
|
if character == decimal {
|
|
inFraction = true
|
|
} else {
|
|
countPlace(character, inFraction, symbols, &layout, &sinceGrouping)
|
|
}
|
|
continue
|
|
}
|
|
if phase == 1 { phase = 2 }
|
|
appendAffix(character, phase, &layout)
|
|
}
|
|
layout.groupingSize = sinceGrouping ?? 0
|
|
return layout
|
|
}
|
|
|
|
/// Tallies one digit-area character into the layout's place counts and
|
|
/// the running last-grouping-interval width.
|
|
private static func countPlace(
|
|
_ character: Character,
|
|
_ inFraction: Bool,
|
|
_ symbols: DecimalFormat,
|
|
_ layout: inout NumberLayout,
|
|
_ sinceGrouping: inout Int?,
|
|
) {
|
|
if character == symbols.groupingSeparator {
|
|
if !inFraction { sinceGrouping = 0 }
|
|
} else if inFraction {
|
|
layout.maxFraction += 1
|
|
if character == symbols.zeroDigit { layout.minFraction += 1 }
|
|
} else {
|
|
if character == symbols.zeroDigit { layout.minInteger += 1 }
|
|
sinceGrouping = sinceGrouping.map { $0 + 1 }
|
|
}
|
|
}
|
|
|
|
private static func appendAffix(_ character: Character, _ phase: Int, _ layout: inout NumberLayout) {
|
|
// The generic currency sign renders as this processor's (en-US)
|
|
// currency symbol, the JDK affix substitution.
|
|
let resolved: Character = character == "\u{A4}" ? "$" : character
|
|
if phase == 0 {
|
|
layout.prefix.append(resolved)
|
|
} else {
|
|
layout.suffix.append(resolved)
|
|
}
|
|
}
|
|
|
|
private static func render(_ value: Double, _ layout: NumberLayout, _ symbols: DecimalFormat) -> String {
|
|
// One scaled rounding, then pure integer digit extraction: repeated
|
|
// float multiplication would re-introduce representation error
|
|
// (0.4812 must render 4812, not 481199...). The scaled product must
|
|
// stay under 2^53 for exact integer extraction, so the picture's
|
|
// fraction places are clamped to 15 and padded back to the
|
|
// requested minimum.
|
|
let places = Swift.min(layout.maxFraction, 15)
|
|
var scale = 1.0
|
|
for _ in 0 ..< places {
|
|
scale *= 10
|
|
}
|
|
let (integerDigits, allFractionDigits) = digits(value, scale: scale, places: places)
|
|
var paddedInteger = integerDigits
|
|
while paddedInteger.count < Swift.max(1, layout.minInteger) {
|
|
paddedInteger = "0" + paddedInteger
|
|
}
|
|
var integerText = digitsToSymbols(paddedInteger, symbols)
|
|
if layout.groupingSize > 0 { integerText = group(integerText, symbols.groupingSeparator, layout.groupingSize) }
|
|
|
|
var fractionDigits = allFractionDigits
|
|
while fractionDigits.count > layout.minFraction, fractionDigits.hasSuffix("0") {
|
|
fractionDigits.removeLast()
|
|
}
|
|
while fractionDigits.count < layout.minFraction {
|
|
fractionDigits += "0"
|
|
}
|
|
let fractionText = digitsToSymbols(fractionDigits, symbols)
|
|
return fractionText.isEmpty ? integerText : integerText + String(symbols.decimalSeparator) + fractionText
|
|
}
|
|
|
|
/// The integer and fraction digit strings of `value` carried to
|
|
/// `places` fraction digits, via a single scaled rounding when the
|
|
/// magnitude fits an Int exactly. Beyond 2^53 a Double holds no
|
|
/// fraction anyway, so the integer digits come from the exponent
|
|
/// expander and the fraction is zeros: no Double-to-Int conversion
|
|
/// here may trap, whatever the value or the picture.
|
|
private static func digits(_ value: Double, scale: Double, places: Int) -> (String, String) {
|
|
let scaled = (value * scale).rounded()
|
|
// Int64 explicitly: on wasm32 Int is 32 bits and the conversion
|
|
// would trap at 2^31 (found by the WASI test run, #144).
|
|
guard scaled.isFinite, scaled < 9_007_199_254_740_992, scale < 9_007_199_254_740_992 else {
|
|
let integer = PureXML.XPath.Value.format(value.rounded(.towardZero))
|
|
return (integer, String(repeating: "0", count: places))
|
|
}
|
|
let total = Int64(scaled)
|
|
let divisor = Int64(scale)
|
|
var fraction = String(total % divisor)
|
|
while fraction.count < places {
|
|
fraction = "0" + fraction
|
|
}
|
|
return (String(total / divisor), places > 0 ? fraction : "")
|
|
}
|
|
|
|
/// Maps ASCII digits to the format's digit set, offset from its zero-digit.
|
|
private static func digitsToSymbols(_ digits: String, _ symbols: DecimalFormat) -> String {
|
|
guard symbols.zeroDigit != "0", let zero = symbols.zeroDigit.unicodeScalars.first else { return digits }
|
|
return String(digits.map { character in
|
|
guard let value = character.wholeNumberValue, let scalar = Unicode.Scalar(zero.value + UInt32(value)) else { return character }
|
|
return Character(scalar)
|
|
})
|
|
}
|
|
|
|
private static func group(_ digits: String, _ separator: Character, _ size: Int) -> String {
|
|
var result = ""
|
|
for (offset, character) in digits.reversed().enumerated() {
|
|
if offset > 0, offset.isMultiple(of: size) { result.append(separator) }
|
|
result.append(character)
|
|
}
|
|
return String(result.reversed())
|
|
}
|
|
}
|
|
|
|
/// The `xsl:key` index: each key name maps a key value to the nodes indexed
|
|
/// under it.
|
|
typealias KeyIndex = [String: [String: [PureXML.Model.TreeNode]]]
|
|
|
|
/// The XSLT extensions to the XPath function library: `current`, `key`,
|
|
/// `format-number`, and `document`, plus the `xsl:key` index they read.
|
|
enum Library {
|
|
/// The XSLT functions for an evaluation whose current node is `current`,
|
|
/// reading the prebuilt `keys` index and the injected `document` loader.
|
|
static func table(
|
|
current: PureXML.XPath.Node,
|
|
keys: @escaping (PureXML.Model.TreeNode) -> KeyIndex,
|
|
loader: @escaping (String) -> String?,
|
|
decimalFormats: [String: PureXML.XSLT.DecimalFormat] = [:],
|
|
documents: PureXML.XSLT.DocumentCache,
|
|
selfDocument: PureXML.Model.Node? = nil,
|
|
unparsedEntities: [String: String] = [:],
|
|
baseURI stylesheetBase: String = "",
|
|
) -> PureXML.XPath.FunctionTable {
|
|
PureXML.XPath.FunctionTable()
|
|
.adding("current") { _, _ in .nodeSet([current]) }
|
|
.adding("format-number") { arguments, context in
|
|
// The decimal-format name is matched by expanded QName.
|
|
let name = expandedName(arguments.count > 2 ? arguments[2].string : "", context.namespaces)
|
|
return .string(FormatNumber.format(
|
|
arguments.first?.number ?? .nan,
|
|
arguments.count > 1 ? arguments[1].string : "",
|
|
decimalFormats[name] ?? PureXML.XSLT.DecimalFormat(),
|
|
))
|
|
}
|
|
.adding("key") { arguments, context in
|
|
keyLookup(arguments, context, keys)
|
|
}
|
|
.adding("document") { arguments, _ in
|
|
// XSLT 1.0 12.1: a string or node-set of URI references, each
|
|
// optionally with a `#fragment`. A relative reference resolves
|
|
// against the base URI of the second argument's first node;
|
|
// without a second argument, against the reference node's own
|
|
// base (node-set) or the stylesheet (string).
|
|
let secondBase: String? = arguments.count > 1
|
|
? (arguments[1].nodes?.min(by: PureXML.XPath.Node.precedes)).map { baseURI(of: $0, documents) }
|
|
: nil
|
|
func load(_ reference: String, _ base: String) -> [PureXML.XPath.Node] {
|
|
documentReference(resolveDocumentReference(reference, against: base), loader, documents, selfDocument)
|
|
}
|
|
if let nodes = arguments.first?.nodes {
|
|
return .nodeSet(nodes.flatMap { load($0.stringValue, secondBase ?? baseURI(of: $0, documents)) })
|
|
}
|
|
// A string-form reference resolves against the base URI of the
|
|
// stylesheet element holding the expression (XSLT 1.0 12.1).
|
|
return .nodeSet(load(arguments.first?.string ?? "", secondBase ?? stylesheetBase))
|
|
}
|
|
.adding("id") { arguments, context in
|
|
idLookup(arguments, context, documents)
|
|
}
|
|
.adding("generate-id") { arguments, context in
|
|
// No argument uses the context node; an explicit empty node-set
|
|
// is the empty string, per the XSLT definition. Attribute and
|
|
// namespace nodes get IDs through their identity hash (owner
|
|
// plus name), distinct from every tree node's.
|
|
let node: PureXML.XPath.Node? = arguments.isEmpty ? context.node : arguments.first?.nodes?.first
|
|
guard let node else { return .string("") }
|
|
return .string("N\(UInt(bitPattern: node.hashValue))")
|
|
}
|
|
.adding("system-property") { arguments, _ in systemProperty(arguments.first?.string ?? "") }
|
|
.adding("element-available") { arguments, _ in .boolean(instructionNames.contains(localPart(arguments.first?.string ?? ""))) }
|
|
.adding("function-available") { arguments, _ in .boolean(functionNames.contains(localPart(arguments.first?.string ?? ""))) }
|
|
.adding("unparsed-entity-uri") { arguments, _ in
|
|
.string(unparsedEntities[arguments.first?.string ?? ""] ?? "")
|
|
}
|
|
}
|
|
|
|
/// Loads one `document()` reference: the whole document, or, when the
|
|
/// reference has a `#fragment`, the nodes its XPointer selects.
|
|
private static func documentReference(
|
|
_ reference: String,
|
|
_ loader: (String) -> String?,
|
|
_ cache: PureXML.XSLT.DocumentCache,
|
|
_ selfDocument: PureXML.Model.Node?,
|
|
) -> [PureXML.XPath.Node] {
|
|
let path: String
|
|
let fragment: String?
|
|
if let hash = reference.firstIndex(of: "#") {
|
|
path = String(reference[..<hash])
|
|
fragment = String(reference[reference.index(after: hash)...])
|
|
} else {
|
|
path = reference
|
|
fragment = nil
|
|
}
|
|
if cache.trees[path] == nil {
|
|
// An empty URI reference is the stylesheet's own document
|
|
// (XSLT 1.0 12.1: `document('')`); any other path loads.
|
|
if path.isEmpty {
|
|
guard let selfDocument else { return [] }
|
|
cache.sources[path] = selfDocument
|
|
cache.trees[path] = PureXML.Model.TreeNode(selfDocument)
|
|
} else {
|
|
guard let text = loader(path), let parsed = try? PureXML.parse(text, limits: .init(allowDoctype: true)) else { return [] }
|
|
cache.sources[path] = parsed
|
|
cache.trees[path] = PureXML.Model.TreeNode(parsed)
|
|
}
|
|
// The loaded document's base URI is the (already base-resolved)
|
|
// reference it came from, so a relative reference taken from one
|
|
// of its nodes resolves against it (XSLT 1.0 12.1).
|
|
if let tree = cache.trees[path] { recordBaseURI(tree, path, cache) }
|
|
}
|
|
guard let tree = cache.trees[path], let parsed = cache.sources[path] else { return [] }
|
|
guard let fragment, !fragment.isEmpty else { return [.tree(tree)] }
|
|
let selections = (try? PureXML.XPointer.evaluate(fragment, over: parsed)) ?? []
|
|
return selections.compactMap { selection in
|
|
guard case let .node(node) = selection else { return nil }
|
|
// A fragment selection is a detached subtree, so its own root,
|
|
// not the cached whole-document tree, must carry the base URI for
|
|
// a later relative reference taken from one of its nodes.
|
|
let subtree = PureXML.Model.TreeNode(node)
|
|
recordBaseURI(subtree, path, cache)
|
|
return .tree(subtree)
|
|
}
|
|
}
|
|
|
|
/// Records `path` as the base URI of the document root containing `node`,
|
|
/// so a relative `document()` reference later taken from `node` resolves
|
|
/// against it (XSLT 1.0 12.1).
|
|
private static func recordBaseURI(_ node: PureXML.Model.TreeNode, _ path: String, _ cache: PureXML.XSLT.DocumentCache) {
|
|
var root = node
|
|
while let parent = root.parent {
|
|
root = parent
|
|
}
|
|
cache.baseURIs[ObjectIdentifier(root)] = path
|
|
}
|
|
|
|
private static func localPart(_ name: String) -> String {
|
|
name.split(separator: ":").last.map(String.init) ?? name
|
|
}
|
|
|
|
/// The XSLT system properties: the version is the number 1.0; the vendor
|
|
/// strings identify this processor. Any other property is the empty string.
|
|
private static func systemProperty(_ name: String) -> PureXML.XPath.Value {
|
|
switch localPart(name) {
|
|
case "version": .number(1.0)
|
|
case "vendor": .string("PureXML")
|
|
case "vendor-url": .string("https://github.com/mihaelamj/PureXML")
|
|
default: .string("")
|
|
}
|
|
}
|
|
|
|
/// The XSLT instruction elements this processor implements, for
|
|
/// `element-available`.
|
|
static let instructionNames: Set<String> = [
|
|
"value-of", "apply-templates", "apply-imports", "call-template", "for-each", "if", "choose",
|
|
"when", "otherwise", "element", "attribute", "copy", "copy-of", "text", "number", "comment",
|
|
"processing-instruction", "variable", "param", "with-param", "sort", "message", "fallback",
|
|
]
|
|
|
|
/// The XPath and XSLT functions this processor implements, for
|
|
/// `function-available`.
|
|
static let functionNames: Set<String> = [
|
|
"last", "position", "count", "id", "local-name", "namespace-uri", "name", "string", "concat",
|
|
"starts-with", "contains", "substring-before", "substring-after", "substring", "string-length",
|
|
"normalize-space", "translate", "boolean", "not", "true", "false", "lang", "number", "sum",
|
|
"floor", "ceiling", "round", "current", "document", "key", "format-number", "generate-id",
|
|
"system-property", "element-available", "function-available", "unparsed-entity-uri",
|
|
]
|
|
|
|
/// Builds the `xsl:key` index: each declared key maps every node matching
|
|
/// its pattern to its `use` value.
|
|
static func buildKeyIndex(stylesheet: Stylesheet, root: PureXML.Model.TreeNode) -> KeyIndex {
|
|
var index: KeyIndex = [:]
|
|
for key in stylesheet.keys {
|
|
let path = key.match.hasPrefix("/") ? key.match : "//" + key.match
|
|
guard let matchQuery = try? PureXML.XPath.Query(path),
|
|
let useQuery = try? PureXML.XPath.Query(key.use) else { continue }
|
|
for node in matchQuery.nodes(over: root) {
|
|
// A node-set use expression indexes the node under every
|
|
// member's string value.
|
|
let used = try? useQuery.value(at: node)
|
|
let values: [String] = if let nodes = used?.nodes {
|
|
nodes.map(\.stringValue)
|
|
} else {
|
|
[used?.string ?? ""]
|
|
}
|
|
for value in values {
|
|
index[key.name, default: [:]][value, default: []].append(node)
|
|
}
|
|
}
|
|
}
|
|
return index
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PureXML.Emitting.Options {
|
|
/// These options with an `xsl:output`'s settings layered over them. Only an
|
|
/// explicit setting overrides; unspecified `xsl:output` attributes leave the
|
|
/// caller's corresponding option untouched.
|
|
func applying(_ output: PureXML.XSLT.Output) -> Self {
|
|
var copy = self
|
|
if let indent = output.indent {
|
|
copy.prettyPrint = indent
|
|
// Xalan-style indentation: children on their own lines with no
|
|
// leading spaces (indent-amount 0), the conformance golds' form.
|
|
copy.indent = ""
|
|
}
|
|
if let omit = output.omitXMLDeclaration { copy.includeXMLDeclaration = !omit }
|
|
if let encoding = output.encoding { copy.encodingName = encoding }
|
|
if let version = output.version { copy.xmlVersion = version }
|
|
if let standalone = output.standalone { copy.standalone = standalone }
|
|
return copy
|
|
}
|
|
}
|