Skip to content
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ Notable changes, newest first. Versions follow [semantic
versioning](https://semver.org): from 1.0.0 the exported API is stable, and a
breaking change means 2.0 with a new module path. See *Stability* below.

## Unreleased

### Added

| Change | What it does | Commit |
|---|---|---|
| Streamed record paths with `//`, wildcards and `outermost()` | `//rec`, `doc//rec`, `/*/*/rec`, `*:rec`, `p:*` stream in both shapes; nested matches ride inside their outermost record. Small records share one tree: 900-deep `count(//rec)` 2.0 s CPU. | [`df9677e`][df9677e], [`fd31bb3`][fd31bb3], [`382983c`][382983c], [`22af5f4`][22af5f4] |
| `TransformOptions` `MaxNodes`, `MaxBytes`, `MaxNodesBuilt`, `MaxBytesBuilt` | Held and total-work bounds for result and temporary trees and strings. Zero keeps the defaults; negative removes the bound. | [`294b965`][294b965] |

### Changed

| Change | Problem → solution | Commit |
|---|---|---|
| Streamed loops were refused after ~1M records of flat state | The node and byte budgets counted every temporary ever built. A loop item that writes no output now hands back what it dropped. | [`294b965`][294b965] |
| `fn:copy-of` and `fn:snapshot` copies were not charged to the result-node budget | Unlike `xsl:copy-of`. They are charged before the copy, so a transform making over 2M such copies is now refused. | [`294b965`][294b965] |

## v2.2.0 — 2026-11-01

Memory footprint work: what a tree holds and what a transform peaks at. Output
Expand Down Expand Up @@ -1890,3 +1906,8 @@ here so every entry in this file sits under a release.
[48b1a38]: https://github.com/knroy/go-xml/commit/48b1a38
[3157566]: https://github.com/knroy/go-xml/commit/3157566
[4944511]: https://github.com/knroy/go-xml/commit/4944511
[df9677e]: https://github.com/knroy/go-xml/commit/df9677e
[fd31bb3]: https://github.com/knroy/go-xml/commit/fd31bb3
[382983c]: https://github.com/knroy/go-xml/commit/382983c
[22af5f4]: https://github.com/knroy/go-xml/commit/22af5f4
[294b965]: https://github.com/knroy/go-xml/commit/294b965
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ and maintains it as a project of his own.
| **XSD 1.1** | 99.98% instance (26,217 of 26,222); **99.97%** schema-validity (15,350 of 15,354); opt-in via `Version11` |
| **RELAX NG** | 100.00% of James Clark's spectest (965 of 965 assertions); XML and compact syntax, from Go and from `go-xml validate -rng` |
| **DTD** | content models, attribute defaults, enumerations, `ID`/`IDREF`; external subset, parameter entities across both subsets, conditional sections — via `dtd.Load` with a caller-supplied resolver, nothing fetched by default |
| **Tests** | 2,988 `func Test` declarations, clean under `-race` (a few subtests skip without the corpora below) |
| **Tests** | 2,999 `func Test` declarations, clean under `-race` (a few subtests skip without the corpora below) |
| **Production schemas** | UBL 2.1, UN/CEFACT CII, Factur-X/ZUGFeRD, Peppol BIS 3.0 — 88 schemas load, instances validate clean |
| **API** | 1.2; the exported surface is stable and additive over 1.1, and a breaking change means 2.0 with a new module path |

Expand Down Expand Up @@ -180,7 +180,7 @@ true here:

**Streamed execution is implemented only for two narrow shapes** (an
`xsl:source-document streamable="yes"` whose body is one `xsl:for-each` or
`xsl:iterate` over a child path, or one `xsl:value-of`/`xsl:copy-of`/
`xsl:iterate` over a record path such as `//rec` or `doc/rec`, or one `xsl:value-of`/`xsl:copy-of`/
`xsl:sequence` over one record path such as `sum(doc/rec/@v)`, read one
record at a time; see
[known gaps](docs/known-gaps.md)). Otherwise the streaming cases are in
Expand Down Expand Up @@ -1393,7 +1393,7 @@ back, is in [docs/testing.md](docs/testing.md).

| method | what it catches | what it misses |
|---|---|---|
| **Unit tests** (2,988 `func Test` declarations) | places where a plausible implementation is quietly wrong | anything nobody thought to write a test for |
| **Unit tests** (2,999 `func Test` declarations) | places where a plausible implementation is quietly wrong | anything nobody thought to write a test for |
| **Spec inventories** | features absent entirely | features present but behaving wrongly |
| **Saxon differential** | subtle behavioural divergence on real stylesheets | constructs the corpora do not use |
| **W3C QT3 suite** | systematic conformance across 22,054 XPath and 30,517 XQuery cases | XSLT (it is an XPath suite) |
Expand Down
2 changes: 1 addition & 1 deletion docs/conformance-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ W3C disagreements: 0 + 0 + 0 + 1 + 8 + 23 + 30 + 31 + 0 = 93. Measured 2026-09-1
<!-- BEGIN GENERATED UNIT TEST COUNT -->
<!-- Generated from tests/conformance/results.json and the source tree by
tests/conformance-docs.go. Do not edit; see docs/stats.md. -->
The unit-test suite is 2,988 tests.
The unit-test suite is 2,999 tests.
<!-- END GENERATED UNIT TEST COUNT -->

The last two rows are not W3C suites but real-world corpora — DocBook xslTNG's
Expand Down
26 changes: 21 additions & 5 deletions docs/known-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,18 @@ Measured on branches in 2026-11 and not adopted, each with the reason:

Since v2.2.0 an `xsl:source-document streamable="yes"` whose body is one
`xsl:for-each` or `xsl:iterate` (optionally inside constant literal result
elements) over a child path of plain names, with non-positional predicates on
the last step only, and a body the gate recognises with a known and verified
elements) over a record path, with non-positional predicates on the last step
only, and a body the gate recognises with a known and verified
§19.8 verdict, runs record by record
through `xdm.ParseBursts`: a 1 GB file totalled by `xsl:iterate` peaks at
27 MB RSS against 3.7 GB, at about 2× the CPU. Everything else builds the tree,
as before.
as before. A record path is child or descendant steps whose tests are names or
wildcards (`doc/rec`, `//rec`, `doc//rec`, `/*/*/rec`, `*:rec`, `p:*`, names
resolved through `xpath-default-namespace`), or `outermost()`/`innermost()` of
one (since v2.3.0). A record is cut at its outermost match, so a match nested
in it is delivered inside its tree, in document order. Small records share one
tree, so ancestors are copied once per element opened: `count(//rec)` under a
900-deep spine takes 2.0 s of CPU against the tree's 1.6 s.

