oneyaml 1.0.0

oneyaml — one core, one interpretation: serde + lossless editing for YAML 1.2.
Documentation
oneyaml-1.0.0 has been yanked.

oneyaml

One core, one interpretation: serde and lossless, comment-preserving editing for YAML 1.2, correct by default and free of panics.

crates.io docs.rs MSRV 1.85 License: MIT OR Apache-2.0

How many times fewer CPU instructions oneyaml needs than the fastest feature-complete competitor, for four operations (serde read, serde write, Document parse, a three-edit Document script) on five generated inputs shaped like real configuration files:

serde read (typed) vs oneyaml Ir/B competitor Ir/B × faster
gha-workflow (3.1 kB) serde_yaml_ng 262.5 292.3 1.1×
compose-merge (2.4 kB) serde_yaml_ng 779.1 625.2 0.80× — slower
helm-values (100 kB) serde_yaml_ng 133.4 174.7 1.3×
openapi-100k (100 kB) serde_norway 359.8 348.4 0.97× — slower
k8s-multi (1.1 MB) serde_norway 370.1 422.2 1.1×
serde write (typed) vs oneyaml Ir/B competitor Ir/B × faster
gha-workflow (3.1 kB) serde-saphyr 123.7 193.2 1.6×
compose-merge (2.4 kB) serde-saphyr 226.9 342.7 1.5×
helm-values (100 kB) serde-saphyr 31.2 43.1 1.4×
openapi-100k (100 kB) serde-saphyr 210.6 311.2 1.5×
k8s-multi (1.1 MB) serde-saphyr 136.8 214.9 1.6×
Document parse vs oneyaml Ir/B competitor Ir/B × faster
gha-workflow (3.1 kB) yaml-edit 319.3 418.1 1.3×
compose-merge (2.4 kB) yaml-edit 471.5 514.3 1.1×
helm-values (100 kB) yaml-edit 167.7 196.0 1.2×
openapi-100k (100 kB) yaml-edit 431.0 449.0 1.0×
k8s-multi (1.1 MB) yaml-edit 417.5 435.2 1.0×
Document edit script (insert + set + remove) vs oneyaml Ir per script competitor Ir per script × faster
gha-workflow (3.1 kB) yaml-edit 1,291,129 1,870,293 1.4×
compose-merge (2.4 kB) yaml-edit 1,078,595 302,491 0.28× — slower
helm-values (100 kB) yaml-edit 7,827,822 11,823,150 1.5×
openapi-100k (100 kB) yaml-edit 21,157,281 37,349,271 1.8×
k8s-multi (1.1 MB) yaml-edit 798,007 1,786,848 2.2×
  • × faster = the competitor's CPU instructions ÷ oneyaml's on the same input (higher is better; below 1.0× oneyaml is slower), counted by valgrind (callgrind): deterministic from run to run, not wall-clock time. Ir/B = instructions per input byte; the edit script counts one script of three edits (insert + set + remove) on the parsed document; lower is better, the better value in bold.
  • Wall-clock (median, same host) points the same way as the instruction count on 16 of 20 rows; the other way on serde read gha-workflow, helm-values and k8s-multi; Document parse compose-merge.
  • Measured 4c6d70c (only the benchmark harness and docs differed from it) on 2026-10-03 · AMD Ryzen 9 7950X3D 16-Core Processor · Ubuntu 26.04 LTS (x86_64) · rustc 1.97.1 (8bab26f4f 2026-07-14) · valgrind-3.27.1, against serde_yaml_ng 0.10.0, serde_norway 0.9.42, serde-saphyr 1.3.0, yaml-edit 0.3.2. Serde rows compare with the fastest feature-complete YAML serde library on that row (aliases, multi-document, merge keys), Document rows with yaml-edit, the other lossless editor; every output is checked for equivalent work. Inputs are generated in the shape of GitHub Actions, docker-compose, Helm, OpenAPI and multi-document Kubernetes files.
  • Full results (every workload, memory, scaling) and how to reproduce them: docs/performance.md (regenerate).

The ratios count instructions, which are deterministic: every run reproduces them exactly. They are not wall-clock times; the Wall-clock line under the tables names the rows where the median wall-clock time on the measurement host points the other way, so don't read those rows as a speed-up. Every row below 1.0× is a loss and is labelled slower. The clearest losses are typed reads of files that use merge keys (compose-merge) and the edit script on documents of a few kilobytes, where a fixed cost per edit outweighs the small document (a waived target).

Full results, every workload, and how to reproduce → docs/performance.md

