Recursive AI

oneyaml

All oneyaml Rust docs

serde and lossless, comment-preserving editing for YAML 1.2, in one crate. Both run on one parser and one interpretation of the file, so a typed read and an edit never disagree about what it means. Correct by default, no unsafe, no panics on any input.

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

How many times fewer CPU instructions oneyaml executes than each competitor (the fastest feature-complete serde library for serde reads and writes, yaml-edit for Document parsing and a three-edit script), on five workloads shaped like real configuration files (higher is better; hatched bars are workloads where oneyaml is slower):

Chart of CPU instruction counts, not wall-clock time: how many times fewer instructions oneyaml executes than each competitor (competitor ÷ oneyaml; higher is better; 1.0× is equal), against the fastest feature-complete YAML serde library on each workload for serde read and serde write, and yaml-edit for Document parse and Document edit script (insert + set + remove); one bar per workload for 5 workloads (gha-workflow, compose-merge, helm-values, openapi-100k and k8s-multi). serde read vs serde_yaml_ng and serde_norway: 1.0× to 1.7×; faster on all 5. serde write vs serde-saphyr: 1.3× to 1.5×; faster on all 5. Document parse vs yaml-edit: 1.1× to 1.5×; faster on all 5. Document edit script (insert + set + remove) vs yaml-edit: 0.60× to 64×; slower on 1 of 5, faster on 4 of 5 (slower: compose-merge 0.60×).

Full results, with wall-clock times and the method →

Why oneyaml

  • One parser, one meaning. serde and the lossless Document read the same events through the same resolver, so a typed read and an edit never disagree about what a file means; a test checks that they agree on every corpus file (consistency.rs).
  • Edits that keep your file. Document::parse(src)?.to_string() == src, byte for byte, on every valid file of the yaml-test-suite (round trip). An edit changes only what you touch: every comment stays with the entry it belongs to, also when you move the entry, by numbered rules, and new content is laid out like its neighbours. JSON Patch and merge patches change values the same way, all or nothing (a patch move carries the value without its comments) (patches and pointers).
  • Correct YAML 1.2.2 by default. The Core schema reads yes as a string and 0777 as a decimal integer, as the spec says; the Failsafe and JSON schemas are built in. Every YAML 1.1 behaviour, merge keys included, and every leniency is a named setting, off by default (known deviations). yaml-test-suite data-2022-01-17, 402 cases: 402/402 under the standard criterion (valid 308/308, error 94/94), 402/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).
  • Safe on any input. Every published crate is #![forbid(unsafe_code)]. Every input gives a value or an error, every fallible API returns Result or Option, and parsing is iterative, so deep nesting can't overflow the stack. Every read enforces resource limits; for input from the network, use Limits::untrusted(). Parsing, serde, editing, comments and diagnostics are fuzzed (crates/fuzz) and tested against serde_yaml_ng, saphyr, yaml-rust2, libyaml and yaml-edit.
  • 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).
  • Fast, with the losses shown. oneyaml executes fewer instructions than each competitor on every workload in the chart above but one, and the cost of a measured edit depends on what it touches, not on the file's size (a patch also reads the keys of each mapping it looks into, once per patch). It isn't faster everywhere: yaml-edit edits the smallest file in fewer instructions and less time, a typed read of the OpenAPI workload is slower in wall-clock time, and some serde reads outside the chart are slower or level (every loss).

The only runtime dependency by default is serde (feature flags). How oneyaml compares with serde_yaml_ng, serde_norway, serde-saphyr and yaml-edit, including where they win: docs/comparison.md.

Install

Terminal
cargo add oneyaml serde -F serde/derive

Quick start

Read and write typed data with serde, then edit a document in place: the edit changes only what you touched, and the comment stays.

RUST
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(())
}

Diagnostics

An error says what went wrong, where, and how to fix it. Display is one line for logs; Error::render prints a rustc-style snippet of your source with a stable Y#### code, labels, help, the path to the value, and a suggested fix you can apply:

RUST
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Config { debug: bool }
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // `yes` is a boolean in YAML 1.1 but a string in YAML 1.2.
    let src = "debug: yes\n";
    let err = oneyaml::from_str::<Config>(src).err().ok_or("expected an error")?;
    assert_eq!(err.render(src), r#"error[Y3000]: invalid type: string "yes", expected a boolean; YAML 1.2 reads `yes` as a string (its booleans are `true` and `false`)
 --> 1:8
  |
1 | debug: yes
  |        ^^^
  |
  = help: write `true` instead of `yes`, or read the YAML 1.1 booleans (y/n, yes/no, on/off) as booleans with Settings::yaml11_booleans(true) or the Compat::yaml_1_1() preset
  = path: debug
  = suggestion (may be incorrect): write the YAML 1.2 boolean `true`: replace `yes` with `true`
"#);
    let fix = err.diagnostic().suggestions.first().ok_or("no fix")?;
    assert_eq!(fix.apply(src).as_deref(), Some("debug: true\n"));
 
    // A forgotten `]`: the error points at the `[` that is still open
    // and offers to close it after `443`.
    let src = "ports: [80, 443\nimage: nginx\n";
    let err = oneyaml::Document::parse(src).err().ok_or("expected an error")?;
    let fix = err.diagnostic().suggestions.first().ok_or("no fix")?;
    assert_eq!(fix.apply(src).as_deref(), Some("ports: [80, 443]\nimage: nginx\n"));
    Ok(())
}

