PureXML/Tests/XSLTTests.swift
Mihaela Mihaljevic 771fa4b457
XSLT/XPath conformance: close 68 Apache Xalan cases (#254)
* fix(xslt): keep namespace nodes on an included literal result element

An xsl:include'd stylesheet's declarations are absorbed after collectUnit
returns, and each literal result element resolves its 7.1.1 namespace nodes
by walking the weak parent chain back to its own included stylesheet element.
Those elements lived only in the write-only retainedRoots array, so an
optimized build released them before the deferred resolution ran and the
in-scope xmlns declarations vanished, dropping the namespace nodes the
included element should carry. The output was correct in debug and wrong in
release. Pin the retained roots across the resolution with
withExtendedLifetime. Imports were unaffected because they compile eagerly
while their tree is still a live local.

Catches Apache Xalan conformance case lre/lre05, surfaced by running the
apache/xalan-test corpus.

* fix(xslt): select a called template by import precedence, not document order

xsl:call-template looked up the target with the first match by position in the
flattened template list. Imports fold in before the including unit's own and
included templates, so when an import and an include both define a template
with the same name, the imported one was wrongly chosen. An included template
carries the including unit's higher import precedence and must win (XSLT 1.0
section 6). The lookup now picks the highest-precedence definition among the
same-name templates.

Closes Apache Xalan conformance cases namedtemplate17, namedtemplate18,
namedtemplate19, and namespace13 (removed from the xalan-test baseline). Full
suite 1608 tests green in debug and release; the Xalan suite passes.

* fix(xpath): string-value of a comment or PI node is its own data

The XPath string-value of a comment node is its character data and of a
processing-instruction node is its data (XPath 1.0 5.6, 5.7), but TreeNode
returned the empty string for both, so value-of select="comment()", and any
matching, sorting, or comparison over comment/PI nodes, produced nothing.

stringValue was conflated with the text-only concatenation that forms an
element's or root's string-value, where comment and PI descendants correctly
contribute nothing (5.1, 5.5). That concatenation now uses a separate
textContent, so an element's string-value still excludes comment and PI text
while a comment or PI node reports its own data.

Closes 24 Apache Xalan conformance cases across node, axes, position, select,
modes, conflictres, and variable (removed from the baseline). Full suite 1609
tests green in debug and release; the Xalan suite passes.

* fix(xslt): resolve top-level variables in any order, not document order

Top-level variables and parameters may reference one another regardless of
declaration order (XSLT 1.0 section 11.4), but they were evaluated once
top-to-bottom, so a forward reference (a global declared before the one it
uses) saw an undefined value and resolved to empty. Globals now evaluate to a
fixpoint: each pass re-evaluates the unsettled ones with the latest bindings,
so a dependency chain in any order settles in at most N passes, a pass that
changes nothing stops early, and a circular reference stops at the cap.

Extracted the evaluation into Transformer.evaluatedGlobals (XSLTGlobals.swift).
Closes Apache Xalan conformance cases variable33, variable34, variable35. Full
suite 1611 tests green in debug and release; the Xalan suite passes.

* fix(xslt): an empty-content variable with no select is the empty string

XSLT 1.0 section 11.2 makes <xsl:variable name="x"/> (empty content, no select)
equivalent to select="", so boolean() of it is false. It was always built as a
result-tree-fragment document node, which boolean() reports as true. A variable
with no select and empty content now yields the empty string; only non-empty
content remains a result tree fragment.

Closes Apache Xalan conformance cases boolean43 and boolean87. Full suite 1612
tests green in debug and release; the Xalan suite passes.

* fix(xpath): allow whitespace around the :: axis separator

XPath 1.0 allows whitespace between tokens, so `child :: sub` is the child axis
with node test `sub`. The parser only matched `::` immediately after the axis
name, so a spaced separator fell back to the child axis with the axis name read
as an element test, selecting nothing. parseAxis now skips whitespace after the
axis name and after `::`; on no match the reset to before the name is unchanged,
so a bare name step is unaffected.

Closes Apache Xalan conformance cases select16, select27, select28. Full suite
1613 tests green in debug and release; the Xalan suite passes.

* feat(xslt): document('') returns the stylesheet's own document

XSLT 1.0 section 12.1 makes an empty URI reference the document containing the
expression, so a stylesheet can read its own xsl:* nodes (embedded lookup
tables, self-inspection). The reference went straight to the document loader,
which has no entry for it, so document('') returned an empty node-set. The
transformer now keeps its parsed stylesheet tree and the document function
returns it for an empty reference, cached like any loaded document; a #fragment
still applies.