Correctness and safety

  • YAML 1.2.2, Core schema by default. yes is a string and 0777 is a decimal integer, as the spec says; the Failsafe and JSON schemas are built in. Every YAML 1.1 behaviour and every leniency is a named setting, off by default (known deviations).
  • Conformance. yaml-test-suite data-2022-01-17, 402 cases: 402/402 under the standard criterion (valid 308/308, error 94/94), 401/402 strict (exact events, error prefixes and codes), and 308/308 valid inputs round-trip byte for byte (evidence, other Rust parsers' scores and how to reproduce).
  • Fuzzed and differential-tested. Coverage-guided fuzzing of parsing, serde, editing, comments and diagnostics (crates/fuzz), and differential tests against serde_yaml_ng, saphyr, yaml-rust2, libyaml and yaml-edit.
  • No unsafe, no panics. Every published crate is #![forbid(unsafe_code)]. Every input gives a value or an error, and every fallible API returns Result or Option. Parsing is iterative, so deep nesting can't overflow the stack.
  • Bounded by default. Every read enforces resource limits; for input from the network use Limits::untrusted().

Install

cargo add oneyaml serde -F serde/derive

Quick start

use oneyaml::Document;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct Config { name: String, port: u16 }

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // serde: read and write typed data.
    let cfg: Config = oneyaml::from_str("name: web\nport: 8080\n")?;
    assert_eq!(oneyaml::to_string(&cfg)?, "name: web\nport: 8080\n");

    // Read values from a document.
    let mut doc = Document::parse("server:\n  port: 8080  # public\ntags: [web]\n")?;
    assert_eq!(doc.get_path("server.port").and_then(|n| n.as_u64()), Some(8080));

    // Edit it: everything else, comments included, stays byte for byte.
    doc.set_path("server.port", 9090)?;
    doc.get_mut("tags")?.push("api")?;
    assert_eq!(doc.to_string(), "server:\n  port: 9090  # public\ntags: [web, api]\n");
    Ok(())
}

Why oneyaml

  • One interpretation. serde and Document read the same events through the same resolver, so they never disagree about what a file means; a test checks that they agree on every corpus file (consistency.rs).
  • Lossless. Document::parse(src)?.to_string() == src, byte for byte, on every valid file of the yaml-test-suite (round trip).
  • Comments kept. Edits keep every comment with the entry it belongs to, by numbered rules; new content is laid out like its neighbours.
  • Correct by default. YAML 1.2.2 and its Core schema (yes is a string); YAML 1.1 behaviours are opt-in settings (migrating from serde_yaml).
  • Errors you can act on. Every error has a stable Y#### code, a span and a path, and common mistakes come with a suggested fix (diagnostics).
  • Minimal dependencies. serde (without derive), nothing else by default (feature flags).

How oneyaml compares with serde_yaml_ng, serde_norway, serde-saphyr and yaml-edit, including where they win: docs/comparison.md.

When not to use oneyaml

  • You need the fastest typed reads in wall-clock time. oneyaml's serde reads use fewer instructions on most inputs, but in wall-clock time serde_yaml_ng and serde_norway are as fast or faster on every workload of the table above (serde wall-clock).
  • You make a few edits to many small files and edit speed matters. Below a few kilobytes, yaml-edit's edits cost fewer instructions (the compose-merge row above).
  • You need something oneyaml doesn't do: no_std, async I/O, schema validation, templating, building objects from tags, or editing a file too large to hold in memory (Document keeps the whole tree; serde reads don't).
  • You want serde_yaml's exact behaviour with no review. Compat::serde_yaml() gets close, but some differences are deliberate; the migration guide lists every one.
  • You need a long production track record. serde_yaml's lineage has years of it; oneyaml is new.

Untrusted input

Every entry point enforces resource limits (depth, alias expansion, document size, scalar length, document count). The default profile suits configuration files; for input from the network, use Limits::untrusted():

use oneyaml::{Limits, Settings, Value};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let settings = Settings::new().limits(Limits::untrusted());
    let small = "a: &a [x, x]\nb: &b [*a, *a]\nc: &c [*b, *b]\n";
    let v: Value = oneyaml::from_str_with(small, &settings)?; // small: fine
    assert_eq!(v["c"][0][1][0].as_str(), Some("x"));

    // "Billion laughs": ten levels of ten aliases each.
    let mut laughs = String::from("l0: &l0 [lol]\n");
    for i in 1..10 {
        let refs = vec![format!("*l{}", i - 1); 10].join(", ");
        laughs.push_str(&format!("l{i}: &l{i} [{refs}]\n"));
    }
    let refused = oneyaml::from_str_with::<Value>(&laughs, &settings);
    assert_eq!(refused.err().map(|e| e.code()), Some(oneyaml::code::ALIAS_BUDGET_EXCEEDED));
    Ok(())
}

