A strongly typed, comment-supporting YAML deserializer that deserializes YAML directly into your Rust types without constructing an intermediate tree of “abstract values.”
Rust
221
833 commits
updated Sep 16, 2026
serde-saphyr is a strongly typed YAML deserializer built on top of granit-parser.
The parser is fuzz-tested and designed not to panic on malformed YAML. This design does not cover out-of-memory conditions,
panics in user-provided callbacks, or similar cases. The library build is configured to deny unsafe code. This does
not extend to transitive dependencies.
The crate deserializes YAML directly into your Rust types without constructing an intermediate tree of “abstract values.” Try it online as a WebAssembly application here.
See release history on GitHub.
serde-saphyr avoids the typical YAML remote code execution vulnerability because it does not support or implement tag-driven object construction. When used for linting, it can be configured to reject unknown tags.Budget.Tagged<T> wrapper captures and emits a node's resolved YAML tag.Commented<T> both captures and emits comments.validator (example) or garde (example).miette (example) integration for more advanced error reporting.serde-saphyr is compatible with WebAssembly. The CI flow includes builds for both wasm32-unknown-unknown (browser / JS) and wasm32-wasip1 (WASI runtimes), with most of the test suite running and passing (excluding tests that require file access or similarly unsupported functionality). We also wrote yva in Dioxus to deploy serde-saphyr on the web.
The test suite currently includes over 3000 passing tests. For YAML Test Suite v2022-01-17, all 350 active test IDs and all 402 active cases from the data-2022-01-17 release are represented (9C9N is intentionally relaxed to keep the library compatible with PyYAML and ruamel.yaml). Although we made a reasonable effort, accidental omissions or conversion errors remain possible. Some additional cases are taken from the original serde-yaml tests.
serde-saphyr is not a fork of the older serde-yaml crate and shares no code with it (apart from some reused tests). It is also not part of the saphyr project. The name was historically chosen to reflect the use of saphyr parser at a time when the Saphyr project did not provide its own Serde integration. granit-parser it's currently using is the fork of Saphyr parser.
serde-saphyr requires Rust 1.89 or newer. This minimum supported Rust version (MSRV) is tested in CI.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Config {
name: String,
enabled: bool,
retries: i32,
}
fn main() {
let yaml_input = r#"
name: "My Application"
enabled: true
retries: 5
...
"#;
let config: Result<Config, _> = serde_saphyr::from_str(yaml_input);
match config {
Ok(parsed_config) => {
println!("Parsed successfully: {:?}", parsed_config);
}
Err(e) => {
eprintln!("Failed to parse YAML: {}", e);
}
}
}
To speed up compilation, you can link only the deserializer or only the serializer (along with their respective dependencies). For easier initial integration, both serialize and deserialize features are enabled by default.
If you only need one side, you can disable default features and enable only the API surface you use:
serde-saphyr = { version = "1", default-features = false, features = ["deserialize"] }
or
serde-saphyr = { version = "1", default-features = false, features = ["serialize"] }
Disabling both will produce a "Invalid feature configuration" error (such configuration makes no sense).
The optional huge_documents feature switches span storage from u32 indices to a packed 48-bit internal representation so spans can cover YAML inputs far beyond 4 GiB without widening every coordinate to a full u64. Public getters still return u64, and values beyond the packed range saturate instead of wrapping.
Version 1.0 removes the APIs that were deprecated during the 0.0.x series and makes a few intentional naming and extensibility changes:
to_writer and to_writer_with_options functions with to_fmt_writer* for std::fmt::Write targets or to_io_writer* for std::io::Write targets.options!, budget!, ser_options!, alias_limits!, and render_options!. Configuration and public request/result structs are non-exhaustive so fields can be added compatibly; use constructors such as ResolvedInclude::new when returning include content. Fields themselves are no longer deprecated.ExternalMessageSource::Parser now carries the parser's ScanError, and the source field of Error::ExternalMessage is boxed. Prefer .. when matching fields you do not need.RcWeakAnchor::from(&rc); consuming a strong pointer no longer creates a weak anchor that immediately dangles.with_indent and with_options constructors validate settings and return Result; options are passed by value. Serializer::new follows SerializerOptions::default, including compact list indentation. SerializerOptions is Clone, but intentionally not Copy.read now returns impl Iterator, matching the other streaming entry points and avoiding an allocation. Code that explicitly required a boxed iterator can wrap it with Box::new.DefaultMessageFormatterWithLocalizer and UserMessageFormatterWithLocalizer are no longer public, and serializer helper types such as TupleSer are now opaque implementation details. Use the returned impl MessageFormatter values or serde::Serializer associated types instead. When both serialization and deserialization are enabled, the new root aliases SerializeError and DeserializeError distinguish their error types.serde-saphyr comes with a simple executable (CLI) that can be used to check the budget of a given YAML file, and can also be used as a YAML validator, printing the YAML error line, column numbers, and excerpt.
The CLI includes filesystem-backed !include support, so it must be built with the
include_fs feature. To install and run it (no Rust knowledge required):
cargo install serde-saphyr --features include_fs
# binary name is the package name by default
serde-saphyr path/to/file.yaml
To enable fancy error reporting (graphical diagnostics) via the optional miette integration, install/build the CLI with the miette feature enabled:
# install with miette enabled
cargo install serde-saphyr --features miette,include_fs
# or run from a git checkout
cargo run --features miette,include_fs -- path/to/file.yaml
If you want to keep the previous plain-text error output even when built with miette, pass --plain:
serde-saphyr --plain path/to/file.yaml
If you want to allow file inclusion (!include tags) during parsing, configure the filesystem root path using --include:
serde-saphyr --include path/to/root path/to/file.yaml
Serde-saphyr provides control over serialization and deserialization behavior. We generally welcome feature requests, but we also recognize that not every user wants every feature enabled by default.
To support different use cases, most behavior can be enabled, disabled, or tuned via Options (deserializers) and SerializerOptions (serializers). Serde-saphyr uses a macro-driven approach based on the options!, budget!, and ser_options! macros.
use serde_saphyr::DuplicateKeyPolicy;
fn main() {
let options = serde_saphyr::options! {
budget: serde_saphyr::budget! {
max_documents: 2,
},
duplicate_keys: DuplicateKeyPolicy::LastWins,
};
}
Struct literals cannot be used because option structures are non-exhaustive (to allow new fields without an API-breaking change).
Fuzzing shows that certain adversarial inputs can make YAML parsers consume excessive time or memory, enabling denial-of-service scenarios. To counter this, serde-saphyr offers a configurable Budget, available through Options. It accounts for parser events, retained copies used to replay anchors, and property-interpolation depth and work. Defaults are intentionally quite permissive; tighten them when you know your input shape, or disable the budget if you only parse YAML you generate yourself.
During reader-based deserialization, serde-saphyr does not buffer the entire payload; it parses incrementally, counting bytes and enforcing configured budgets.
Reader-based APIs enforce configured byte and structural limits while reading. When streaming from the reader through the iterator, other budget limits apply on a per-document basis, since such a reader may be expected to stream indefinitely. The total size of the input is not limited in this case.
To find the typical budget requirements for your file, use our web demo or run the main() executable of this library, providing a YAML file path as a program parameter. You can also fetch the budget programmatically by registering a closure with Options::with_budget_report.
Adding or removing a single space in YAML indentation may result in a document that is still syntactically correct but semantically wrong. To mitigate such issues, serde-saphyr can enforce indentation rules during deserialization via RequireIndent.
You can require the number of indentation columns to be consistent throughout the document, ensure it is even, or enforce that it is divisible by a specific number (for example, 4 or 6). Configure the desired policy using Options.
Duplicate key handling is configurable. By default it’s an error; “first wins” and “last wins” strategies are available via Options. The duplicate key policy applies not just to strings but also to other types (if used as keys when deserializing into a map).
YAML integer keys are parsed to their numeric meaning before checking for duplicates, regardless of the target Rust type. For example, 0xB and 11 are the same integer key, even in a HashMap<String, _>. This comparison uses exact integer values within the supported i128/u128 range and respects legacy_octal_numbers; values outside that range retain text-based comparison. The same rule applies inside composite keys and when resolving merge keys.
Deserialization into a string still preserves the original scalar spelling: 0xB becomes "0xB". “First wins” and “last wins” retain the selected entry's spelling and value; when integer keys require numeric comparison, “last wins” buffers the remaining mapping to select entries before passing them to Serde. Quoted keys and keys tagged !!str remain strings, so "0xB" and "11" are distinct keys. Duplicate checking uses YAML key identity; the target Rust map can still combine distinct YAML keys if they become equal after deserialization.
Buffered “last wins” entries use the same replay mechanism as struct fields: trailing comments and comments nested inside buffered values are not preserved, and nested Spanned::referenced locations use the buffered value's reference location. The default error policy and “first wins” do not require this additional buffering.
By default, if the target field is boolean, serde-saphyr will attempt to interpret standard YAML 1.1 values as boolean (not just false but also no, etc.).
If you do not want this (or if you are parsing into a JSON Value where it might be incorrectly inferred), enclose the value in quotes or set strict_booleans to true in Options.
To address the “Norway problem,” the target Rust types serve as an explicit schema. Because the parser knows whether a field expects a string or a boolean, it can correctly accept 1.2 either as a number or as the string "1.2", and interpret the common YAML boolean shorthands (y, on, n, off) as actual booleans when appropriate (can be disabled). Likewise, 0x2A is parsed as a hexadecimal integer when the target field is numeric, and as a string when the target is String. As with StrictYAML, serde-saphyr uses the Rust type system as the schema for typed deserialization. Integer key comparison is an exception: duplicate checking uses the integer's numeric meaning while preserving its original spelling for string targets.
Schema-based parsing can be disabled by setting no_schema to true in Options. In this case all unquoted values that are parsed into strings, but can be understood as something else, are rejected. This can be used for enforcing compatibility with another YAML parser that reads the same content and requires this quoting. Default setting is false.
Legacy octal notation such as 0052 can be enabled via Options, but it is disabled by default.
The concept that “Rust code is the schema” naturally extends to implemented support for validator and garde, as these crates allow annotations to be added directly to Rust types, providing even stricter control over permissible values.
YAML streams can contain several documents separated by ---/... markers. When deserializing with serde_saphyr::from_multiple, you still need to supply the vector element type up front (Vec<T>). That does not lock you into a single shape: make the element an enum and each document will deserialize into the matching variant. This lets you mix different payloads in one stream while retaining strong typing on the Rust side.
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
enum Document {
#[serde(rename = "person")]
Person { name: String, age: u8 },
#[serde(rename = "pet")]
Pet { kind: String },
}
fn main() {
let input = r#"---
person:
name: Alice
age: 30
---
pet:
kind: cat
---
person:
name: Bob
age: 25
"#;
let docs: Vec<Document> =
serde_saphyr::from_multiple(input).expect("valid YAML stream");
}
Externally tagged enums nest naturally in YAML as maps keyed by the variant name. This enables strict, expressive models (enums with associated data) instead of generic maps.
use serde::Deserialize;
#[derive(Deserialize)]
struct Move {
by: f32,
constraints: Vec<Constraint>,
}
#[derive(Deserialize)]
enum Constraint {
StayWithin { x: f32, y: f32, r: f32 },
MaxSpeed { v: f32 },
}
fn main() {
let yaml = r#"
- by: 10.0
constraints:
- StayWithin:
x: 0.0
y: 0.0
r: 5.0
- StayWithin:
x: 4.0
y: 0.0
r: 5.0
- MaxSpeed:
v: 3.5
"#;
let robot_moves: Vec<Move> = serde_saphyr::from_str(yaml).unwrap();
println!("Parsed {} moves", robot_moves.len());
}
There are two variants of the deserialization functions: from_* and from_*_with_options. The latter accepts an Options object that allows you to configure budget and other aspects of parsing. For larger projects that require consistent parsing behavior, we recommend defining a wrapper function so that all option and budget settings are managed in one place (see examples/wrapper_function.rs).
It is possible to deserialize tuple enum variants:
use serde::Deserialize;
#[derive(Debug, PartialEq, Eq, Deserialize)]
pub enum Value {
Expression(String),
Pair(String, i32),
}
#[derive(Debug, PartialEq, Eq, Deserialize)]
pub struct Context {
value: Value,
}
serde_saphyr::from_str::<Context>(yaml) would take the value: !Expression 1 + 1 or value: !Pair [a, 12]. Both YAML lists and Rust tuples allow their elements to have different types.
To verify support for polymorphism of arbitrary objects, not just enums, serde-saphyr is also tested with typetag.
YAML supports complex (non-string) mapping keys. Rust maps can mirror this, allowing you to parse such structures directly.
use serde::{Deserialize};
use std::collections::HashMap;
#[derive(Debug, PartialEq, Eq, Hash, Deserialize)]
struct Point {
x: i32,
y: i32
}
#[derive(Debug, PartialEq, Deserialize)]
struct Transform {
// Transform between locations
map: HashMap<Point, Point>,
}
fn main() {
let yaml = r#"
map:
{x: 1, y: 2}: {x: 3, y: 4}
{x: 5, y: 6}: {x: 7, y: 8}
"#;
let transform: Transform = serde_saphyr::from_str(yaml).unwrap();
println!("{} entries", transform.map.len());
}
!!binary-tagged YAML values are base64-decoded when deserializing into Vec<u8> or String (reporting an error if they are not valid UTF-8).
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct Blob {
data: Vec<u8>,
}
fn main() {
let blob: Blob = serde_saphyr::from_str("data: !!binary aGVsbG8=").unwrap();
assert_eq!(blob.data, b"hello");
}
Important: some projects add the !!binary tag while actually expecting a verbatim string value (for example, the literal string "aGVsbG8="). This works with parsers that simply ignore the tag. However, serde-saphyr decodes !!binary values by default, attempting to interpret them as UTF-8 bytes.
If you use !!binary only as a documentation or annotation tag, enable ignore_binary_tag_for_string = true in Options.
use serde::Deserialize;
#[derive(Deserialize)]
struct ContainsString {
name: String,
}
fn main() -> Result<(), serde_saphyr::Error> {
let value: ContainsString = serde_saphyr::from_str_with_options(
"name: !!binary H4sIAA==",
serde_saphyr::options! {
ignore_binary_tag_for_string: true
},
)?;
assert_eq!(value.name, "H4sIAA==");
Ok(())
}
!!binary for other types like Vec<u8> will stay supported.
If you must work with abstract types, you can also deserialize YAML into serde_json::Value. Serde will drive the process through deserialize_any because Value does not fix a Rust primitive type ahead of time. You lose the strict type control provided by Rust struct data types. Also, unlike YAML, JSON does not allow composite keys; keys must be strings. Mapping entries are presented to Serde in source order. Whether the target retains that order depends on its implementation.
serde-saphyr supports zero-copy deserialization for string fields when using from_str or from_slice. This allows deserializing into &str fields that borrow directly from the input, avoiding allocation overhead.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Data<'a> {
name: &'a str,
value: i32,
}
let yaml = "name: hello\nvalue: 42\n";
let data: Data = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(data.name, "hello");
Reader-based entry points (from_reader, from_reader_with_options,
read, and read_with_options) accept BOM-marked UTF-8, UTF-16LE, and
UTF-16BE. If no recognized BOM is present, reader input is treated as UTF-8. String- and slice-based entry
points take UTF-8 only.
serde-saphyr supports merge keys, which reduce redundancy and verbosity by specifying shared key-value pairs once and then reusing them across multiple mappings. Here is an example with merge keys (inherited properties):
use serde::Deserialize;
/// Configuration to parse into. Does not include "defaults"
#[derive(Debug, Deserialize, PartialEq)]
struct Config {
development: Connection,
production: Connection,
}
#[derive(Debug, Deserialize, PartialEq)]
struct Connection {
adapter: String,
host: String,
database: String,
}
fn main() {
let yaml_input = r#"
defaults: &defaults # Here we define "default configuration"
adapter: postgres
host: localhost
development:
<<: *defaults
database: dev_db
production:
<<: *defaults
database: prod_db
"#;
// Deserialize YAML with anchors, aliases and merge keys into the Config struct
let parsed: Config = serde_saphyr::from_str(yaml_input).expect("Failed to deserialize YAML");
// Define expected Config structure explicitly
let expected = Config {
development: Connection {
adapter: "postgres".into(),
host: "localhost".into(),
database: "dev_db".into(),
},
production: Connection {
adapter: "postgres".into(),
host: "localhost".into(),
database: "prod_db".into(),
},
};
// Assert parsed config matches expected
assert_eq!(parsed, expected);
}
Merge keys are standard in YAML 1.1. Although YAML 1.2 no longer includes merge keys in its specification, it doesn't explicitly disallow them either, and many parsers implement this feature.
Merge-key handling is configurable with the merge_keys option. The default
MergeKeyPolicy::Merge expands both implicitly resolved << entries and explicit
YAML 1.1 !!merge << entries. The verbatim !<tag:yaml.org,2002:merge> form and
equivalent %TAG handles are also recognized. Use MergeKeyPolicy::AsOrdinary
to accept these as regular mapping keys, or MergeKeyPolicy::Error to reject them.
The YAML 1.1 !!value tag is recognized but intentionally has no special default-value
behavior. Its scalar content is deserialized normally, and a tagged = mapping key remains
an ordinary "=" key. With reject_unsupported_tags: true, this tag is accepted only on that
exact scalar mapping key. The same strict-mode context check limits !!merge to a scalar <<
mapping key.
serde-saphyr can capture tags. Applications can use custom tags to express units, priorities, accessibility and the like.
The Tagged<T>
wrapper stores a value and the resolved YAML tag attached to its node. The tag is represented as an
Option<String>. An empty string is not a valid tag value; use None instead.
Tag handles are resolved while parsing. For example, !!str becomes
tag:yaml.org,2002:str, while the local tag !nanoseconds remains !nanoseconds. Given:
%TAG !css! tag:app.styles,2026:
---
font: !css!important bold
When the font value is deserialized as Tagged<String>, the captured tag is
Some("tag:app.styles,2026:important").
Serialization round-trips the resolved tag identity, but may normalize its source spelling.
When constructing Tagged<T> directly, a tag beginning with ! is local; every other tag identity
must have valid absolute URI syntax. Characters requiring URI escaping are percent-encoded on
output.
By default, unknown application-specific YAML tags remain available for tagged-enum handling and
are otherwise ignored where possible. Set reject_unsupported_tags: true in Options to reject any
explicit tag that serde-saphyr does not recognize. This strict mode also rejects custom tags used to
select enum variants. YAML 1.1 !!merge and !!value tags remain accepted only on their exact
scalar mapping keys, << and = respectively; using either tag on a value, a collection, or any
other scalar is rejected in strict mode. Known scalar, sequence, and mapping tags are likewise
accepted only on matching node kinds, even when reject_unsupported_tags is false. Robotics-only
!degrees and !radians tags are accepted in strict mode only when the robotics crate feature and
angle_conversions: true are both enabled.
Likewise, in strict mode, !include is accepted only when the include crate feature is enabled
and an include resolver is configured. Tag capture does not bypass normal YAML tag semantics or
the reject_unsupported_tags option.
Tagged enums written as !!EnumName VARIANT are also supported, but only for single-level scalar variants. Use mapping-based representations (EnumName: RED) if you need to embed enums within other enums.
As granit-parser now supports comments, the wrapper Commented will also capture the relevant YAML comment into its field when deserializing YAML.
Comment capture is enabled by default. Set emit_comments: false in Options to recognize and validate YAML comments without retaining their text or emitting parser comment events. In this mode, deserialized Commented<T> values have an empty comment string. Comment bytes are still consumed and validated, so this is not an input-size or processing-time limit.
Budget enforcement and reporting then treat comments as unretained data:
Budget::max_total_comment_bytes is not enforced;Budget::max_buffered_comment_events has no effect;Budget::max_events or BudgetReport::events; andBudgetReport::total_comment_bytes remains 0.During serialization, Commented also emits a comment next to a scalar or reference (handy when the reference is far from its definition and needs explanation).
For container values, a comment attached to the parent value itself, such as root: # comment, is captured only by Commented<Container> and is not inherited by the first child. A comment inside the container, directly above a child key or sequence item, is captured by that child.
Comments are not copied from anchor definitions through aliases or merge keys. In actual: { <<: *defaults }, a Commented field materialized from &defaults will not receive a comment that was written at the definition site above defaults.port; that comment belongs to the original field.
For aliases to containers used as nested values, leading comments above the alias follow the same rule as comments inside a direct nested container. In root:\n # comment\n *defaults, the comment remains available to the expanded container's first child rather than being captured as a comment on the alias use itself.
See example commented.rs.
Many configuration formats contain secret values that should not live in checked-in YAML or leak into error snippets.
The optional properties feature adds docker-compose-style ${NAME} interpolation for that use case, with values supplied through Options.
It is also useful for generated values or values that change between releases or deployments.
Interpolation is intentionally narrow:
$NAME form is opt-in (see below) so a bare $NAME stays a literal by default,$${NAME} escapes to a literal ${NAME},default/replacement/error text supports nested braced references, subject to the configured budget,${...} form remains unchanged.| Form | NAME unset | NAME set to empty | NAME set to non-empty |
|---|---|---|---|
${NAME} | error | "" | the value |
${NAME-default} | default | "" | the value |
${NAME:-default} | default | default | the value |
${NAME+replacement} | "" | replacement | replacement |
${NAME:+replacement} | "" | "" | replacement |
${NAME?error} | error (with error as hint) | "" | the value |
${NAME:?error} | error (with error as hint) | error (with error as hint) | the value |
default, replacement, and error are source text from the YAML and are not treated as secret.
Selected operator text can contain nested braced references, for example
${PRIMARY:-${FALLBACK:-default}}.
The error hint may be empty (${NAME?} / ${NAME:?}), matching docker-compose.
properties is gated behind the properties feature flag.
Once enabled, pass a property map through Options::with_properties(...):
use serde::Deserialize;
#[cfg(feature = "properties")]
#[derive(Debug, PartialEq, Eq, Deserialize)]
struct Config {
database_url: String,
mode: String,
}
#[cfg(feature = "properties")]
fn property_map() -> Result<Config, serde_saphyr::Error> {
use serde_saphyr::{options, from_str_with_options};
use std::collections::HashMap;
let mut properties = HashMap::new();
properties.insert(
"DATABASE_URL".to_string(),
"postgres://db.example/app".to_string(),
);
properties.insert("MODE".to_string(), "production".to_string());
let options = options! {
budget: serde_saphyr::budget! {
max_property_expansion_depth: 16,
max_total_property_interpolation_work: 1_048_576,
},
}
.with_properties(properties);
let yaml = r#"
database_url: ${DATABASE_URL}
mode: ${MODE}
"#;
let parsed: Config = from_str_with_options(yaml, options)?;
Ok(parsed)
}
#[cfg(feature = "properties")]
fn main() {
let parsed = property_map().unwrap();
assert_eq!(
parsed,
Config {
database_url: "postgres://db.example/app".to_string(),
mode: "production".to_string(),
}
);
}
# #[cfg(not(feature = "properties"))]
# fn main() {}
Property expansion limits are configured through Budget. Exceeding either limit returns
Error::Budget with a BudgetBreach::PropertyExpansionDepth or
BudgetBreach::PropertyInterpolationWork value. Setting Options::budget to None disables
these limits together with the rest of budget enforcement.
Set property_syntax: PropertySyntax::BracedOrBare to also accept the unbraced $NAME shorthand.
It uses the same Required semantics as ${NAME}, including "$$NAME" being a literal "$NAME".
Name boundaries are greedy.
$NAMEfoo looks up NAMEfoo, so write ${NAME}foo instead when you need to concatenate.
Unset names produce an error.
Modifiers stay brace-only:
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct Config {
db: String,
}
#[cfg(feature = "properties")]
fn main() -> Result<(), serde_saphyr::Error> {
use serde_saphyr::{PropertySyntax, options, from_str_with_options};
use std::collections::HashMap;
let mut properties = HashMap::from([
("DATABASE_URL".to_string(), "postgres://db.example/app".to_string()),
]);
let opts = options! { property_syntax: PropertySyntax::BracedOrBare }
.with_properties(properties);
let parsed: Config = from_str_with_options("db: $DATABASE_URL\n", opts)?;
let expected = Config { db: "postgres://db.example/app".to_string() };
assert_eq!(expected, parsed);
Ok(())
}
# #[cfg(not(feature = "properties"))]
# fn main() {}
A bare ${NAME} with no value in the map (and no -/:- default), a ${NAME?msg} / ${NAME:?msg} that triggers its error condition, or a malformed ${...} candidate (invalid name, unsupported modifier), fails deserialization with a dedicated error pointing at the YAML source location.
Configuration mistakes fail closed rather than silently producing partial values.
When the property values are secrets, interpolation resolves the final value before Serde finishes deserializing the surrounding type, so a downstream custom deserializer or validation path could otherwise echo the resolved secret.
serde-saphyr tracks interpolated values and redacts them back to their ${...} form in later error messages.
Treat the property map itself as sensitive - do not log or format it directly.
The need for including YAML (not part of the official specs) can be seen from the popularity of the command-line yaml-include crate. That crate is very feature-complete. However, if the YAML parser and validator are separate from the pre-processor, they usually only report the line number and snippet in the processed document. For large documents with multiple and deep includes, this becomes challenging to interpret. YAML indentation and security requirements like path confinement or anchor isolation make "quick adding" of includes non-trivial.
serde-saphyr allows resolving !include tags via a custom resolver configured in Options. When using a single !include directly as a value, it works naturally for replacing a scalar, sequence, or an entire mapping:
# Replacing the entire mapping value
my_mapping: !include my_mapping.yaml
# Supplying a list/sequence value
my_list: !include my_list.yaml
However, if you want to include a mapping and merge its keys into a parent mapping alongside other keys, you must use the merge key (<<). Attempting to list !include inside a mapping without a merge key is invalid YAML syntax:
# INVALID: `!include` is treated as a key missing a value (`:`)
a: 1
!include my_mapping.yaml
b: 2
Instead, use the merge key to correctly inject the included mapping:
# VALID: merges the contents of my_mapping.yaml
a: 1
<<: !include my_mapping.yaml
b: 2
!include is gated behind the include feature flag. If it is not enabled, or the resolver is not set, this tag has no special treatment; with reject_unsupported_tags: true, it is rejected as unsupported. The include feature allows resolvers that do not access the filesystem. For the most common case, where files are included from the filesystem, include_fs must be enabled as well. Then the most common way to enable includes looks like this:
use serde::Deserialize;
use serde_saphyr::{from_str_with_options, options};
#[derive(Debug, Deserialize)]
struct Config {
selected_users: Vec<User>,
}
#[derive(Debug, Deserialize)]
struct User {
name: String,
}
# #[cfg(feature = "include_fs")]
fn main() {
let yaml = "selected_users: !include#users value.yaml\n";
let options = options! {}
.with_filesystem_root("examples")
.expect("failed to create filesystem include resolver");
let config: Config = from_str_with_options(yaml, options)
.expect("failed to parse filesystem include example");
assert_eq!(config.selected_users[0].name, "Alice");
}
# #[cfg(not(feature = "include_fs"))]
# fn main() {}
You can alternatively use SafeFileResolver to configure more options, or provide your own IncludeResolver callback that resolves a name into YAML text, which can be useful for custom storage backends or generated YAML without using the filesystem.
Instead of including the whole document, you can also include only the value of a specific anchor defined in the included YAML document:
!include my_mapping.yaml#anchor_name
SafeFileResolver has a built-in capability for anchor extraction. For flexibility, custom IncludeResolver implementations must do this on their own, splitting anchor from the reference and then returning InputSource::AnchoredText.
Unless otherwise stated, the anchor scope is restricted to the document where it is defined. Overriding a parent anchor value somewhere deep inside included content would be challenging to debug and could even become a security issue.
Whole-document includes only support sources that contain a single YAML document. Fragment includes also require the included source to contain a single YAML document; multi-document sources are rejected instead of scanning across document boundaries. Recursive inclusion is not permitted (and the file, not the fragment, is the include's identity).
To make debugging easier, serde-saphyr renders snippets of the YAML that caused an error (similar to how many compilers report errors). These snippets include the line where the error occurred along with some surrounding context. Any terminal control sequences that might be present in the YAML are stripped out. If not desired, snippets can be removed for a specific error using without_snippet, or disabled entirely via the Options configuration.
This crate optionally integrates with validator or garde to run declarative validation. serde-saphyr error will print the snippet, providing location information. If the invalid value comes from the YAML anchor, serde-saphyr will also tell where this anchor has been defined.
# #[cfg(feature = "garde")]
use garde::Validate;
# #[cfg(feature = "garde")]
use serde::Deserialize;
# #[cfg(feature = "garde")]
#[derive(Debug, Deserialize, Validate)]
#[serde(rename_all = "camelCase")] // Rust in snake_case, YAML in camelCase.
struct AB {
// Just defined here (we validate `second_string` only).
#[garde(skip)]
first_string: String,
#[garde(length(min = 2))]
second_string: String,
}
# #[cfg(feature = "garde")]
fn main() {
let yaml = r#"
firstString: &A "x"
secondString: *A
"#;
let err = serde_saphyr::from_str_valid::<AB>(yaml)
.expect_err("must fail validation");
// Field in error message in camelCase (as in YAML).
eprintln!("{err}");
}
# #[cfg(not(feature = "garde"))]
# fn main() {}
# #[cfg(feature = "validator")]
use serde::Deserialize;
# #[cfg(feature = "validator")]
use validator::Validate;
# #[cfg(feature = "validator")]
#[derive(Debug, Deserialize, Validate)]
#[serde(rename_all = "camelCase")] // Rust in snake_case, YAML in camelCase.
struct AB {
// Just defined here (we validate `second_string` only).
#[allow(dead_code)]
first_string: String,
#[validate(length(min = 2))]
second_string: String,
}
# #[cfg(feature = "validator")]
fn main() {
let yaml = r#"
firstString: &A "x"
secondString: *A
"#;
let err = serde_saphyr::from_str_validate::<AB>(yaml)
.expect_err("must fail validation");
eprintln!("{err}");
}
# #[cfg(not(feature = "validator"))]
# fn main() {}
A typical output with serde-saphyr native snippet rendering looks like:
error: line 3 column 23: invalid here, validation error: length is lower than 2 for `secondString`
--> the value is used here:3:23
|
1 |
2 | firstString: &A "x"
3 | secondString: *A
| ^ invalid here, validation error: length is lower than 2 for `secondString`
4 |
|
| This value comes indirectly from the anchor at line 2 column 25:
|
1 |
2 | firstString: &A "x"
| ^ defined here
3 | secondString: *A
4 |
The integration of garde is feature-gated and disabled by default. Use serde-saphyr = { version = "1", features = ["garde"] } (or features = ["validator"]) in Cargo.toml to enable it.
If you prefer to validate without validation crates and want to ensure that location information is always available, use the heavier approach with Spanned<T> wrapper instead.
The default error messages are developer-oriented. They may mention serde-saphyr APIs and
options and include “action items” intended to help fix the problem.
If error messages are shown to end users, switch to the built-in user-facing formatter or provide your own formatter (for example, to translate messages into another language).
See:
MessageFormatter — controls the main message text for each Error.Localizer — controls message pieces that are composed outside MessageFormatter::format_message (location suffixes, validation/snippet labels, etc.).use serde_saphyr::UserMessageFormatter;
# let err = serde_saphyr::from_str::<String>("").unwrap_err();
println!("\n[User Error]:\n{}", err.render_with_formatter(&UserMessageFormatter));
mietteIf you want fancy diagnostics via miette, you can convert a serde-saphyr error to a
miette::Report while still controlling the message text via a custom formatter:
use serde_saphyr::{MessageFormatter, UserMessageFormatter};
# #[cfg(feature = "miette")]
fn main() {
let yaml = "not_a_bool\n";
let opts = serde_saphyr::options! { with_snippet: false };
let err = serde_saphyr::from_str_with_options::<bool>(yaml, opts)
.expect_err("bool parse error expected");
// You can plug in `&UserMessageFormatter` or your own `&dyn MessageFormatter`.
let formatter: &dyn MessageFormatter = &UserMessageFormatter;
let report = serde_saphyr::miette::to_miette_report_with_formatter(
&err,
yaml,
"config.yaml",
formatter,
);
eprintln!("{report:?}");
}
# #[cfg(not(feature = "miette"))]
# fn main() {}
This requires enabling the crate’s miette feature.
For a complete custom formatter/localizer example, see examples/pirate_formatter.rs. For an
end-to-end miette example, see examples/miette.rs.
Both figment and figment2 are supported as optional features (see examples/figment_yaml).
use serde::Serialize;
#[derive(Serialize)]
struct User { name: String, active: bool }
let yaml = serde_saphyr::to_string(&User { name: "Ada".into(), active: true }).unwrap();
assert!(yaml.contains("name: Ada"));
Serde-saphyr can conceptually connect YAML anchors with Rust shared references (Rc, Weak and Arc). You need to use wrappers to activate this feature:
RcAnchor<T> and ArcAnchor<T> emit anchors like &a1 on first occurrence and may emit aliases *a1 later.RcWeakAnchor<T> and ArcWeakAnchor<T> serialize a weak ref: if the strong pointer is gone, it becomes null.use serde::{Deserialize, Serialize};
use serde_saphyr::RcAnchor;
use std::rc::Rc;
#[derive(Debug, Deserialize, Serialize)]
struct Node {
name: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct Document {
primary: RcAnchor<Node>,
alias: RcAnchor<Node>,
}
fn main() {
let shared = Rc::new(Node {
name: "shared node".to_string(),
});
let document = Document {
primary: RcAnchor::from(shared.clone()),
alias: RcAnchor::from(shared),
};
let yaml = serde_saphyr::to_string(&document).expect("serialize anchors");
assert!(yaml.contains("&a1"));
assert!(yaml.contains("*a1"));
let deserialized: Document =
serde_saphyr::from_str(&yaml).expect("deserialize anchors");
assert!(Rc::ptr_eq(
&deserialized.primary.0,
&deserialized.alias.0,
));
}
When anchors are highly repetitive and also large, packing them into references can make YAML more human-readable.
To support round-tripping, the library can also deserialize into these anchor structures; this deserialization is identity-preserving. A field or structure that is defined once and subsequently referenced will exist as a single instance in memory, with all anchor fields pointing to it. This is crucial when the topology of references itself constitutes important information to be transferred.
While recursive YAML is unusual, it is not forbidden by the specification. Real-world examples and requests to implement it exist.
Serde-saphyr supports recursive structures, but Rust requires being very explicit about this. A structure that may hold recursive references to itself must be wrapped in a RcRecursive<T>, and any reference that points to it must be RcRecursion<T>. Arc varieties exist. See also examples/recursive_yaml.rs.
:).y, yes, on, etc.) are normally quoted as both keys and values. If this is undesired (y is a coordinate), set yaml_12 to true.These settings can be changed in SerializerOptions.
The feature-gated "robotics" capability enables parsing of YAML extensions commonly used in robotics (ROS). These extensions support conversion functions (deg, rad) and simple mathematical expressions such as deg(180), rad(pi), 1 + 2*(3 - 4/5), or rad(pi/2). This capability is gated behind the robotics feature and is not enabled by default. Additionally, angle_conversions must be set to true in the Options. Just adding the robotics feature is not enough to activate this mode of parsing. This parser is still just a simple expression calculator implemented directly in Rust, not some hook into a language interpreter.
rad_tag: !radians 0.15 # value in radians, stays in radians
deg_tag: !degrees 180 # value in degrees, converts to radians
expr_complex: 1 + 2*(3 - 4/5) # simple expressions supported
func_deg: deg(180) # value in degrees, converts to radians
func_rad: rad(pi) # value in radians (stays in radians)
hh_mm_secs: -0:30:30.5 # Time
longitude: !radians 8:32:53.2 # Nautical, ETH Zürich Main Building (8°32′53.2″ E)
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct RoboFloats {
func_deg: f64,
func_rad: f64,
}
# #[cfg(feature = "robotics")]
fn main() {
let yaml = "func_deg: deg(180)\nfunc_rad: rad(pi)\n";
let options = serde_saphyr::options! {
angle_conversions: true,
};
let v: RoboFloats = serde_saphyr::from_str_with_options(yaml, options)
.expect("parse robotics YAML");
assert!((v.func_deg - std::f64::consts::PI).abs() < 1e-12);
assert!((v.func_rad - std::f64::consts::PI).abs() < 1e-12);
}
# #[cfg(not(feature = "robotics"))]
# fn main() {}
Safety hardening measures with this feature enabled include limits on maximal expression depth, maximal number of digits, strict underscore placement, and fraction parsing limits to the precision-relevant digit.
Spanned<T> cannot be used within variants of untagged or internally tagged enums due to a fundamental limitation in Serde. Instead, wrap the entire enum in Spanned<T>, or use externally tagged enums (the default).#[serde(flatten)] directive (this is Serde limitation we can't work around). Deserialization succeeds, but full strong-pointer identity may be lost for nested anchors inside flattened payloads."hello world" can be borrowed, but "hello\nworld" cannot because \n is transformed to a newline). For maximum flexibility, use Cow<'a, str> which borrows when possible and owns when transformation is required.from_reader) require DeserializeOwned and cannot return borrowed values.serde-saphyr does not capture freestanding comments, not obviously attached to any node (separated by multiple empty lines, or at the end of the document). Use granit-parser directly to capture such comments (serde-saphyr re-exports it).Let's all donate strategically to David Tolnay. Without his serde, serde-saphyr would not make any sense. If all donations go to a single person, this is more likely to make a difference. We are not affiliated with him.
Rust
100.0%
A strongly typed, comment-supporting YAML deserializer that deserializes YAML directly into your Rust types without constructing an intermediate tree of “abstract values.”
Rust
221
833 commits
updated Sep 16, 2026
serde-saphyr is a strongly typed YAML deserializer built on top of granit-parser.
The parser is fuzz-tested and designed not to panic on malformed YAML. This design does not cover out-of-memory conditions,
panics in user-provided callbacks, or similar cases. The library build is configured to deny unsafe code. This does
not extend to transitive dependencies.
The crate deserializes YAML directly into your Rust types without constructing an intermediate tree of “abstract values.” Try it online as a WebAssembly application here.
See release history on GitHub.
serde-saphyr avoids the typical YAML remote code execution vulnerability because it does not support or implement tag-driven object construction. When used for linting, it can be configured to reject unknown tags.Budget.Tagged<T> wrapper captures and emits a node's resolved YAML tag.Commented<T> both captures and emits comments.validator (example) or garde (example).miette (example) integration for more advanced error reporting.serde-saphyr is compatible with WebAssembly. The CI flow includes builds for both wasm32-unknown-unknown (browser / JS) and wasm32-wasip1 (WASI runtimes), with most of the test suite running and passing (excluding tests that require file access or similarly unsupported functionality). We also wrote yva in Dioxus to deploy serde-saphyr on the web.
The test suite currently includes over 3000 passing tests. For YAML Test Suite v2022-01-17, all 350 active test IDs and all 402 active cases from the data-2022-01-17 release are represented (9C9N is intentionally relaxed to keep the library compatible with PyYAML and ruamel.yaml). Although we made a reasonable effort, accidental omissions or conversion errors remain possible. Some additional cases are taken from the original serde-yaml tests.
serde-saphyr is not a fork of the older serde-yaml crate and shares no code with it (apart from some reused tests). It is also not part of the saphyr project. The name was historically chosen to reflect the use of saphyr parser at a time when the Saphyr project did not provide its own Serde integration. granit-parser it's currently using is the fork of Saphyr parser.
serde-saphyr requires Rust 1.89 or newer. This minimum supported Rust version (MSRV) is tested in CI.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Config {
name: String,
enabled: bool,
retries: i32,
}
fn main() {
let yaml_input = r#"
name: "My Application"
enabled: true
retries: 5
...
"#;
let config: Result<Config, _> = serde_saphyr::from_str(yaml_input);
match config {
Ok(parsed_config) => {
println!("Parsed successfully: {:?}", parsed_config);
}
Err(e) => {
eprintln!("Failed to parse YAML: {}", e);
}
}
}
To speed up compilation, you can link only the deserializer or only the serializer (along with their respective dependencies). For easier initial integration, both serialize and deserialize features are enabled by default.
If you only need one side, you can disable default features and enable only the API surface you use:
serde-saphyr = { version = "1", default-features = false, features = ["deserialize"] }
or
serde-saphyr = { version = "1", default-features = false, features = ["serialize"] }
Disabling both will produce a "Invalid feature configuration" error (such configuration makes no sense).
The optional huge_documents feature switches span storage from u32 indices to a packed 48-bit internal representation so spans can cover YAML inputs far beyond 4 GiB without widening every coordinate to a full u64. Public getters still return u64, and values beyond the packed range saturate instead of wrapping.
Version 1.0 removes the APIs that were deprecated during the 0.0.x series and makes a few intentional naming and extensibility changes:
to_writer and to_writer_with_options functions with to_fmt_writer* for std::fmt::Write targets or to_io_writer* for std::io::Write targets.options!, budget!, ser_options!, alias_limits!, and render_options!. Configuration and public request/result structs are non-exhaustive so fields can be added compatibly; use constructors such as ResolvedInclude::new when returning include content. Fields themselves are no longer deprecated.ExternalMessageSource::Parser now carries the parser's ScanError, and the source field of Error::ExternalMessage is boxed. Prefer .. when matching fields you do not need.RcWeakAnchor::from(&rc); consuming a strong pointer no longer creates a weak anchor that immediately dangles.with_indent and with_options constructors validate settings and return Result; options are passed by value. Serializer::new follows SerializerOptions::default, including compact list indentation. SerializerOptions is Clone, but intentionally not Copy.read now returns impl Iterator, matching the other streaming entry points and avoiding an allocation. Code that explicitly required a boxed iterator can wrap it with Box::new.DefaultMessageFormatterWithLocalizer and UserMessageFormatterWithLocalizer are no longer public, and serializer helper types such as TupleSer are now opaque implementation details. Use the returned impl MessageFormatter values or serde::Serializer associated types instead. When both serialization and deserialization are enabled, the new root aliases SerializeError and DeserializeError distinguish their error types.serde-saphyr comes with a simple executable (CLI) that can be used to check the budget of a given YAML file, and can also be used as a YAML validator, printing the YAML error line, column numbers, and excerpt.
The CLI includes filesystem-backed !include support, so it must be built with the
include_fs feature. To install and run it (no Rust knowledge required):
cargo install serde-saphyr --features include_fs
# binary name is the package name by default
serde-saphyr path/to/file.yaml
To enable fancy error reporting (graphical diagnostics) via the optional miette integration, install/build the CLI with the miette feature enabled:
# install with miette enabled
cargo install serde-saphyr --features miette,include_fs
# or run from a git checkout
cargo run --features miette,include_fs -- path/to/file.yaml
If you want to keep the previous plain-text error output even when built with miette, pass --plain:
serde-saphyr --plain path/to/file.yaml
If you want to allow file inclusion (!include tags) during parsing, configure the filesystem root path using --include:
serde-saphyr --include path/to/root path/to/file.yaml
Serde-saphyr provides control over serialization and deserialization behavior. We generally welcome feature requests, but we also recognize that not every user wants every feature enabled by default.
To support different use cases, most behavior can be enabled, disabled, or tuned via Options (deserializers) and SerializerOptions (serializers). Serde-saphyr uses a macro-driven approach based on the options!, budget!, and ser_options! macros.
use serde_saphyr::DuplicateKeyPolicy;
fn main() {
let options = serde_saphyr::options! {
budget: serde_saphyr::budget! {
max_documents: 2,
},
duplicate_keys: DuplicateKeyPolicy::LastWins,
};
}
Struct literals cannot be used because option structures are non-exhaustive (to allow new fields without an API-breaking change).
Fuzzing shows that certain adversarial inputs can make YAML parsers consume excessive time or memory, enabling denial-of-service scenarios. To counter this, serde-saphyr offers a configurable Budget, available through Options. It accounts for parser events, retained copies used to replay anchors, and property-interpolation depth and work. Defaults are intentionally quite permissive; tighten them when you know your input shape, or disable the budget if you only parse YAML you generate yourself.
During reader-based deserialization, serde-saphyr does not buffer the entire payload; it parses incrementally, counting bytes and enforcing configured budgets.
Reader-based APIs enforce configured byte and structural limits while reading. When streaming from the reader through the iterator, other budget limits apply on a per-document basis, since such a reader may be expected to stream indefinitely. The total size of the input is not limited in this case.
To find the typical budget requirements for your file, use our web demo or run the main() executable of this library, providing a YAML file path as a program parameter. You can also fetch the budget programmatically by registering a closure with Options::with_budget_report.
Adding or removing a single space in YAML indentation may result in a document that is still syntactically correct but semantically wrong. To mitigate such issues, serde-saphyr can enforce indentation rules during deserialization via RequireIndent.
You can require the number of indentation columns to be consistent throughout the document, ensure it is even, or enforce that it is divisible by a specific number (for example, 4 or 6). Configure the desired policy using Options.
Duplicate key handling is configurable. By default it’s an error; “first wins” and “last wins” strategies are available via Options. The duplicate key policy applies not just to strings but also to other types (if used as keys when deserializing into a map).
YAML integer keys are parsed to their numeric meaning before checking for duplicates, regardless of the target Rust type. For example, 0xB and 11 are the same integer key, even in a HashMap<String, _>. This comparison uses exact integer values within the supported i128/u128 range and respects legacy_octal_numbers; values outside that range retain text-based comparison. The same rule applies inside composite keys and when resolving merge keys.
Deserialization into a string still preserves the original scalar spelling: 0xB becomes "0xB". “First wins” and “last wins” retain the selected entry's spelling and value; when integer keys require numeric comparison, “last wins” buffers the remaining mapping to select entries before passing them to Serde. Quoted keys and keys tagged !!str remain strings, so "0xB" and "11" are distinct keys. Duplicate checking uses YAML key identity; the target Rust map can still combine distinct YAML keys if they become equal after deserialization.
Buffered “last wins” entries use the same replay mechanism as struct fields: trailing comments and comments nested inside buffered values are not preserved, and nested Spanned::referenced locations use the buffered value's reference location. The default error policy and “first wins” do not require this additional buffering.
By default, if the target field is boolean, serde-saphyr will attempt to interpret standard YAML 1.1 values as boolean (not just false but also no, etc.).
If you do not want this (or if you are parsing into a JSON Value where it might be incorrectly inferred), enclose the value in quotes or set strict_booleans to true in Options.
To address the “Norway problem,” the target Rust types serve as an explicit schema. Because the parser knows whether a field expects a string or a boolean, it can correctly accept 1.2 either as a number or as the string "1.2", and interpret the common YAML boolean shorthands (y, on, n, off) as actual booleans when appropriate (can be disabled). Likewise, 0x2A is parsed as a hexadecimal integer when the target field is numeric, and as a string when the target is String. As with StrictYAML, serde-saphyr uses the Rust type system as the schema for typed deserialization. Integer key comparison is an exception: duplicate checking uses the integer's numeric meaning while preserving its original spelling for string targets.
Schema-based parsing can be disabled by setting no_schema to true in Options. In this case all unquoted values that are parsed into strings, but can be understood as something else, are rejected. This can be used for enforcing compatibility with another YAML parser that reads the same content and requires this quoting. Default setting is false.
Legacy octal notation such as 0052 can be enabled via Options, but it is disabled by default.
The concept that “Rust code is the schema” naturally extends to implemented support for validator and garde, as these crates allow annotations to be added directly to Rust types, providing even stricter control over permissible values.
YAML streams can contain several documents separated by ---/... markers. When deserializing with serde_saphyr::from_multiple, you still need to supply the vector element type up front (Vec<T>). That does not lock you into a single shape: make the element an enum and each document will deserialize into the matching variant. This lets you mix different payloads in one stream while retaining strong typing on the Rust side.
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
enum Document {
#[serde(rename = "person")]
Person { name: String, age: u8 },
#[serde(rename = "pet")]
Pet { kind: String },
}
fn main() {
let input = r#"---
person:
name: Alice
age: 30
---
pet:
kind: cat
---
person:
name: Bob
age: 25
"#;
let docs: Vec<Document> =
serde_saphyr::from_multiple(input).expect("valid YAML stream");
}
Externally tagged enums nest naturally in YAML as maps keyed by the variant name. This enables strict, expressive models (enums with associated data) instead of generic maps.
use serde::Deserialize;
#[derive(Deserialize)]
struct Move {
by: f32,
constraints: Vec<Constraint>,
}
#[derive(Deserialize)]
enum Constraint {
StayWithin { x: f32, y: f32, r: f32 },
MaxSpeed { v: f32 },
}
fn main() {
let yaml = r#"
- by: 10.0
constraints:
- StayWithin:
x: 0.0
y: 0.0
r: 5.0
- StayWithin:
x: 4.0
y: 0.0
r: 5.0
- MaxSpeed:
v: 3.5
"#;
let robot_moves: Vec<Move> = serde_saphyr::from_str(yaml).unwrap();
println!("Parsed {} moves", robot_moves.len());
}
There are two variants of the deserialization functions: from_* and from_*_with_options. The latter accepts an Options object that allows you to configure budget and other aspects of parsing. For larger projects that require consistent parsing behavior, we recommend defining a wrapper function so that all option and budget settings are managed in one place (see examples/wrapper_function.rs).
It is possible to deserialize tuple enum variants:
use serde::Deserialize;
#[derive(Debug, PartialEq, Eq, Deserialize)]
pub enum Value {
Expression(String),
Pair(String, i32),
}
#[derive(Debug, PartialEq, Eq, Deserialize)]
pub struct Context {
value: Value,
}
serde_saphyr::from_str::<Context>(yaml) would take the value: !Expression 1 + 1 or value: !Pair [a, 12]. Both YAML lists and Rust tuples allow their elements to have different types.
To verify support for polymorphism of arbitrary objects, not just enums, serde-saphyr is also tested with typetag.
YAML supports complex (non-string) mapping keys. Rust maps can mirror this, allowing you to parse such structures directly.
use serde::{Deserialize};
use std::collections::HashMap;
#[derive(Debug, PartialEq, Eq, Hash, Deserialize)]
struct Point {
x: i32,
y: i32
}
#[derive(Debug, PartialEq, Deserialize)]
struct Transform {
// Transform between locations
map: HashMap<Point, Point>,
}
fn main() {
let yaml = r#"
map:
{x: 1, y: 2}: {x: 3, y: 4}
{x: 5, y: 6}: {x: 7, y: 8}
"#;
let transform: Transform = serde_saphyr::from_str(yaml).unwrap();
println!("{} entries", transform.map.len());
}
!!binary-tagged YAML values are base64-decoded when deserializing into Vec<u8> or String (reporting an error if they are not valid UTF-8).
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct Blob {
data: Vec<u8>,
}
fn main() {
let blob: Blob = serde_saphyr::from_str("data: !!binary aGVsbG8=").unwrap();
assert_eq!(blob.data, b"hello");
}
Important: some projects add the !!binary tag while actually expecting a verbatim string value (for example, the literal string "aGVsbG8="). This works with parsers that simply ignore the tag. However, serde-saphyr decodes !!binary values by default, attempting to interpret them as UTF-8 bytes.
If you use !!binary only as a documentation or annotation tag, enable ignore_binary_tag_for_string = true in Options.
use serde::Deserialize;
#[derive(Deserialize)]
struct ContainsString {
name: String,
}
fn main() -> Result<(), serde_saphyr::Error> {
let value: ContainsString = serde_saphyr::from_str_with_options(
"name: !!binary H4sIAA==",
serde_saphyr::options! {
ignore_binary_tag_for_string: true
},
)?;
assert_eq!(value.name, "H4sIAA==");
Ok(())
}
!!binary for other types like Vec<u8> will stay supported.
If you must work with abstract types, you can also deserialize YAML into serde_json::Value. Serde will drive the process through deserialize_any because Value does not fix a Rust primitive type ahead of time. You lose the strict type control provided by Rust struct data types. Also, unlike YAML, JSON does not allow composite keys; keys must be strings. Mapping entries are presented to Serde in source order. Whether the target retains that order depends on its implementation.
serde-saphyr supports zero-copy deserialization for string fields when using from_str or from_slice. This allows deserializing into &str fields that borrow directly from the input, avoiding allocation overhead.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Data<'a> {
name: &'a str,
value: i32,
}
let yaml = "name: hello\nvalue: 42\n";
let data: Data = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(data.name, "hello");
Reader-based entry points (from_reader, from_reader_with_options,
read, and read_with_options) accept BOM-marked UTF-8, UTF-16LE, and
UTF-16BE. If no recognized BOM is present, reader input is treated as UTF-8. String- and slice-based entry
points take UTF-8 only.
serde-saphyr supports merge keys, which reduce redundancy and verbosity by specifying shared key-value pairs once and then reusing them across multiple mappings. Here is an example with merge keys (inherited properties):
use serde::Deserialize;
/// Configuration to parse into. Does not include "defaults"
#[derive(Debug, Deserialize, PartialEq)]
struct Config {
development: Connection,
production: Connection,
}
#[derive(Debug, Deserialize, PartialEq)]
struct Connection {
adapter: String,
host: String,
database: String,
}
fn main() {
let yaml_input = r#"
defaults: &defaults # Here we define "default configuration"
adapter: postgres
host: localhost
development:
<<: *defaults
database: dev_db
production:
<<: *defaults
database: prod_db
"#;
// Deserialize YAML with anchors, aliases and merge keys into the Config struct
let parsed: Config = serde_saphyr::from_str(yaml_input).expect("Failed to deserialize YAML");
// Define expected Config structure explicitly
let expected = Config {
development: Connection {
adapter: "postgres".into(),
host: "localhost".into(),
database: "dev_db".into(),
},
production: Connection {
adapter: "postgres".into(),
host: "localhost".into(),
database: "prod_db".into(),
},
};
// Assert parsed config matches expected
assert_eq!(parsed, expected);
}
Merge keys are standard in YAML 1.1. Although YAML 1.2 no longer includes merge keys in its specification, it doesn't explicitly disallow them either, and many parsers implement this feature.
Merge-key handling is configurable with the merge_keys option. The default
MergeKeyPolicy::Merge expands both implicitly resolved << entries and explicit
YAML 1.1 !!merge << entries. The verbatim !<tag:yaml.org,2002:merge> form and
equivalent %TAG handles are also recognized. Use MergeKeyPolicy::AsOrdinary
to accept these as regular mapping keys, or MergeKeyPolicy::Error to reject them.
The YAML 1.1 !!value tag is recognized but intentionally has no special default-value
behavior. Its scalar content is deserialized normally, and a tagged = mapping key remains
an ordinary "=" key. With reject_unsupported_tags: true, this tag is accepted only on that
exact scalar mapping key. The same strict-mode context check limits !!merge to a scalar <<
mapping key.
serde-saphyr can capture tags. Applications can use custom tags to express units, priorities, accessibility and the like.
The Tagged<T>
wrapper stores a value and the resolved YAML tag attached to its node. The tag is represented as an
Option<String>. An empty string is not a valid tag value; use None instead.
Tag handles are resolved while parsing. For example, !!str becomes
tag:yaml.org,2002:str, while the local tag !nanoseconds remains !nanoseconds. Given:
%TAG !css! tag:app.styles,2026:
---
font: !css!important bold
When the font value is deserialized as Tagged<String>, the captured tag is
Some("tag:app.styles,2026:important").
Serialization round-trips the resolved tag identity, but may normalize its source spelling.
When constructing Tagged<T> directly, a tag beginning with ! is local; every other tag identity
must have valid absolute URI syntax. Characters requiring URI escaping are percent-encoded on
output.
By default, unknown application-specific YAML tags remain available for tagged-enum handling and
are otherwise ignored where possible. Set reject_unsupported_tags: true in Options to reject any
explicit tag that serde-saphyr does not recognize. This strict mode also rejects custom tags used to
select enum variants. YAML 1.1 !!merge and !!value tags remain accepted only on their exact
scalar mapping keys, << and = respectively; using either tag on a value, a collection, or any
other scalar is rejected in strict mode. Known scalar, sequence, and mapping tags are likewise
accepted only on matching node kinds, even when reject_unsupported_tags is false. Robotics-only
!degrees and !radians tags are accepted in strict mode only when the robotics crate feature and
angle_conversions: true are both enabled.
Likewise, in strict mode, !include is accepted only when the include crate feature is enabled
and an include resolver is configured. Tag capture does not bypass normal YAML tag semantics or
the reject_unsupported_tags option.
Tagged enums written as !!EnumName VARIANT are also supported, but only for single-level scalar variants. Use mapping-based representations (EnumName: RED) if you need to embed enums within other enums.
As granit-parser now supports comments, the wrapper Commented will also capture the relevant YAML comment into its field when deserializing YAML.
Comment capture is enabled by default. Set emit_comments: false in Options to recognize and validate YAML comments without retaining their text or emitting parser comment events. In this mode, deserialized Commented<T> values have an empty comment string. Comment bytes are still consumed and validated, so this is not an input-size or processing-time limit.
Budget enforcement and reporting then treat comments as unretained data:
Budget::max_total_comment_bytes is not enforced;Budget::max_buffered_comment_events has no effect;Budget::max_events or BudgetReport::events; andBudgetReport::total_comment_bytes remains 0.During serialization, Commented also emits a comment next to a scalar or reference (handy when the reference is far from its definition and needs explanation).
For container values, a comment attached to the parent value itself, such as root: # comment, is captured only by Commented<Container> and is not inherited by the first child. A comment inside the container, directly above a child key or sequence item, is captured by that child.
Comments are not copied from anchor definitions through aliases or merge keys. In actual: { <<: *defaults }, a Commented field materialized from &defaults will not receive a comment that was written at the definition site above defaults.port; that comment belongs to the original field.
For aliases to containers used as nested values, leading comments above the alias follow the same rule as comments inside a direct nested container. In root:\n # comment\n *defaults, the comment remains available to the expanded container's first child rather than being captured as a comment on the alias use itself.
See example commented.rs.
Many configuration formats contain secret values that should not live in checked-in YAML or leak into error snippets.
The optional properties feature adds docker-compose-style ${NAME} interpolation for that use case, with values supplied through Options.
It is also useful for generated values or values that change between releases or deployments.
Interpolation is intentionally narrow:
$NAME form is opt-in (see below) so a bare $NAME stays a literal by default,$${NAME} escapes to a literal ${NAME},default/replacement/error text supports nested braced references, subject to the configured budget,${...} form remains unchanged.| Form | NAME unset | NAME set to empty | NAME set to non-empty |
|---|---|---|---|
${NAME} | error | "" | the value |
${NAME-default} | default | "" | the value |
${NAME:-default} | default | default | the value |
${NAME+replacement} | "" | replacement | replacement |
${NAME:+replacement} | "" | "" | replacement |
${NAME?error} | error (with error as hint) | "" | the value |
${NAME:?error} | error (with error as hint) | error (with error as hint) | the value |
default, replacement, and error are source text from the YAML and are not treated as secret.
Selected operator text can contain nested braced references, for example
${PRIMARY:-${FALLBACK:-default}}.
The error hint may be empty (${NAME?} / ${NAME:?}), matching docker-compose.
properties is gated behind the properties feature flag.
Once enabled, pass a property map through Options::with_properties(...):
use serde::Deserialize;
#[cfg(feature = "properties")]
#[derive(Debug, PartialEq, Eq, Deserialize)]
struct Config {
database_url: String,
mode: String,
}
#[cfg(feature = "properties")]
fn property_map() -> Result<Config, serde_saphyr::Error> {
use serde_saphyr::{options, from_str_with_options};
use std::collections::HashMap;
let mut properties = HashMap::new();
properties.insert(
"DATABASE_URL".to_string(),
"postgres://db.example/app".to_string(),
);
properties.insert("MODE".to_string(), "production".to_string());
let options = options! {
budget: serde_saphyr::budget! {
max_property_expansion_depth: 16,
max_total_property_interpolation_work: 1_048_576,
},
}
.with_properties(properties);
let yaml = r#"
database_url: ${DATABASE_URL}
mode: ${MODE}
"#;
let parsed: Config = from_str_with_options(yaml, options)?;
Ok(parsed)
}
#[cfg(feature = "properties")]
fn main() {
let parsed = property_map().unwrap();
assert_eq!(
parsed,
Config {
database_url: "postgres://db.example/app".to_string(),
mode: "production".to_string(),
}
);
}
# #[cfg(not(feature = "properties"))]
# fn main() {}
Property expansion limits are configured through Budget. Exceeding either limit returns
Error::Budget with a BudgetBreach::PropertyExpansionDepth or
BudgetBreach::PropertyInterpolationWork value. Setting Options::budget to None disables
these limits together with the rest of budget enforcement.
Set property_syntax: PropertySyntax::BracedOrBare to also accept the unbraced $NAME shorthand.
It uses the same Required semantics as ${NAME}, including "$$NAME" being a literal "$NAME".
Name boundaries are greedy.
$NAMEfoo looks up NAMEfoo, so write ${NAME}foo instead when you need to concatenate.
Unset names produce an error.
Modifiers stay brace-only:
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct Config {
db: String,
}
#[cfg(feature = "properties")]
fn main() -> Result<(), serde_saphyr::Error> {
use serde_saphyr::{PropertySyntax, options, from_str_with_options};
use std::collections::HashMap;
let mut properties = HashMap::from([
("DATABASE_URL".to_string(), "postgres://db.example/app".to_string()),
]);
let opts = options! { property_syntax: PropertySyntax::BracedOrBare }
.with_properties(properties);
let parsed: Config = from_str_with_options("db: $DATABASE_URL\n", opts)?;
let expected = Config { db: "postgres://db.example/app".to_string() };
assert_eq!(expected, parsed);
Ok(())
}
# #[cfg(not(feature = "properties"))]
# fn main() {}
A bare ${NAME} with no value in the map (and no -/:- default), a ${NAME?msg} / ${NAME:?msg} that triggers its error condition, or a malformed ${...} candidate (invalid name, unsupported modifier), fails deserialization with a dedicated error pointing at the YAML source location.
Configuration mistakes fail closed rather than silently producing partial values.
When the property values are secrets, interpolation resolves the final value before Serde finishes deserializing the surrounding type, so a downstream custom deserializer or validation path could otherwise echo the resolved secret.
serde-saphyr tracks interpolated values and redacts them back to their ${...} form in later error messages.
Treat the property map itself as sensitive - do not log or format it directly.
The need for including YAML (not part of the official specs) can be seen from the popularity of the command-line yaml-include crate. That crate is very feature-complete. However, if the YAML parser and validator are separate from the pre-processor, they usually only report the line number and snippet in the processed document. For large documents with multiple and deep includes, this becomes challenging to interpret. YAML indentation and security requirements like path confinement or anchor isolation make "quick adding" of includes non-trivial.
serde-saphyr allows resolving !include tags via a custom resolver configured in Options. When using a single !include directly as a value, it works naturally for replacing a scalar, sequence, or an entire mapping:
# Replacing the entire mapping value
my_mapping: !include my_mapping.yaml
# Supplying a list/sequence value
my_list: !include my_list.yaml
However, if you want to include a mapping and merge its keys into a parent mapping alongside other keys, you must use the merge key (<<). Attempting to list !include inside a mapping without a merge key is invalid YAML syntax:
# INVALID: `!include` is treated as a key missing a value (`:`)
a: 1
!include my_mapping.yaml
b: 2
Instead, use the merge key to correctly inject the included mapping:
# VALID: merges the contents of my_mapping.yaml
a: 1
<<: !include my_mapping.yaml
b: 2
!include is gated behind the include feature flag. If it is not enabled, or the resolver is not set, this tag has no special treatment; with reject_unsupported_tags: true, it is rejected as unsupported. The include feature allows resolvers that do not access the filesystem. For the most common case, where files are included from the filesystem, include_fs must be enabled as well. Then the most common way to enable includes looks like this:
use serde::Deserialize;
use serde_saphyr::{from_str_with_options, options};
#[derive(Debug, Deserialize)]
struct Config {
selected_users: Vec<User>,
}
#[derive(Debug, Deserialize)]
struct User {
name: String,
}
# #[cfg(feature = "include_fs")]
fn main() {
let yaml = "selected_users: !include#users value.yaml\n";
let options = options! {}
.with_filesystem_root("examples")
.expect("failed to create filesystem include resolver");
let config: Config = from_str_with_options(yaml, options)
.expect("failed to parse filesystem include example");
assert_eq!(config.selected_users[0].name, "Alice");
}
# #[cfg(not(feature = "include_fs"))]
# fn main() {}
You can alternatively use SafeFileResolver to configure more options, or provide your own IncludeResolver callback that resolves a name into YAML text, which can be useful for custom storage backends or generated YAML without using the filesystem.
Instead of including the whole document, you can also include only the value of a specific anchor defined in the included YAML document:
!include my_mapping.yaml#anchor_name
SafeFileResolver has a built-in capability for anchor extraction. For flexibility, custom IncludeResolver implementations must do this on their own, splitting anchor from the reference and then returning InputSource::AnchoredText.
Unless otherwise stated, the anchor scope is restricted to the document where it is defined. Overriding a parent anchor value somewhere deep inside included content would be challenging to debug and could even become a security issue.
Whole-document includes only support sources that contain a single YAML document. Fragment includes also require the included source to contain a single YAML document; multi-document sources are rejected instead of scanning across document boundaries. Recursive inclusion is not permitted (and the file, not the fragment, is the include's identity).
To make debugging easier, serde-saphyr renders snippets of the YAML that caused an error (similar to how many compilers report errors). These snippets include the line where the error occurred along with some surrounding context. Any terminal control sequences that might be present in the YAML are stripped out. If not desired, snippets can be removed for a specific error using without_snippet, or disabled entirely via the Options configuration.
This crate optionally integrates with validator or garde to run declarative validation. serde-saphyr error will print the snippet, providing location information. If the invalid value comes from the YAML anchor, serde-saphyr will also tell where this anchor has been defined.
# #[cfg(feature = "garde")]
use garde::Validate;
# #[cfg(feature = "garde")]
use serde::Deserialize;
# #[cfg(feature = "garde")]
#[derive(Debug, Deserialize, Validate)]
#[serde(rename_all = "camelCase")] // Rust in snake_case, YAML in camelCase.
struct AB {
// Just defined here (we validate `second_string` only).
#[garde(skip)]
first_string: String,
#[garde(length(min = 2))]
second_string: String,
}
# #[cfg(feature = "garde")]
fn main() {
let yaml = r#"
firstString: &A "x"
secondString: *A
"#;
let err = serde_saphyr::from_str_valid::<AB>(yaml)
.expect_err("must fail validation");
// Field in error message in camelCase (as in YAML).
eprintln!("{err}");
}
# #[cfg(not(feature = "garde"))]
# fn main() {}
# #[cfg(feature = "validator")]
use serde::Deserialize;
# #[cfg(feature = "validator")]
use validator::Validate;
# #[cfg(feature = "validator")]
#[derive(Debug, Deserialize, Validate)]
#[serde(rename_all = "camelCase")] // Rust in snake_case, YAML in camelCase.
struct AB {
// Just defined here (we validate `second_string` only).
#[allow(dead_code)]
first_string: String,
#[validate(length(min = 2))]
second_string: String,
}
# #[cfg(feature = "validator")]
fn main() {
let yaml = r#"
firstString: &A "x"
secondString: *A
"#;
let err = serde_saphyr::from_str_validate::<AB>(yaml)
.expect_err("must fail validation");
eprintln!("{err}");
}
# #[cfg(not(feature = "validator"))]
# fn main() {}
A typical output with serde-saphyr native snippet rendering looks like:
error: line 3 column 23: invalid here, validation error: length is lower than 2 for `secondString`
--> the value is used here:3:23
|
1 |
2 | firstString: &A "x"
3 | secondString: *A
| ^ invalid here, validation error: length is lower than 2 for `secondString`
4 |
|
| This value comes indirectly from the anchor at line 2 column 25:
|
1 |
2 | firstString: &A "x"
| ^ defined here
3 | secondString: *A
4 |
The integration of garde is feature-gated and disabled by default. Use serde-saphyr = { version = "1", features = ["garde"] } (or features = ["validator"]) in Cargo.toml to enable it.
If you prefer to validate without validation crates and want to ensure that location information is always available, use the heavier approach with Spanned<T> wrapper instead.
The default error messages are developer-oriented. They may mention serde-saphyr APIs and
options and include “action items” intended to help fix the problem.
If error messages are shown to end users, switch to the built-in user-facing formatter or provide your own formatter (for example, to translate messages into another language).
See:
MessageFormatter — controls the main message text for each Error.Localizer — controls message pieces that are composed outside MessageFormatter::format_message (location suffixes, validation/snippet labels, etc.).use serde_saphyr::UserMessageFormatter;
# let err = serde_saphyr::from_str::<String>("").unwrap_err();
println!("\n[User Error]:\n{}", err.render_with_formatter(&UserMessageFormatter));
mietteIf you want fancy diagnostics via miette, you can convert a serde-saphyr error to a
miette::Report while still controlling the message text via a custom formatter:
use serde_saphyr::{MessageFormatter, UserMessageFormatter};
# #[cfg(feature = "miette")]
fn main() {
let yaml = "not_a_bool\n";
let opts = serde_saphyr::options! { with_snippet: false };
let err = serde_saphyr::from_str_with_options::<bool>(yaml, opts)
.expect_err("bool parse error expected");
// You can plug in `&UserMessageFormatter` or your own `&dyn MessageFormatter`.
let formatter: &dyn MessageFormatter = &UserMessageFormatter;
let report = serde_saphyr::miette::to_miette_report_with_formatter(
&err,
yaml,
"config.yaml",
formatter,
);
eprintln!("{report:?}");
}
# #[cfg(not(feature = "miette"))]
# fn main() {}
This requires enabling the crate’s miette feature.
For a complete custom formatter/localizer example, see examples/pirate_formatter.rs. For an
end-to-end miette example, see examples/miette.rs.
Both figment and figment2 are supported as optional features (see examples/figment_yaml).
use serde::Serialize;
#[derive(Serialize)]
struct User { name: String, active: bool }
let yaml = serde_saphyr::to_string(&User { name: "Ada".into(), active: true }).unwrap();
assert!(yaml.contains("name: Ada"));
Serde-saphyr can conceptually connect YAML anchors with Rust shared references (Rc, Weak and Arc). You need to use wrappers to activate this feature:
RcAnchor<T> and ArcAnchor<T> emit anchors like &a1 on first occurrence and may emit aliases *a1 later.RcWeakAnchor<T> and ArcWeakAnchor<T> serialize a weak ref: if the strong pointer is gone, it becomes null.use serde::{Deserialize, Serialize};
use serde_saphyr::RcAnchor;
use std::rc::Rc;
#[derive(Debug, Deserialize, Serialize)]
struct Node {
name: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct Document {
primary: RcAnchor<Node>,
alias: RcAnchor<Node>,
}
fn main() {
let shared = Rc::new(Node {
name: "shared node".to_string(),
});
let document = Document {
primary: RcAnchor::from(shared.clone()),
alias: RcAnchor::from(shared),
};
let yaml = serde_saphyr::to_string(&document).expect("serialize anchors");
assert!(yaml.contains("&a1"));
assert!(yaml.contains("*a1"));
let deserialized: Document =
serde_saphyr::from_str(&yaml).expect("deserialize anchors");
assert!(Rc::ptr_eq(
&deserialized.primary.0,
&deserialized.alias.0,
));
}
When anchors are highly repetitive and also large, packing them into references can make YAML more human-readable.
To support round-tripping, the library can also deserialize into these anchor structures; this deserialization is identity-preserving. A field or structure that is defined once and subsequently referenced will exist as a single instance in memory, with all anchor fields pointing to it. This is crucial when the topology of references itself constitutes important information to be transferred.
While recursive YAML is unusual, it is not forbidden by the specification. Real-world examples and requests to implement it exist.
Serde-saphyr supports recursive structures, but Rust requires being very explicit about this. A structure that may hold recursive references to itself must be wrapped in a RcRecursive<T>, and any reference that points to it must be RcRecursion<T>. Arc varieties exist. See also examples/recursive_yaml.rs.
:).y, yes, on, etc.) are normally quoted as both keys and values. If this is undesired (y is a coordinate), set yaml_12 to true.These settings can be changed in SerializerOptions.
The feature-gated "robotics" capability enables parsing of YAML extensions commonly used in robotics (ROS). These extensions support conversion functions (deg, rad) and simple mathematical expressions such as deg(180), rad(pi), 1 + 2*(3 - 4/5), or rad(pi/2). This capability is gated behind the robotics feature and is not enabled by default. Additionally, angle_conversions must be set to true in the Options. Just adding the robotics feature is not enough to activate this mode of parsing. This parser is still just a simple expression calculator implemented directly in Rust, not some hook into a language interpreter.
rad_tag: !radians 0.15 # value in radians, stays in radians
deg_tag: !degrees 180 # value in degrees, converts to radians
expr_complex: 1 + 2*(3 - 4/5) # simple expressions supported
func_deg: deg(180) # value in degrees, converts to radians
func_rad: rad(pi) # value in radians (stays in radians)
hh_mm_secs: -0:30:30.5 # Time
longitude: !radians 8:32:53.2 # Nautical, ETH Zürich Main Building (8°32′53.2″ E)
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct RoboFloats {
func_deg: f64,
func_rad: f64,
}
# #[cfg(feature = "robotics")]
fn main() {
let yaml = "func_deg: deg(180)\nfunc_rad: rad(pi)\n";
let options = serde_saphyr::options! {
angle_conversions: true,
};
let v: RoboFloats = serde_saphyr::from_str_with_options(yaml, options)
.expect("parse robotics YAML");
assert!((v.func_deg - std::f64::consts::PI).abs() < 1e-12);
assert!((v.func_rad - std::f64::consts::PI).abs() < 1e-12);
}
# #[cfg(not(feature = "robotics"))]
# fn main() {}
Safety hardening measures with this feature enabled include limits on maximal expression depth, maximal number of digits, strict underscore placement, and fraction parsing limits to the precision-relevant digit.
Spanned<T> cannot be used within variants of untagged or internally tagged enums due to a fundamental limitation in Serde. Instead, wrap the entire enum in Spanned<T>, or use externally tagged enums (the default).#[serde(flatten)] directive (this is Serde limitation we can't work around). Deserialization succeeds, but full strong-pointer identity may be lost for nested anchors inside flattened payloads."hello world" can be borrowed, but "hello\nworld" cannot because \n is transformed to a newline). For maximum flexibility, use Cow<'a, str> which borrows when possible and owns when transformation is required.from_reader) require DeserializeOwned and cannot return borrowed values.serde-saphyr does not capture freestanding comments, not obviously attached to any node (separated by multiple empty lines, or at the end of the document). Use granit-parser directly to capture such comments (serde-saphyr re-exports it).Let's all donate strategically to David Tolnay. Without his serde, serde-saphyr would not make any sense. If all donations go to a single person, this is more likely to make a difference. We are not affiliated with him.
Rust
100.0%