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

12 KiB

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

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.