Closes Apache Xalan conformance cases select67, select68, idkey50, mdocs17,
namespace20. Full suite 1614 tests green in debug and release; Xalan passes.

* fix(xslt): implement unparsed-entity-uri from the source DTD

unparsed-entity-uri was a stub returning the empty string. It now reads the
source document type's unparsed (NDATA) entities, threaded to the function table
alongside the document loader, and returns the named entity's system URI; an
unknown name still returns empty.

Closes Apache Xalan conformance case expression02. Full suite 1615 tests green
in debug and release; the Xalan suite passes.

* fix(xpath): substring-after(s, '') returns s

Per the XPath 1.0 errata, an empty second argument matches at the start of the
string, so substring-after returns the whole string. The function bailed to the
empty string on an empty separator. substring-before(s, '') stays empty, as the
spec requires.

Closes Apache Xalan conformance case string143. Full suite 1615 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): match a template mode by expanded name, not its prefix

XSLT 1.0 section 5.7 compares a mode by expanded name, so two prefixes bound to
the same namespace name the same mode. The prefixed QName string was stored and
compared verbatim, so apply-templates mode="foo:m" missed a template with
mode="moo:m" even when foo and moo share a namespace. Mode names on xsl:template
and xsl:apply-templates now resolve to {uri}local at parse time.

Moved the parser's declaration helpers to XSLTParserDeclarations.swift to keep
the file within the length budget.

Closes Apache Xalan conformance case modes16. Full suite 1616 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): name a variable by expanded QName, not its prefix

XSLT 1.0 section 11.1 names a variable by expanded name, so two prefixes bound
to the same namespace name the same variable. The declared name and the
$prefix:local reference were compared as raw strings, so $new:x did not see a
variable declared txt:x even when new and txt share a namespace. A prefixed
declared name now resolves to {uri}local at parse time, and the XPath evaluator
falls back to the namespace-resolved name when the raw key is unbound, so a
caller-supplied raw binding still works and unprefixed names are unchanged.

Closes Apache Xalan conformance case variable55. Full suite 1617 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): a match pattern predicate sees the global variables

XSLT 1.0 section 5.2 evaluates a pattern predicate with the top-level (global)
variables in scope, but pattern matching ran with no variables bound, so a
template like match="foo[. > $screen]" never matched ($screen was undefined).
The transformer supplies the global bindings to the match cache; they are
constant per transform, so they do not enter the cache key.

Closes Apache Xalan conformance cases match14 and idkey43. Full suite 1618 tests
green in debug and release; the Xalan suite passes.

* fix(emit): make a comment ending in a hyphen or containing -- well-formed

XML forbids `--` inside a comment and a `-` immediately before `-->`, so a
comment whose text ends with `-` was emitted as <!--text---> (malformed). The
serializer now inserts a space after each offending hyphen (the XSLT 1.0 16
recovery), so `text-` becomes `text- `. Applied in both the Serializer and the
streaming Writer via a shared Escaping.comment helper.

Closes Apache Xalan conformance cases output89 and output90. Full suite 1619
tests green in debug and release; the Xalan suite passes.

* fix(xslt): preserve whitespace adjacent to a CDATA section in a template

Text and CDATA are one text node in the data model, so <out> <![CDATA[test]]>
</out> is the node " test ", whose surrounding spaces must survive whitespace
stripping (XSLT 1.0 3.4). The template body dropped each whitespace-only text
node independently, so the spaces around the CDATA vanished. Adjacent text and
CDATA children now coalesce into one run, dropped only when the whole run is
whitespace; a purely whitespace run between elements is still removed.

Closes Apache Xalan conformance case whitespace13. Full suite 1620 tests green
in debug and release; the Xalan suite passes.

* fix(xslt): xsl:processing-instruction/comment keep only text-node content

XSLT 1.0 sections 7.4 and 7.6 make it an error for xsl:comment or
xsl:processing-instruction content to create a non-text node; the recovery is
to ignore the offending node together with its content. PureXML took the
string-value of all instantiated content, so a copied element leaked its text
into the PI or comment. The content now contributes only its text and CDATA
node items.

Closes Apache Xalan conformance case copy60. Full suite 1621 tests green in
debug and release; the Xalan suite passes.

* fix(xslt): xsl:attribute keeps only text-node content (errata E27)

XSLT 1.0 errata E27 makes it an error for xsl:attribute content to create a
non-text node, recovering by ignoring the offending node together with its
content (the same rule as xsl:comment/processing-instruction). PureXML took the
string-value of all instantiated content, so a copied element leaked its text
into the attribute value. The content now contributes only its text and CDATA
node items, reusing textNodesOnly.