A fix is marked machine-applicable only when applying it can't change what the rest of the file means; any other fix, like the two above, is labelled for review. Codes are never reused, and limit errors name the setting to raise. Document::parse_recovering keeps going past the first error, so you see a file's errors together, and the miette feature plugs errors into miette's reports. More on diagnostics on Error.

Patches and pointers

A Document applies JSON Patch (RFC 6902) operations on YAML, and merge patches (RFC 7396), all or nothing: if any operation fails, the document is exactly as it was. A patch changes only the text of the values it names, so the comments and layout around them stay:

RUST
use oneyaml::{Document, MergePatch, Patch, PatchErrorKind};
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut doc = Document::parse("# app\nimage: web:1.0  # pinned\nports: [80]\n")?;
    let patch = Patch::from_json(
        r#"[{"op": "test", "path": "/image", "value": "web:1.0"},
            {"op": "replace", "path": "/image", "value": "web:1.1"},
            {"op": "add", "path": "/ports/-", "value": 443}]"#,
    )?;
    doc.apply_patch(&patch)?;
    assert_eq!(doc.to_string(), "# app\nimage: web:1.1  # pinned\nports: [80, 443]\n");
 
    // The `test` fails now, so nothing is applied.
    let err = doc.apply_patch(&patch).err().ok_or("expected an error")?;
    assert_eq!(err.kind(), PatchErrorKind::Test);
    assert_eq!(doc.get_pointer("/ports/1")?.as_u64(), Some(443));
 
    // A merge patch: `null` removes a key.
    let mut doc = Document::parse("image: web:1.0  # pinned\nenv:\n  DEBUG: \"1\"\n  LOG: info\n")?;
    doc.apply_merge_patch(&MergePatch::from_json(r#"{"image": "web:1.1", "env": {"DEBUG": null}}"#)?)?;
    assert_eq!(doc.to_string(), "image: web:1.1  # pinned\nenv:\n  LOG: info\n");
    Ok(())
}

Document::get_pointer reads a node by JSON Pointer, and DocumentMut and Stream apply patches too. To change one value without a patch, the setters (Document::set_path, NodeMut::set) write exactly the value you give and keep the old value's style: a block scalar stays a block scalar, quotes stay, and so does a tag that still fits.

Patches work on the YAML data model, so they differ from JSON Patch in a few places: test compares YAML values (1 is not 1.0), a pointer token matches a key by its text, and writes below an alias or to a key merged in by << are refused. copy and move write the value, not its text: the comments and anchors inside a moved value are dropped. To move an entry with its comments, use NodeMut::move_entry or Removed. Patch lists every difference and what each operation keeps, and says what a successful patch guarantees. Patch::from_json also reads YAML text, with the default limits; for untrusted text, read a Value with from_str_with and Limits::untrusted() and build the patch with Patch::from_value.

When not to use oneyaml

  • You need the fastest serde reads in wall-clock time. oneyaml's serde reads use fewer instructions on most workloads, but in wall-clock time serde_yaml_ng or serde_norway is faster on some of them: typed reads of the OpenAPI workload, most reads into structs that use a few fields, and some reads into Value (serde read 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 and less wall-clock time (compose-merge in the chart above; see the benchmark tables).
  • 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():

RUST
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:

RUST
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, and the guides in the repository cover the rest:

  • Read and edit a document. Document walks the core path (parse, look up, edit, write), says how new content and set values are written, 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.
  • Apply patches. Document::apply_patch and apply_merge_patch cover what each operation writes, how a pointer token matches a key, and the limits a patch is held to; PatchError names the operation that failed.
  • 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 reading and parsing entry point has a *_with twin (Patch::from_json and MergePatch::from_json excepted: they read with the defaults) 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.
  • Performance in detail. Results at a glance has the numbers behind the chart as tables (absolute instruction counts, both ratios, wall-clock time, every workload where oneyaml is slower and the machine they were measured on); the rest of docs/performance.md covers every workload, memory, scaling and how to reproduce it all.
  • 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

  • Apache License, Version 2.0 (LICENSE-APACHE or <https://www.apache.org/licenses/LICENSE-2.0>)
  • MIT license (LICENSE-MIT or <https://opensource.org/licenses/MIT>)

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.