PureYAML/docs/DESIGN.md
Mihaela M 44abc12e80 docs: record rules conformance, import contract, and round-trip law
Add docs/rules-conformance.md documenting how the repo conforms to the
vendored canon, the documentation.md vs dependency-free conflict and its
scoped resolution, the domain-mismatched rules that do not apply, and the
conscious deviations. Add docs/package-import-contract-status.md. State
the round-trip law (Lambda1/Lambda2) in DESIGN.md.
2026-06-26 06:14:24 +02:00

259 lines
12 KiB
Markdown

# PureYAML Design
| Field | Value |
|---|---|
| Status | draft |
| Created | 2026-06-05 |
| Last revised | 2026-06-05 |
## Purpose
PureYAML exists to provide a dependency-free YAML implementation in Swift that
can build on macOS, Linux, and WebAssembly/WASI. The first user is
OpenAPIDoctor, which needs YAML parsing and mutation without a C-backed parser
blocking browser deployment.
## Goals
- Parse a useful YAML subset with no external dependencies.
- Preserve mapping order.
- Emit YAML from the same model.
- Validate YAML structures with path-aware issues.
- Build the library target for `wasm32-unknown-wasip1`.
- Grow toward Yams-compatible behavior through tests, not source copying.
## Non-Goals
- No C source in the public package.
- No external SwiftPM dependency.
- No JavaScript parser or build-time generator.
- No arbitrary `Any` load or dump API that collapses YAML mappings into
dictionary-shaped values before validation.
- No claim of complete YAML 1.2 coverage until the test corpus proves it.
## Current Architecture
```text
PureYAML
├── Model
│ ├── Value
│ ├── Mapping
│ └── Pair
├── Parsing
│ ├── Parser
│ ├── Reader
│ ├── Scanner
│ ├── Token
│ ├── TokenCursor
│ ├── TokenEventParser
│ ├── EventComposer
│ ├── Event
│ ├── Mark
│ └── ParseError
├── Emitting
│ ├── Dumper
│ └── Options
├── Decoding
│ ├── Decoder
│ └── Error
├── Encoding
│ ├── Encoder
│ └── Error
├── Stream
│ ├── Document
│ ├── Issue
│ └── Result
├── Tagged
│ ├── Node
│ ├── Document
│ ├── Tag
│ ├── Validator
│ └── Rule
└── Validation
├── Validator
├── Rule
├── Issue
└── Path
```
`PureYAML.parse(_:)`, `PureYAML.parseStream(_:)`, and `PureYAML.dump(_:)` are
convenience entry points. Parsing runs through the scanner, token-event parser,
and event composer before returning `PureYAML.Model.Value` for one document or
`PureYAML.Stream.Document` values for a stream. The implementation lives in
namespaced parser, dumper, scalar typed coder, stream, tagged-node, and
validator types.
## First Milestone
The initial parser supports:
- block mappings
- block sequences
- ordered mappings
- strings
- ints
- doubles
- bools
- nulls
- quoted strings
- comments outside quoted strings
- flow sequences and mappings
- literal and folded block scalars
- anchors and aliases
- merge-key expansion for mappings and sequences of mappings
- complex mapping keys as sequence or mapping values
- YAML directives, document markers, and multi-document streams
- explicit built-in scalar tags for strings, integers, floats, booleans, and
nulls
- tag-preserving parse APIs for scalar and collection tags
The parser layer also has an internal event contract. `Parsing.Event` can
represent stream, document, scalar, sequence, mapping, and alias events, with
marks plus scalar and collection styles. Anchors and tags are carried as event
metadata so the later scanner/composer work can add YAML feature coverage
without changing the event shape.
The parser layer now also has an internal reader/scanner/event contract.
`Parsing.Reader` advances through Swift `String` input while tracking UTF-8 byte
indexes, lines, and columns. `Parsing.Scanner` emits lexical `Parsing.Token`
values for comments, indentation, block entries, mapping indicators, flow
delimiters, quoted scalar starts, block scalar headers, anchors, aliases, tags,
YAML directives, document markers, and source mark ranges. `Parsing.Scanner`
also expands tag handles from `%TAG` directives before emitting tag tokens.
`Parsing.TokenEventParser` consumes that token stream and emits `Parsing.Event`
values for block collections, flow collections, aliases, anchors, tags, and
scalars. `Parsing.EventComposer` consumes those events into
`PureYAML.Model.Value`, preserving ordered mapping pairs, block scalar text,
alias-resolved values, and explicit built-in scalar tags where the current model
can represent them.
`PureYAML.parse(_:)` remains a single-document API and rejects a second document
with `unsupportedMultiDocumentStream`. `PureYAML.parseStream(_:)` parses all
documents in the stream, including empty explicit documents as `null`, and
returns `PureYAML.Stream.Document` values with stable zero-based indexes.
`PureYAML.dump(_:)` accepts those stream documents and emits explicit `---`
document starts in the caller-provided array order. Each document body is
rendered through the same dumper used for single-document values, so block,
flow, literal scalar, and complex-key output policies stay centralized.
`PureYAML.parseTagged(_:)` and `PureYAML.parseTaggedStream(_:)` reuse the same
scanner and event parser but compose `PureYAML.Tagged.Node` values that preserve
explicit tags on scalar, sequence, and mapping nodes. Tagged parsing deliberately
does not construct Foundation values for `!!timestamp` or `!!binary`, and
project-specific tags are preserved for caller-owned conversion. It preserves
source shape for diagnostics; normal parsing is the semantic surface that
expands merge keys into
`Model.Value`. `PureYAML.Tagged.Validator` reports
unsupported built-in tags and built-in tags applied to the wrong node kind with
the same path and stream issue types used by the main validator.
`PureYAML.Tagged.Constructor<Output>` is the explicit construction boundary for
projects that want to turn tagged nodes into domain values. It starts empty,
registers handlers by exact tag and node kind, and reports path-aware
`ConstructionError` values when no handler exists or a tag appears on the wrong
node kind. Handler context carries the root node, current subject, path, and a
recursive construction method so nested conversion keeps exact diagnostics. The
model fallback is deliberately named `modelValueErasingTags`: it preserves
mapping order and duplicate keys, then erases tag metadata only when the caller
has opted into that lossy boundary.
Mappings are ordered pairs. String keys remain the common path through
`Model.Pair.key`, `Model.Mapping` string lookup, validation dot/bracket paths,
and keyed `Codable`. Complex keys are represented by `Model.Key` and stored on
each pair as `Model.Pair.keyNode`. Sequence and mapping keys stay visible for
validation, dumping, and model inspection; they are not silently flattened into
Swift dictionary keys. This mirrors the reference behavior that treats mapping
keys as nodes, while keeping PureYAML's public typed-conversion boundary
string-keyed and dependency-free.
The complex-key path contract is deterministic. Simple string keys keep the
existing `.key` path rendering. Sequence and mapping keys use an explicit
complex-key path component whose description is derived from the same stable
flow YAML representation used by duplicate-key diagnostics. That keeps paths
unambiguous even for YAML states that ordinary loaders collapse or cannot
represent in native dictionaries.
The dumper emits deterministic YAML from the model. The default policy is
block-style collections with quoted strings. `Emitting.Options` can opt into
conservative plain scalars, safe literal block scalars for multiline strings,
and flow-style collections. Literal block scalar emission is intentionally
limited to strings whose lines round-trip through the current parser; other
multiline strings stay quoted. Flow collections always use inline scalars.
Complex mapping keys emit as explicit YAML keys in block or flow style.
The validator traverses parsed YAML values and reports path-aware issues. The
default rule rejects duplicate mapping keys. Callers can compose custom rules on
top of the default validator or start from `Validation.Validator.blank` for
project-specific checks only. Strict validation treats warnings as failures;
non-strict validation returns warnings while still throwing for errors. The
validation corpus pins exact issue paths, descriptions, severity splits,
strict/non-strict behavior, rule traversal order, and duplicate-key diagnostics.
The stream validation API wraps document-local issues in `PureYAML.Stream.Issue`
so callers keep both the document index and the exact path inside that document.
Typed conversion supports scalar single-value Decodable and Encodable values,
keyed mapping-backed structs with scalar, optional scalar, nested keyed struct,
and sequence fields, plus unkeyed sequences with scalar elements, optional
elements, nested sequences, and keyed mapping elements. Keyed `Codable` is
deliberately string-keyed: complex mapping keys remain available in
`Model.Value`, but do not appear in `allKeys`, `contains(_:)`, or keyed property
lookup. `PureYAML.decode(_:from:)`, `PureYAML.encode(_:)`, and
`PureYAML.encodeToYAML(_:)` validate input shapes and throw exact path-aware
typed coding errors.
Typed decoding runs default validation before Swift `Decodable` construction.
That keeps ambiguous YAML states, such as duplicate mapping keys, from being
silently collapsed by keyed property lookup.
## Rejected API Shape: Arbitrary Any
PureYAML does not provide a public arbitrary `Any` loader or dumper. Unknown YAML
must stay in `PureYAML.Model.Value`; known YAML should use typed `Codable`.
This is a deterministic validation decision. A Swift dictionary-shaped value
cannot preserve all YAML mapping entries when keys repeat, cannot represent
complex mapping keys without a caller-owned key policy, and makes tag
normalization ambiguous. Merge keys are handled explicitly by the parser instead
of being hidden inside an arbitrary-value projection. The research constructor
also includes Foundation-specific scalar conversions, which would break the
dependency-free and WASM-compatible library boundary.
Application code may project `Model.Value` or `Tagged.Node` into JSON-like or
domain-specific values after validation. `Tagged.Constructor` is the supported
typed form of that projection, while dictionary-shaped `Any` projection remains
outside the library because it must own data-loss policy explicitly.
## Planned Compatibility Work
Compatibility should be added in small, test-backed slices:
1. Additional built-in validation rules beyond duplicate-key behavior.
2. Broader Codable compatibility beyond scalar, keyed, and unkeyed containers.
3. Yams corpus comparison tests in a separate compatibility suite.
The private `PureYAMLResearch` repository may be used to study Yams behavior, but
the public implementation must be written in Swift and must not copy C parser
source.
## Attribution Boundary
Yams and its bundled `CYaml` / libyaml-derived C parser are compatibility
references for PureYAML. They should guide test cases and behavior questions.
They are not source material to copy into this package.
## Round-trip law
PureYAML keeps the parser (`PureYAML.parse`) and the emitter (`PureYAML.dump`)
as separate hand-written implementations, not one invertible grammar. The
guarantee they jointly hold is a fixed point over the emitter's normal form, not
a bijection over arbitrary author formatting (the surface-to-model relation is
many-to-one: spacing, flow vs block, and comments do not survive into the model):
- Lambda1 (model fidelity): for a `Model.Value` `x`, `parse(dump(x)) == x`.
- Lambda2 (idempotence): `parse(dump(parse(s))) == parse(s)` for any input `s`.
These are enforced as gates, not hoped for: corpus and generated-document
round-trip tests assert reparsed equality, and the empty-collection degenerate
values are pinned as passing regressions. `dump` is total: no force-unwraps, and
every empty collection renders to `[]` / `{}` at every position.