formats: Xml.encode writes a typed value as an XML document (step 3b of #78) #605

Closed
opened 2026-10-29 20:13:27 +00:00 by orion · 5 comments
Collaborator

Context

Step 3b of the epic #78 (std/xml). Steps 1 (#429, tree, reader, writer), 2 (#552, internal subset) and 3a (#570, Xml.decode) have landed on main. Writing is the other half of typed binding: request bodies of S3 (CompleteMultipartUpload, Delete) and WebDAV (#20, #67) are XML that a program builds from a type.

Goal

Xml().encode(value) writes any Encode value as an XML document with the default mapping, the inverse of Xml.decode: a record is an element, a field is a child element.

Source

  • ENCODING.md section 9 (document formats: binding for data that travels as XML, no XmlEncode), slice 8 (section 14), decisions 15 to 17 in section 16.
  • std/toml/src/encoder.trb (TomlEncoder: an Encoder that builds the tree, then the writer writes it) and Toml.encode/Toml.value are the layout model; std/xml/src/writer.trb writes the XmlNode tree.

Design record point

ENCODING.md slice 8, section 9. Touches the seed? No: the compiler does not import std/xml, and no std name is looked up by string.

Decisions (decided by the loop)

  • New public XmlEncoder with Encoder (std/xml/src/encoder.trb) builds an XmlNode.Document with one root element. Xml.encode<Value: Encode>(value): Result<String, XmlError> runs it and the existing writer (so indent applies); Xml.tree<Value: Encode>(value): Result<XmlNode, XmlError> answers the tree, like Toml.value. Xml gets no new option: naming (field names, Naming.PascalCase for S3) applies to the way out too.
  • The root element is named after the record's type name, as declared (naming is for field names only), and the root must be a record: a bare string, number, list or None is an XmlError. No XML declaration is written (the tree path writes one with XmlDeclaration).
  • A field is a child element of its name, in declaration order, and never an attribute (attributes and namespaces need the mapping DSL, step 4). A scalar is the text of its element: Bool as true/false, whole numbers and Float/Decimal as their text, String as it is (the writer escapes), Bytes as base64 (like TomlEncoder). A field that holds None is left out, which Xml.decode reads back as None or the default. A nested record is a child element holding its fields. A sequence field is one child element per item, all with the field's name (the inverse of the repeated-children rule); an empty list writes nothing.
  • A map writes one child element per entry, named by its key; a key that is no XML name is an XmlError. A record that announces no field (a wrapper) is its one value, and a variant is written in the shape ValueDecoder reads back, as TomlEncoder.variantNode does. Round trip (decode of encode) is pinned in tests for records, options, lists and nested records; a map or a variant that does not read back is documented, not forced.
  • Unwritable is an XmlError with line 0 (the message names the chain of fields, like decodeFailed): a root that is no record, a sequence directly inside a sequence, None inside a sequence, a map key that is no name, and a character XML 1.0 forbids (already refused by the writer).
  • Format<XmlError> is not in this step. The trait (STREAMS.md section 10) is encodeAll(value): Bytes and encoded(): Stage<Item, Bytes> and says they never fail; JSON can, because it degrades (NaN becomes null). Xml.encode can fail on data (a NUL character in a string, a map key), and PANICS.md forbids a panic for a failure that depends on the data. So the trait needs a decision of its own (an additive failing twin with a default, or lossy replacement in encodeAll only). That is step 3c, filed just in time after this one lands.

Acceptance criteria

  • Xml().encode(value), Xml().tree(value) and XmlEncoder exist with the documented behaviour, each failure above is the XmlError described.
  • Tests in std/xml/tests: a record of scalars; Option left out; a List<T> as repeated elements and an empty one; a nested record; Bytes; naming (PascalCase and SnakeCase); indent; escaping of <, & and " in text; each unwritable case with its message; decode<T>(encode(v)) == v for a type that has strings, numbers, Bool, Option, a list and a nested record.
  • A test encodes an S3 CompleteMultipartUpload request body (Part elements with PartNumber and ETag, Naming.PascalCase) and compares the exact text; the xmlns attribute is out of scope (step 4) and the test says so.
  • docs/standard-library/xml.md documents encode, tree and XmlEncoder and the mapping; the std/xml page summary stays within 240 characters (the docs check limit that #573 hit), lib.trb no longer says "There is no encode yet"; ENCODING.md section 14 (row 8: step 3b done, Format<XmlError> is 3c) and section 16 (new decision point 18) record the step; skills/ regenerated.
  • sh tools/gates.sh a green in forge CI; tools/check-commits.sh clean; torb format --check on the touched files.

Size

One PR, two Conventional Commits: feat(std/xml): XmlEncoder builds the document of a typed value (encoder and its unit tests) and feat(std/xml): Xml.encode and Xml.tree write a typed value (the two methods, round-trip and S3 tests, docs, ENCODING.md, skills).

Dependencies

Part of #78 (step 3b); needs #570 (landed as cd8da8b5). Followed by step 3c (Format<XmlError>), then the mapping DSL (step 4); unblocks the request side of #20 and #67.

## Context Step 3b of the epic #78 (`std/xml`). Steps 1 (#429, tree, reader, writer), 2 (#552, internal subset) and 3a (#570, `Xml.decode`) have landed on `main`. Writing is the other half of typed binding: request bodies of S3 (`CompleteMultipartUpload`, `Delete`) and WebDAV (#20, #67) are XML that a program builds from a type. ## Goal `Xml().encode(value)` writes any `Encode` value as an XML document with the default mapping, the inverse of `Xml.decode`: a record is an element, a field is a child element. ## Source - ENCODING.md section 9 (document formats: binding for data that travels as XML, no `XmlEncode`), slice 8 (section 14), decisions 15 to 17 in section 16. - `std/toml/src/encoder.trb` (`TomlEncoder`: an `Encoder` that builds the tree, then the writer writes it) and `Toml.encode`/`Toml.value` are the layout model; `std/xml/src/writer.trb` writes the `XmlNode` tree. ## Design record point ENCODING.md slice 8, section 9. Touches the seed? No: the compiler does not import `std/xml`, and no std name is looked up by string. ## Decisions (decided by the loop) - New public `XmlEncoder with Encoder` (`std/xml/src/encoder.trb`) builds an `XmlNode.Document` with one root element. `Xml.encode<Value: Encode>(value): Result<String, XmlError>` runs it and the existing writer (so `indent` applies); `Xml.tree<Value: Encode>(value): Result<XmlNode, XmlError>` answers the tree, like `Toml.value`. `Xml` gets no new option: `naming` (field names, `Naming.PascalCase` for S3) applies to the way out too. - The root element is named after the record's type name, as declared (naming is for field names only), and the root must be a record: a bare string, number, list or `None` is an `XmlError`. No XML declaration is written (the tree path writes one with `XmlDeclaration`). - A field is a child element of its name, in declaration order, and never an attribute (attributes and namespaces need the mapping DSL, step 4). A scalar is the text of its element: `Bool` as `true`/`false`, whole numbers and `Float`/`Decimal` as their text, `String` as it is (the writer escapes), `Bytes` as base64 (like `TomlEncoder`). A field that holds `None` is left out, which `Xml.decode` reads back as `None` or the default. A nested record is a child element holding its fields. A sequence field is one child element per item, all with the field's name (the inverse of the repeated-children rule); an empty list writes nothing. - A map writes one child element per entry, named by its key; a key that is no XML name is an `XmlError`. A record that announces no field (a wrapper) is its one value, and a variant is written in the shape `ValueDecoder` reads back, as `TomlEncoder.variantNode` does. Round trip (decode of encode) is pinned in tests for records, options, lists and nested records; a map or a variant that does not read back is documented, not forced. - Unwritable is an `XmlError` with line 0 (the message names the chain of fields, like `decodeFailed`): a root that is no record, a sequence directly inside a sequence, `None` inside a sequence, a map key that is no name, and a character XML 1.0 forbids (already refused by the writer). - **`Format<XmlError>` is not in this step.** The trait (STREAMS.md section 10) is `encodeAll(value): Bytes` and `encoded(): Stage<Item, Bytes>` and says they never fail; JSON can, because it degrades (NaN becomes `null`). `Xml.encode` can fail on data (a NUL character in a string, a map key), and PANICS.md forbids a panic for a failure that depends on the data. So the trait needs a decision of its own (an additive failing twin with a default, or lossy replacement in `encodeAll` only). That is step 3c, filed just in time after this one lands. ## Acceptance criteria - `Xml().encode(value)`, `Xml().tree(value)` and `XmlEncoder` exist with the documented behaviour, each failure above is the `XmlError` described. - Tests in `std/xml/tests`: a record of scalars; `Option` left out; a `List<T>` as repeated elements and an empty one; a nested record; `Bytes`; `naming` (`PascalCase` and `SnakeCase`); `indent`; escaping of `<`, `&` and `"` in text; each unwritable case with its message; `decode<T>(encode(v)) == v` for a type that has strings, numbers, `Bool`, `Option`, a list and a nested record. - A test encodes an S3 `CompleteMultipartUpload` request body (`Part` elements with `PartNumber` and `ETag`, `Naming.PascalCase`) and compares the exact text; the xmlns attribute is out of scope (step 4) and the test says so. - `docs/standard-library/xml.md` documents `encode`, `tree` and `XmlEncoder` and the mapping; the `std/xml` page summary stays within 240 characters (the `docs check` limit that #573 hit), `lib.trb` no longer says "There is no `encode` yet"; ENCODING.md section 14 (row 8: step 3b done, `Format<XmlError>` is 3c) and section 16 (new decision point 18) record the step; `skills/` regenerated. - `sh tools/gates.sh a` green in forge CI; `tools/check-commits.sh` clean; `torb format --check` on the touched files. ## Size One PR, two Conventional Commits: `feat(std/xml): XmlEncoder builds the document of a typed value` (encoder and its unit tests) and `feat(std/xml): Xml.encode and Xml.tree write a typed value` (the two methods, round-trip and S3 tests, docs, ENCODING.md, skills). ## Dependencies Part of #78 (step 3b); needs #570 (landed as `cd8da8b5`). Followed by step 3c (`Format<XmlError>`), then the mapping DSL (step 4); unblocks the request side of #20 and #67.
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261030 until 2026-10-30T05:37Z

[loop:torbscript] claim implement by session:loop-torbscript-20261030 until 2026-10-30T05:37Z
Author
Collaborator

[loop:torbscript] checkpoint: branch loop/605-xml-encode pushed (2 commits: XmlEncoder; Xml.encode/tree + tests, docs, ENCODING.md, skills). Opening PR next.

[loop:torbscript] checkpoint: branch loop/605-xml-encode pushed (2 commits: XmlEncoder; Xml.encode/tree + tests, docs, ENCODING.md, skills). Opening PR next.
Author
Collaborator

[loop:torbscript] checkpoint: PR #637 is open, Argus requested, waiting for CI.

[loop:torbscript] checkpoint: PR #637 is open, Argus requested, waiting for CI.
Author
Collaborator

[loop:torbscript] checkpoint: PR #637 open, Argus requested. CI so far green: commit messages, pdisc, tier B, bootstrap, release-sync, cross-compiles. Still pending: gates, tier A, pack the seed, archives, playground.

[loop:torbscript] checkpoint: PR #637 open, Argus requested. CI so far green: commit messages, pdisc, tier B, bootstrap, release-sync, cross-compiles. Still pending: gates, tier A, pack the seed, archives, playground.
Author
Collaborator

[loop:torbscript] release implement: PR #637 opened, head d6e06f0540; CI partly green (gates and tier A still pending). Open point for review: a Bytes field encoded through the derived Encode comes out as a list of ints, not base64 as the decisions say; base64 applies only where bytes() is called directly. Either the derive path is fixed in this PR or the deviation is recorded. The item is in review; CI and Argus decide.

[loop:torbscript] release implement: PR #637 opened, head d6e06f05406a34397d9464212c49919a9ff2cc37; CI partly green (gates and tier A still pending). Open point for review: a `Bytes` field encoded through the derived `Encode` comes out as a list of ints, not base64 as the decisions say; base64 applies only where `bytes()` is called directly. Either the derive path is fixed in this PR or the deviation is recorded. The item is in review; CI and Argus decide.
argus closed this issue 2026-10-30 01:31:47 +00:00
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#605
No description provided.