wireform-yaml: Spec-conformant YAML 1.2 encoding/decoding

[ bsd3, codec, data, library, serialization ] [ Propose Tags ] [ Report a vulnerability ]

YAML 1.2 codecs with a dynamic value model, generic and Template Haskell derivation, and an aeson bridge. Supports block and flow collections, tags, anchors, aliases, literal and folded scalars, and document streams. An annotated syntax tree supports source-preserving edits. Decoders bound nesting depth and alias expansion.


[Skip to Readme]

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.1.0.0
Change log CHANGELOG.md
Dependencies aeson (>=2.1 && <2.4), base (>=4.20 && <5), bytestring (>=0.11 && <0.13), containers (>=0.6 && <0.9), deepseq (>=1.4 && <1.6), hashable (>=1.4 && <1.6), mtl (>=2.2 && <2.4), scientific (>=0.3 && <0.4), template-haskell (>=2.18 && <2.25), text (>=2.0 && <2.2), unordered-containers (>=0.2 && <0.3), vector (>=0.13 && <0.14), wireform-derive (>=0.2.0.0 && <0.3) [details]
Tested with ghc ==9.10.3, ghc ==9.12.4, ghc ==9.14.1
License BSD-3-Clause
Copyright 2026 Ian Duncan
Author Ian Duncan
Maintainer ian@iankduncan.com
Uploaded by IanDuncan at 2026-11-02T18:13:21Z
Category Data, Codec, Serialization
Home page https://github.com/iand675/wireform-
Bug tracker https://github.com/iand675/wireform-/issues
Source repo head: git clone https://github.com/iand675/wireform-.git(wireform-yaml)
Distributions
Downloads 3 total (3 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs uploaded by user
Build status unknown [no reports yet]

Readme for wireform-yaml-0.1.0.0

[back to package description]

wireform-yaml

BSD-3-Clause

[!CAUTION] This is an experimental, pre-1.0 package. APIs may change between releases.

YAML 1.2 for Haskell. Encode and decode the dynamic YAML.Value, derive typeclass instances generically or via Template Haskell, work with an annotated AST that preserves source spans / comments / scalar style, pretty-print back to YAML, bridge to JSON, and pass the official yaml-test-suite when you point the test harness at a clone of it.

YAML 1.2 covers block-style and flow-style scalars, sequences, and mappings; anchors and aliases (with merge-key semantics and a document-wide expansion budget); explicit tags; the four scalar styles (plain, single-quoted, double-quoted, literal-folded); multi-document streams; and the JSON-compatible flow subset. wireform-yaml implements the spec close enough to read and write the corner cases that show up in Kubernetes manifests, CI configs, and Ansible playbooks without surprising round-trip behavior. It also exposes a separate annotated AST for tooling that needs to preserve source positions and trivia (comments, blank lines, scalar style) across a load / save cycle.

This package is part of the wireform monorepo and shares its allocation primitives, annotation deriver, and testing discipline with every other format.

Install

build-depends:
  base,
  wireform-yaml ^>= 0.1.0.0,
  wireform-derive ^>= 0.2.0.0, -- if importing the shared modifier vocabulary

The package is part of the wireform monorepo and requires GHC 9.10 or later. Clone the repo and cabal build wireform-yaml to compile locally. Compiling with the LLVM backend (-fllvm) adds compile time but measurably improves runtime performance.

Hello world

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE DerivingStrategies #-}

import GHC.Generics (Generic)
import Data.Text (Text)
import qualified Data.Text as T
import YAML.Class (ToYAML, FromYAML, encodeYAML, decodeYAML)

data Service = Service
  { name     :: !Text
  , replicas :: !Int
  , image    :: !Text
  } deriving stock (Show, Eq, Generic)
    deriving anyclass (ToYAML, FromYAML)

main :: IO ()
main = do
  let svc  = Service "api" 3 "ghcr.io/example/api:1.4.2"
      text = encodeYAML svc
  putStrLn (T.unpack text)
  case decodeYAML text of
    Right (decoded :: Service) -> print decoded
    Left  err                  -> putStrLn err

encodeYAML svc renders to:

name: api
replicas: 3
image: ghcr.io/example/api:1.4.2

What's in here

Module Role
YAML.Value Dynamic untyped Value ADT (scalars, sequences, mappings, tags, anchors) plus the Document and Stream wrappers for multi-document inputs
YAML.Encode Emitter: Value / Document / Stream to YAML Text or ByteString
YAML.Decode Loader: ByteString / Text to Value / Document / Stream, with anchor and alias resolution
YAML.Encoding Builder used by ToYAML instances
YAML.Pretty Render Value / Document / Stream back to YAML text with configurable scalar style, indent, and flow / block preference (render, renderBS, renderDocument, renderStream, defaultOptions, compactOptions)
YAML.Class Public ToYAML / FromYAML typeclasses + encodeYAML / encodeYAMLBS / decodeYAML / decodeYAMLBS
YAML.Derive deriveYAML / deriveToYAML / deriveFromYAML Template Haskell entry points
YAML.Annotated Annotated AST (AValue, ADocument, AStream) that preserves source spans, comments, scalar style, and chomping indicators
YAML.Decode.Annotated Loader for the annotated AST
YAML.JSON Lossy bridge to and from aeson's Value (mapping keys are stringified, tags and anchors are dropped)

Encode and decode

The typeclass entry points cover both Text and ByteString I/O:

encodeYAML   :: ToYAML   a => a          -> Text
encodeYAMLBS :: ToYAML   a => a          -> ByteString
decodeYAML   :: FromYAML a => Text       -> Either String a
decodeYAMLBS :: FromYAML a => ByteString -> Either String a

For dynamic values without a Haskell type to mirror them, work with YAML.Value directly. The Value ADT carries the explicit YAML tag on each node (!!str, !!int, !!seq, custom tags), so type information that JSON would discard survives the round-trip.

For multi-document YAML streams (--- separated), use the Document and Stream wrappers in YAML.Value, decodeStream / decodeStreamBS, and encodeStream. Single-document entry points reject extra documents.

Annotated AST

The annotated AST in YAML.Annotated is the surface to use when you need to preserve more than the data model: source spans for error reporting, scalar style for round-trip preservation, comments and blank lines (trivia) for tooling that has to round-trip a file without rewriting unrelated lines.

import qualified YAML.Decode.Annotated as YDA
import qualified YAML.Pretty           as YP

case YDA.decodeAnnotated input of
  Right (doc, original) -> putStrLn (T.unpack (YP.renderAnnotatedDocument YP.defaultOptions doc (Just original)))
  Left  err -> putStrLn err

Edits preserve untouched flow-node tokens, quoting, and comments as well as block-entry text. Changed flow collections may be laid out over multiple lines to retain their comments. Parsed aliases keep their binding identities: changing an anchor updates its live references, while deleting or moving a definition materializes references that would otherwise become invalid. Editing through an alias makes an independent copy.

Common consumers: language servers, schema-driven config editors, formatters, and any tool that has to make a small change to a YAML file without touching the rest.

Annotation-driven deriving

YAML.Derive consumes the cross-format Wireform.Derive.Modifier vocabulary from wireform-derive, so the same annotated record can produce YAML, JSON, and any other backend's instances:

{-# LANGUAGE TemplateHaskell #-}

import qualified YAML.Derive          as DYAML
import qualified Wireform.Derive.Aeson as DAeson
import Wireform.Derive (rename, renameStyle, KebabCase)

data DeploymentSpec = DeploymentSpec
  { deploymentReplicas       :: !Int
  , deploymentImagePullPolicy :: !Text
  } deriving stock (Show, Eq, Generic)

{-# ANN type DeploymentSpec ("DeploymentSpec" :: String) #-}
{-# ANN deploymentReplicas        (renameStyle KebabCase) #-}
{-# ANN deploymentImagePullPolicy (renameStyle KebabCase) #-}

DYAML.deriveYAML  ''DeploymentSpec
DAeson.deriveJSON ''DeploymentSpec

Record constructors inside sums use a mapping for contents, so their field renames, defaults, optional fields, and skipped fields work just like standalone records. Positional constructors retain scalar or sequence contents.

JSON bridge

YAML.JSON round-trips between YAML.Value and Data.Aeson.Value. The bridge is intentionally lossy: YAML's data model is a strict superset of JSON's, so non-string mapping keys are stringified, and explicit tags and anchors are dropped. See the module haddock for the exact mapping table. Non-finite YAML floats become JSON null. YAML integers use arbitrary-precision Integer; bounded Haskell integer decoders reject overflow, negative unsigned values, and fractional inputs. ByteString decoder entry points detect UTF-8, UTF-16, and UTF-32 and reject malformed encodings. Native records and maps reject ambiguous field names after tag metadata is removed. The ByteString serialization instance uses a text scalar for valid UTF-8 and a sequence of byte-valued integers for arbitrary binary data.

The parser bounds nesting and logical alias expansion (10 million nodes), rejects cyclic/forward aliases, and limits tracked indentation to 1,048,574 spaces.

Testing

The per-format Hedgehog suite lives in test/:

cabal test wireform-yaml:wireform-yaml-test

No data yet. Run cabal test wireform-yaml:all --test-show-details=streaming --xml=dist-stats/test-results/wireform-yaml.junit.xml to populate.

It covers the parser, the emitter, the typeclass instances, the annotated AST, security-relevant edge cases (billion-laughs anchor expansion, deep nesting), and round-trip property tests against Value.

Conformance against yaml-test-suite

The test binary also runs an opt-in conformance harness against the official yaml/yaml-test-suite. Point YAML_TEST_SUITE at a local clone and the harness will walk every case it finds:

git clone --branch data https://github.com/yaml/yaml-test-suite /tmp/yaml-test-suite
YAML_TEST_SUITE=/tmp/yaml-test-suite \
  cabal test wireform-yaml:wireform-yaml-test

The event-only upstream fixtures X38W and 2JQS contain duplicate keys. The harness asserts that value composition rejects them; it does not treat valid parser events as permission to construct a mapping with duplicate keys. All available JSON companions are compared across every document. Each accepted fixture also round-trips structurally through the fast emitter, both pretty layouts, and annotated normalization; collection-valued keys are checked directly.

When the env var is unset the harness reports a no-op skip group so CI stays green out of the box. A built-in mini-suite drawn from the YAML 1.2 spec examples always runs, so core compliance is exercised even without the external clone.

Benchmarks

A criterion harness in bench/Bench.hs compares wireform-yaml's parser and emitter against the two established Haskell YAML libraries:

  • HsYAML, pure Haskell, prioritises spec compliance.
  • yaml, bindings to libyaml.
cabal bench wireform-yaml:wireform-yaml-bench
wireform-yaml decode vs libyaml across input sizes
Operation wireform-yaml yaml (libyaml) ratio
tiny 0.44 µs 6.14 µs 13.95x
small 6.73 µs 34.6 µs 5.15x
flow 7.51 µs 44.2 µs 5.88x
literal 4.71 µs 11.2 µs 2.38x
big 37.5 µs 122 µs 3.26x

Last run 2026-11-02 14:35:47 UTC. ghc-9.10.3 on darwin-aarch64, criterion 1.6.5.

wireform-yaml decode vs HsYAML (pure Haskell) across input sizes
Operation wireform-yaml HsYAML ratio
tiny 0.44 µs 119 µs 270.86x
small 6.73 µs 1068 µs 158.75x
flow 7.51 µs 1322 µs 176.03x
literal 4.71 µs 1504 µs 319.30x
big 37.5 µs 5027 µs 133.95x

Last run 2026-11-02 14:35:47 UTC. ghc-9.10.3 on darwin-aarch64, criterion 1.6.5.

Escaped strings and derived records

Escaped strings and derived records vs libyaml
Operation wireform-yaml yaml (libyaml) ratio
escaped decode 7.12 µs 13.6 µs 1.91x
record decode 1.85 µs 11.9 µs 6.43x
record encode 1.25 µs 5.36 µs 4.29x

Last run 2026-11-02 14:35:47 UTC. ghc-9.10.3 on darwin-aarch64, criterion 1.6.5.

The record fixture uses the actual deriveYAML output. Every benchmark fixture is checked for successful decoding before timing; the HsYAML comparison forces the parsed contents as well as the outer document list.

ASCII validation scans eight bytes at a time and skips line normalization when there are no carriage returns. Unicode and unusual controls retain lexical validation. Literal scalars measure their final chomped length and copy their UTF-8 lines into one exact buffer, so discarded trailing blank lines are neither copied nor retained. Escaped scalars likewise write directly into a bounded UTF-8 buffer. Optimized Core shows bulk byte copies, direct byte writes, and unboxed offset loops without per-escape result allocation.

For cross-language comparisons:

  • C: libyaml. The Hackage yaml package is a thin binding around it, so the Haskell-vs-Haskell number above is already partly cross-language.
  • Rust: serde_yaml (the established library) and saphyr (a newer, faster alternative).

The committed tables above are generated from Criterion measurements. The harness also exercises floating-point boundaries and the generated native-record codec.

License

BSD-3-Clause.

References