There are three profiles: Limits::default(), Limits::untrusted() and Limits::unlimited(), the only one without alias budgets (for input you trust completely). Exceeding a limit is an error whose help names the setting to raise. The alias budgets count the nodes and bytes that alias expansion produces beyond the input, and an alias whose whole expansion would exceed a budget is refused before it is replayed, so "billion laughs" fails fast. Document never expands aliases, and compatibility presets never change limits. The values of each profile and how the budgets are counted are on Limits.

Merge keys (<<)

YAML 1.2 has no merge keys: by default << is an ordinary string key, as the spec says. docker-compose files and many CI configurations rely on the YAML 1.1 merge-key extension; turn it on with one setting:

use oneyaml::{Settings, Value};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let src = "base: &b {image: nginx, replicas: 2}\nweb:\n  <<: *b\n  replicas: 3\n";
    let v: Value = oneyaml::from_str_with(src, &Settings::new().merge_keys(true))?;
    assert_eq!(v["web"]["image"].as_str(), Some("nginx"));
    assert_eq!(v["web"]["replicas"].as_u64(), Some(3));
    Ok(())
}

Compat::yaml_1_1() turns on every YAML 1.1 behaviour, merge keys included; Compat::serde_yaml() leaves them off, as serde_yaml's deserializer does. Merges through aliases count toward the alias budget. Document keeps << entries as written and edits them like any other entry; examples/compose_merge.rs edits a docker-compose file that uses them. Details: Settings::merge_keys.

Going further

The API reference on docs.rs has a worked, tested example for each task:

  • Read and edit a document. Document walks the core path (parse, look up, edit, write) and lists what an edit refuses; NodeRef covers typed reads, spans and deserialize; NodeMut covers edits in depth, and Removed covers moving an entry. Paths are strings ("spec.containers[0].image") or path! values; the syntax, and how to quote a key that holds a ., are on IntoPath.
  • Keep and edit comments. Each comment has an owner, set by the comment attachment rulebook; edits move comments with their owner, and explain_comment tells you which rule decided. See comments on Document.
  • Multi-document files, anchors and aliases. A file with several documents is a Stream, read and edited one document at a time (or iterated with serde). Editing an anchored node is seen through every alias; see anchors and aliases.
  • Diagnostics and recovering parses. Every error has a Y#### code (code), a span, a path and, for common mistakes, a fix you can apply; see diagnostics on Error and Render. Document::parse_recovering never fails, and you can still read and edit everything outside the broken regions.
  • Serialize and configure. to_string and to_writer write any Serialize, quoting every string that some reader would read as something else. Every entry point has a *_with twin that takes an explicit Settings (there are no globals): the schema, YAML 1.1 behaviours, duplicate-key handling, output layout (Emit) and resource limits (Limits).
  • Migrate from serde_yaml. The entry points keep serde_yaml's names (from_str, from_slice, from_reader, to_string, to_writer, from_value, to_value, Value, Mapping, Deserializer, Serializer), so migrating starts with a use swap, and Compat::serde_yaml() reads and writes as serde_yaml does where YAML 1.2 allows. The migration guide walks through it and lists every remaining difference with its reason.
  • Real-world programs. examples/ starts with edit_config.rs (the quick start as a program), then edits a Kubernetes manifest, a GitHub Actions workflow and a docker-compose file with merge keys, checking every comment's owner before and after. Run one with cargo run -p oneyaml --example k8s_manifest.

Feature flags

All are off by default; without miette and serde_json, the only third-party runtime dependency is serde.

  • miette: Error implements miette::Diagnostic (code, severity, labels, help with the suggested fixes, and a documentation URL per code); DiagnosticReport does the same for a Diagnostic. Adds miette 7 without its default features (it brings cfg-if and unicode-width).
  • serde_json: TryFrom conversions between Value and serde_json::Value in both directions, and Value::to_json_with. What has no JSON form (tags, non-string keys, .nan/.inf, integers beyond 64 bits, nesting past 128 levels) is an error with its path, never a silent coercion; the full table is on Value::to_json_with. Adds serde_json with only its std feature.
  • __internal-checks (unstable, not covered by semver): test-only consistency checks used by this crate's own test suites and fuzzers. Never enable it in a library or application.

Minimum supported Rust version

Rust 1.85 (edition 2024), for oneyaml, oneyaml-core and oneyaml-syntax. The MSRV is rolling: it may move to any stable release at least 12 months old, only in a minor release, and every move is announced in the changelog.

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in oneyaml by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. See CONTRIBUTING.md and SECURITY.md.