The second shape is a body that is one `xsl:value-of`, `xsl:copy-of` or
`xsl:sequence` (again optionally inside constant literal result elements) whose
Expand All @@ -201,15 +207,25 @@ still growing with the input. A select that would keep the records' nodes or
copies (`copy-of(doc/rec)`, filters returning nodes, `head`/`tail` passed on)
is refused and runs on the tree, because a kept record costs more there than in
a whole tree (945 B against 208 B). Also refused: `distinct-values`, `reverse`,
`sort`, `last()`, union/intersect/except, `let`, user functions, wildcard steps.
`sort`, `last()`, union/intersect/except, `let`, user functions, and paths to
non-elements (`//text()`, `//@x`).

In the W3C XSLT 3.0 run 364 cases take the burst path (9 before the second
shape). Of the 786 single-instruction bodies over one record path with a known
and verified verdict, 294 are admitted (261 bounded reducers, 33 pipelined).
Also:

- **The result is still built in memory**, so a large filtered copy grows with
its output and stops at the result-node cap.
its output and stops at the result-node cap. Temporary work per record is not
charged cumulatively since v2.3.0: a loop item that writes nothing to the
output hands back up to 4,096 nodes and 64 KiB it dropped
(`TransformOptions.MaxNodesBuilt`/`MaxBytesBuilt` lift that cap). An item that
writes output keeps all it built charged, so a stream that both writes and
builds temporaries per record reaches the cap sooner than its output alone.
- **Growing state is bounded only per value or per expression.** A carried
integer multiplied each record (`MaxDigits`), a sequence appended to or
passed through `distinct-values` each record (`MaxItems`) run quadratic time
and are stopped by the context deadline, before and after v2.3.0.
- **Error order can differ from tree mode, for the loop shape.** (The single-instruction
shape re-runs on the tree after any error, so its errors are the tree's; the
cost is a second read of the document, and the 1 GiB string budget charges the
Expand Down
4 changes: 4 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,10 @@ res, err := sty.Transform(ctx, doc.Root, xslt.TransformOptions{
| `Collections` | `xpath.CollectionResolver` | disabled | Resolves `fn:collection`. **Nil disables it**, and setting `Documents` does not set this — the two are separate switches on purpose. |
| `MaxDepth` | `int` | `DefaultMaxDepth` = 1000 | Template recursion limit, and the bound on `fn:transform` nesting. Catches a stylesheet with no base case. |
| `MaxItems` | `int` | `xpath.MaxItems` = 5,000,000 | Items one transformation may materialise, `XPDY0130` past it. Negative removes the bound; nested `fn:transform` and `xsl:evaluate` keep the caller's. |
| `MaxNodes` | `int` | `xpath.MaxNodes` = 2,000,000 | Result- and temporary-tree nodes the transform may hold, `XPDY0130` past it. Negative removes the bound. In a streamed `xsl:for-each`/`xsl:iterate` an item that writes no output hands back what it dropped, so only what it carries on counts; elsewhere every node built counts. |
| `MaxBytes` | `int64` | `xpath.MaxBytes` = 1 GiB | String content held, on `MaxNodes`' terms. |
| `MaxNodesBuilt` | `int64` | 0: a streamed item hands back at most 4,096 nodes | Every node built, refunds notwithstanding. Positive bounds the total and lifts the per-item cap; negative removes both, so only what is held is bounded. |
| `MaxBytesBuilt` | `int64` | 0: at most 65,536 bytes per item | `MaxNodesBuilt` for string content. |
| `DisableAssertions` | `bool` | `false` — assertions enabled | Turns off `xsl:assert` checking for the whole transformation. XSLT 3.0 §22.2: "By default, assertions are enabled." |
| `InitialMode` | `string` | default mode | Mode for the initial `apply-templates`. |
| `InitialTemplate` | `string` | match the root | Invokes a named template instead, which is how a stylesheet of only named templates is entered. |
Expand Down
Loading
Loading