Expand description
§yaml0 — YAML data format implementation for serde
A YAML 1.2 best-effort parser and emmitter with the goals of:
- Filling the gap after archival of
serde-yaml - Best effort compliance to support common docker-compose, kubernetes resource and configuration files
- Trustworthy, least-dependencies implementation to avoid the trust issues surrounding other similarly motivated replacement crates dismissed for suspicious, inexplicable dependencies and code
§Quick start
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Service {
name: String,
port: u16,
}
let yaml = "name: web\nport: 8080\n";
let svc: Service = yaml0::from_str(yaml).unwrap();
assert_eq!(svc, Service { name: "web".into(), port: 8080 });
let back = yaml0::to_string(&svc).unwrap();
assert_eq!(back, yaml);§Entry points
| Function | Purpose |
|---|---|
from_str | Deserialize a single document into T: DeserializeOwned. |
from_value | Deserialize from a pre-parsed BorrowedValue, supporting zero-copy borrows. |
to_string | Serialize T: Serialize to a YAML string. |
to_value | Serialize T: Serialize to a BorrowedValue (for inspection or post-processing). |
Parser | Manual parsing API. Use Parser::parse_all for multi-document streams. |
§Two value types: Value and BorrowedValue
There are two flavors of the dynamic data model, and picking one comes down to a single question: would you rather not write lifetimes, or do you want zero-copy borrows? You can’t have both at once — that’s the whole trade.
| Type | Lifetime | Strings | Reach for it when |
|---|---|---|---|
Value | none | owned String | You just want a serde_json::Value-style value to pass around, return, or drop into a struct field — without <'a> following you everywhere. |
BorrowedValue | <'a> | Cow<'a, str> into the source | You’re deserializing and want fields that borrow &str straight out of the input, no copying (see from_value). |
Value is the one to reach for by default. It carries no lifetime and
implements Deserialize and
Serialize, so let v: Value = from_str(s)? and a plain
yaml0::Value struct field both just work.
BorrowedValue is the parser’s native output and the emitter’s input — it
holds Cow::Borrowed slices of the source, which is what keeps the zero-copy
path actually zero-copy. Hop between the two with From: materializing into
an owned Value clones the strings, but borrowing back the other way
doesn’t copy a thing.
use yaml0::{Value, BorrowedValue, Parser};
let borrowed = Parser::new("a: 1\n").parse().unwrap();
let owned: Value = borrowed.into(); // materialize: strings cloned
assert!(matches!(owned, Value::Map(_)));
let view: BorrowedValue = (&owned).into(); // borrow back: nothing copied
assert!(matches!(view, BorrowedValue::Map(_)));One asymmetry worth knowing: tags live only on BorrowedValue. A custom
!tag survives parsing as BorrowedValue::Tagged, but deserializing into
an owned Value quietly unwraps it — you get the inner value, not the tag.
§Why DeserializeOwned for from_str?
from_str builds an intermediate BorrowedValue that lives only for the call.
If your target type held borrowed &str fields they’d reference a BorrowedValue
that’s already been dropped — unsound. The serde::de::DeserializeOwned
bound rules out borrowed fields at compile time.
To get the zero-copy payoff (struct fields that are &str slices of the
input), keep a BorrowedValue alive yourself and use from_value:
use serde::Deserialize;
#[derive(Deserialize)]
struct Borrowed<'a> { name: &'a str }
let src = "name: hello\n";
let value = yaml0::Parser::new(src).parse().unwrap();
let b: Borrowed<'_> = yaml0::from_value(&value).unwrap();
assert_eq!(b.name, "hello");§Multi-document streams
Files written by tools like kubectl get all -o yaml contain multiple
documents separated by ---. Use Parser::parse_all:
let stream = "\
---
kind: Pod
---
kind: Service
";
let docs = yaml0::Parser::new(stream).parse_all().unwrap();
assert_eq!(docs.len(), 2);§Number model
Integers deserialize to a single Value::Int (i64) — there is no separate
unsigned variant, so 5 and -5 are both Int, and matches!(v, Value::Int(_))
reliably means “is an integer.” A scalar is a Value::Float only when it
carries a ., e, or E; thus 1 is Int(1) while 1.0 is Float(1.0).
An integer literal outside i64 range is not truncated — it is preserved
verbatim as Value::String, losslessly, and recovered through the accessors,
which parse the text on demand:
// 20 digits, beyond i64::MAX → kept as String, read back as u64:
let v: yaml0::Value = yaml0::from_str("18446744073709551615").unwrap();
assert_eq!(v.as_u64(), Some(18446744073709551615));Prefer the accessors over matching variants when you only care about the value:
as_i64, as_u64, as_i128, as_f64, as_bool, as_str, truthy,
is_integer, is_numeric. Integers widen into f64 targets; narrowing conversions that
don’t fit fail loudly rather than wrap.
§Untagged enums and flatten
yaml0 is fully self-describing, so serde’s #[serde(untagged)] and
#[serde(flatten)] both work — the idiomatic way to model YAML’s recurring
“string or list or mapping” shapes and to capture open-ended keys such as
Compose’s x-* extensions:
use std::collections::HashMap;
use serde::Deserialize;
use yaml0::Value;
#[derive(Deserialize)]
struct Service {
image: String,
#[serde(flatten)]
extensions: HashMap<String, Value>,
}
let svc: Service = yaml0::from_str("image: nginx\nx-team: platform\n").unwrap();
assert_eq!(svc.image, "nginx");
assert!(svc.extensions.contains_key("x-team"));Gotcha — untagged variant order. An untagged enum is resolved by trying its
variants top-to-bottom and keeping the first that deserializes. Because an
integer widens into an f64 without error, a Float variant placed before
an Int variant will swallow integers and you lose their integer-ness. Put the
integer variant first:
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum Good { Int(i64), Float(f64) } // 42 → Int(42)
#[derive(Deserialize)]
#[serde(untagged)]
enum Bad { Float(f64), Int(i64) } // 42 → Float(42.0), integer-ness lost
assert!(matches!(yaml0::from_str::<Good>("42").unwrap(), Good::Int(42)));
assert!(matches!(yaml0::from_str::<Bad>("42").unwrap(), Bad::Float(_)));This ordering rule is a property of serde’s untagged matching, not of yaml0; it applies to any self-describing format.
§YAML feature coverage
Per YAML 1.2:
- Block and flow scalars (literal
|, folded>, plain, single/double quoted) - Block and flow containers (sequences and mappings)
- Standard tags (
!!str,!!int,!!float,!!bool,!!null) with coercion - Custom tags preserved via
BorrowedValue::Tagged - Anchors (
&name) and aliases (*name), document-scoped per spec - Multi-document streams (
---/...) - UTF-8 BOM and leading directives (
%YAML/%TAG) tolerated
Beyond strict YAML 1.2:
- Merge keys (
<<: *base) resolved automatically — heavy in docker-compose.
Not implemented (rare in target ecosystems):
- Explicit complex keys (
? key: value) - Strict
%YAMLversion enforcement %TAGhandle substitution
§Design principles
- Spec-correct parser, pragmatic emitter. Roundtrip-equal in data, not necessarily byte-identical in presentation.
- Zero-copy via
Cow<'a, str>. Plain and unescaped quoted scalars are borrowed slices of the input; allocation only when escapes or folds force it. - Lossless scalar resolution. Plain
42→Int(42); quoted"42"staysString("42").
Structs§
Enums§
- Borrowed
Value - A parsed YAML node, lifetime-bound to the original source string.
- Value
Functions§
- from_
str - Parse a YAML string and deserialize the single document into
T. - from_
value - Deserialize from a pre-parsed
BorrowedValue. - to_
string - Serialize
Tto a YAML string. - to_
value - Serialize a value to a
BorrowedValue.