Closes Apache Xalan conformance cases copy56 and copy58. Full suite 1622 tests
green in debug and release; the Xalan suite passes.

* fix(xslt): xsl:copy-of carries an element's inherited namespace nodes

XSLT 1.0 11.3 copies a node together with its namespace nodes, so copying an
element must reproduce its full in-scope set, including a declaration inherited
from an ancestor. copy-of reproduced only the element's own xmlns attributes,
dropping inherited ones. The inherited declarations are now added to the copied
element and the serializer's namespace fixup drops any already in the result
scope.

Closes Apache Xalan conformance cases copy09, copy27, copy48, mdocs09. Full
suite 1623 tests green in debug and release; the Xalan suite passes.

* fix(xslt): xsl:element with an unusable name passes its content through

XSLT 1.0 7.1.2 makes a name that is not a QName an error; the recovery (the
pinned element-name-not-QName "pass-through") emits the element's content
without the wrapper. The check rejected only an empty or multi-colon name, so
an undeclared prefix (none:foo) or a name that is not an NCName (this is a
string) still produced a malformed element. The name is now also rejected when
a part is not an NCName or its prefix is unbound.

Closes Apache Xalan conformance cases namespace40, namespace42, namespace43,
namespace60, namespace73, namespace79. Full suite 1624 tests green in debug and
release; the Xalan suite passes.

* fix(xslt): xsl:copy of the root node honors use-attribute-sets

Copying the root node produces no element of its own, so the use-attribute-sets
attributes have nowhere to attach on the copy and instead join the enclosing
result element (XSLT 1.0 7.5). copyInstruction dropped them for the root case;
they are now emitted ahead of the copied content so the enclosing element picks
them up.

Closes Apache Xalan conformance cases attribset27 and attribset29. Full suite
1625 tests green in debug and release; the Xalan suite passes.

* refactor(xslt): address review (canonical name/whitespace checks, html comment safety)

Review follow-ups on the conformance series:

- xsl:element name validation now uses the canonical Parsing.XMLCharacter
  .isValidName instead of a hand-rolled grapheme check, which had narrowed valid
  XML names (it dropped a name using a non-ASCII NameChar such as U+00B7).
- The template-body whitespace check reuses Parsing.XMLCharacter.isWhitespace
  instead of a third local copy of the same predicate.
- The HTML serializer applies the section-16 comment recovery too, so a
  method="html" comment ending in a hyphen (or containing two) is well-formed;
  the C14N canonicalizer keeps comments verbatim as its contract requires.

Adds regression tests for a non-ASCII NameChar element name and for html
comment hyphen-safety. Full suite 1626 tests green in debug and release; the
Xalan suite passes.
2026-06-24 00:13:14 +02:00

298 lines
13 KiB
Swift

import Testing
@testable import PureXML
@Suite("XSLT transform")
struct XSLTTests {
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
private func transform(_ stylesheet: String, _ source: String) throws -> String {
// These cases assert result markup, not the prolog: drop the
// spec-default XML declaration the xml method now writes.
let output = try PureXML.XSLT.transform(stylesheet: stylesheet, source: source)
guard output.hasPrefix("<?xml ") else { return output }
guard let end = output.range(of: "?>") else { return output }
var body = String(output[end.upperBound...])
if body.hasPrefix("\n") { body.removeFirst() }
return body
}
@Test("call-template passes with-param values, falling back to param defaults")
func test_callTemplateParams() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out>
<xsl:call-template name="greet"><xsl:with-param name="who" select="'Ada'"/></xsl:call-template>
<xsl:call-template name="greet"/>
</out>
</xsl:template>
<xsl:template name="greet">
<xsl:param name="who" select="'world'"/>
<hi><xsl:value-of select="$who"/></hi>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<x/>") == "<out><hi>Ada</hi><hi>world</hi></out>")
}
@Test("apply-templates passes parameters to the matched template")
func test_applyTemplatesParams() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<xsl:apply-templates select="r/i"><xsl:with-param name="p" select="'X'"/></xsl:apply-templates>
</xsl:template>
<xsl:template match="i">
<xsl:param name="p" select="'-'"/>
<v><xsl:value-of select="$p"/><xsl:value-of select="."/></v>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<r><i>a</i><i>b</i></r>") == "<v>Xa</v><v>Xb</v>")
}
@Test("Modes route nodes to mode-specific templates")
func test_modes() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<toc><xsl:apply-templates select="doc/h" mode="toc"/></toc>
<body><xsl:apply-templates select="doc/h"/></body>
</xsl:template>
<xsl:template match="h" mode="toc"><li><xsl:value-of select="."/></li></xsl:template>
<xsl:template match="h"><h1><xsl:value-of select="."/></h1></xsl:template>
</xsl:stylesheet>
"""
let source = "<doc><h>A</h><h>B</h></doc>"
#expect(try transform(stylesheet, source) == "<toc><li>A</li><li>B</li></toc><body><h1>A</h1><h1>B</h1></body>")
}
@Test("A mode is matched by expanded name: prefixes sharing a namespace name the same mode")
func test_modeMatchedByExpandedName() throws {
// foo and moo are both bound to urn:m, so apply-templates mode="foo:m"
// selects the template with mode="moo:m" (XSLT 1.0 5.7, Xalan modes16).
let stylesheet = """
<xsl:stylesheet \(xsl) xmlns:foo="urn:m" xmlns:moo="urn:m" exclude-result-prefixes="foo moo">
<xsl:template match="/"><out><xsl:apply-templates select="doc/a" mode="foo:m"/></out></xsl:template>
<xsl:template match="a" mode="moo:m">matched</xsl:template>
<xsl:template match="a">unmatched</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<doc><a/></doc>") == "<out>matched</out>")
}
@Test("xsl:copy of the root with use-attribute-sets adds the attributes to the enclosing element")
func test_copyRootWithAttributeSets() throws {
// Copying the root makes no element of its own, so the attribute-set
// attributes join the enclosing <out> (XSLT 1.0 7.5, Xalan attribset29).
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:attribute-set name="s"><xsl:attribute name="a">1</xsl:attribute></xsl:attribute-set>
<xsl:template match="/"><out><xsl:copy use-attribute-sets="s"/></out></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<x/>") == "<out a=\"1\"/>")
}
@Test("xsl:element with an unusable name passes its content through")
func test_elementInvalidNamePassThrough() throws {
func out(_ name: String) throws -> String {
try transform("""
<xsl:stylesheet \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:element name="\(name)"><yyy/></xsl:element></out></xsl:template>
</xsl:stylesheet>
""", "<x/>")
}
// An undeclared prefix or non-QName name is unusable; the recovery emits
// the content without the wrapper element (XSLT 1.0 7.1.2, Xalan namespace40/43).
#expect(try out("none:foo") == "<out><yyy/></out>") // undeclared prefix
#expect(try out("this is bad") == "<out><yyy/></out>") // not an NCName
#expect(try out("good") == "<out><good><yyy/></good></out>") // a valid name still wraps
// A name using a non-ASCII NameChar (U+00B7 middle dot) is a valid XML
// name and must still wrap, not be dropped as unusable.
#expect(try out("a\u{B7}b") == "<out><a\u{B7}b><yyy/></a\u{B7}b></out>")
}
@Test("xsl:copy-of carries an element's inherited namespace nodes")
func test_copyOfInheritedNamespaces() throws {
// The copied <inner> inherits xmlns:ped from the root, with no ped-prefixed
// name of its own, so copy-of must still carry the ped namespace node
// (XSLT 1.0 11.3, Apache Xalan copy09).
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:copy-of select="root/inner"/></out></xsl:template>
</xsl:stylesheet>
"""
let source = "<root xmlns:ped=\"urn:ped\"><inner xmlns:bdd=\"urn:bdd\"/></root>"
#expect(try transform(stylesheet, source) == "<out><inner xmlns:bdd=\"urn:bdd\" xmlns:ped=\"urn:ped\"/></out>")
}
@Test("xsl:processing-instruction and xsl:comment keep only text-node content")
func test_piCommentIgnoreNonTextContent() throws {
// Content that creates a non-text node (here a copied element) is ignored
// with its content, not flattened to its string value (XSLT 1.0 7.4/7.6,
// Apache Xalan copy60), so only the leading text node survives.
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/doc"><out><xsl:processing-instruction name="pi"><xsl:copy-of select="node()"/></xsl:processing-instruction></out></xsl:template>
</xsl:stylesheet>
"""
// doc = text "foo" then an element; the element node is dropped.
#expect(try transform(stylesheet, "<doc>foo<a>bar</a></doc>") == "<out><?pi foo?></out>")
}
@Test("xsl:attribute keeps only text-node content (errata E27)")
func test_attributeIgnoresNonTextContent() throws {
// a = "T1" + <b>BBBB</b> + "T2"; the <b> element node is ignored with its
// content, so the attribute is "T1T2", not "T1BBBBT2" (Xalan copy58).
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/docs"><out><xsl:attribute name="attr1"><xsl:copy-of select="a/node()"/></xsl:attribute></out></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<docs><a>T1<b>BBBB</b>T2</a></docs>") == "<out attr1=\"T1T2\"/>")
}
@Test("Whitespace around a CDATA section in a template is preserved")
func test_literalWhitespaceAroundCData() throws {
// Text and CDATA coalesce into one node, so " <![CDATA[test]]> " is the
// non-whitespace node " test " and its spaces survive stripping, while a
// purely whitespace run between elements is still dropped (Xalan
// whitespace13).
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/"><out> <![CDATA[test]]> </out></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<x/>") == "<out> test </out>")
}
@Test("A match pattern predicate sees top-level variables")
func test_patternReferencesGlobalVariable() throws {
// foo[. > $screen] matches only foo elements over the global threshold
// (XSLT 1.0 5.2: a pattern predicate sees the global variables; Xalan
// match14). Without them the predicate fails and nothing matches.
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:variable name="screen" select="7"/>
<xsl:output omit-xml-declaration="yes"/>
<xsl:template match="/doc"><out><xsl:apply-templates/></out></xsl:template>
<xsl:template match="foo[. &gt; $screen]"><xsl:value-of select="@n"/>:over,</xsl:template>
<xsl:template match="*"><xsl:value-of select="@n"/>:under,</xsl:template>
</xsl:stylesheet>
"""
let source = "<doc><foo n=\"a\">8</foo><foo n=\"b\">5</foo><foo n=\"c\">9</foo></doc>"
#expect(try transform(stylesheet, source) == "<out>a:over,b:under,c:over,</out>")
}
@Test("The identity transform reproduces the input")
func test_identity() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="*">
<xsl:copy><xsl:copy-of select="@*"/><xsl:apply-templates/></xsl:copy>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<a id=\"1\"><b>text</b><c/></a>"
#expect(try transform(stylesheet, source) == source)
}
@Test("value-of outputs the string value of an expression")
func test_valueOf() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<result><xsl:value-of select="doc/name"/></result>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<doc><name>Ada</name></doc>") == "<result>Ada</result>")
}
@Test("apply-templates dispatches to matching templates")
func test_applyTemplates() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/"><ul><xsl:apply-templates select="list/item"/></ul></xsl:template>
<xsl:template match="item"><li><xsl:value-of select="."/></li></xsl:template>
</xsl:stylesheet>
"""
let source = "<list><item>a</item><item>b</item></list>"
#expect(try transform(stylesheet, source) == "<ul><li>a</li><li>b</li></ul>")
}
@Test("for-each iterates a node-set")
func test_forEach() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out><xsl:for-each select="r/x"><v><xsl:value-of select="@n"/></v></xsl:for-each></out>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<r><x n=\"1\"/><x n=\"2\"/></r>"
#expect(try transform(stylesheet, source) == "<out><v>1</v><v>2</v></out>")
}
@Test("if and choose select conditionally")
func test_conditionals() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<xsl:for-each select="nums/n">
<xsl:choose>
<xsl:when test=". > 0"><pos><xsl:value-of select="."/></pos></xsl:when>
<xsl:otherwise><neg/></xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<nums><n>5</n><n>-3</n></nums>"
#expect(try transform(stylesheet, source) == "<pos>5</pos><neg/>")
}
@Test("xsl:attribute and xsl:element build the result dynamically")
func test_dynamicElement() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<xsl:element name="box"><xsl:attribute name="w"><xsl:value-of select="size/@w"/></xsl:attribute></xsl:element>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<size w=\"10\"/>") == "<box w=\"10\"/>")
}
@Test("xsl:sort orders the selected nodes")
func test_sort() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out><xsl:apply-templates select="r/n"><xsl:sort select="." data-type="number"/></xsl:apply-templates></out>
</xsl:template>
<xsl:template match="n"><v><xsl:value-of select="."/></v></xsl:template>
</xsl:stylesheet>
"""
let source = "<r><n>3</n><n>1</n><n>2</n></r>"
#expect(try transform(stylesheet, source) == "<out><v>1</v><v>2</v><v>3</v></out>")
}
@Test("Attribute value templates substitute expressions")
func test_attributeValueTemplate() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/"><a href="id-{doc/@k}">link</a></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<doc k=\"7\"/>") == "<a href=\"id-7\">link</a>")
}
}