Introduction
omnist is one canonical data model for JSON, YAML, TOML, XML, and its
own native OML, plus a schema language (OSD) to validate and compare
shapes over that model. This book is the Rust port’s documentation
(the crate is omnist, CLI is omnist-cli).
Start with the Quickstart, then read the
user guide for the full model. The CLI reference
covers the omnist binary, and each entry under Formats
documents one codec’s specific round-trip behavior.
This is the Rust port of omnist. See Python divergences for where the two implementations differ, Conformance against omnist-spec for this port’s real, measured pass/fail/ skip numbers against the upstream spec, and Limitations & stability for the current alpha-status caveats.
Quickstart
[dependencies]
omnist = "0.12.0-alpha"
The shortest possible tour – one round trip, one schema, one validation,
one inference. Each snippet below is a trimmed version of a real file under
omnist/examples/; run it yourself with
cargo run --example <name>.
1. Read and write a document
#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::json::{read_json, write_json};
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();
let text = write_json(&doc, Some(2), true, None).unwrap();
// {
// "name": "Ada",
// "age": 37
// }
let doc2 = read_json(&text).unwrap();
assert!(doc.eq_doc(&doc2), "round trip must be lossless");
}
2. Validate against a schema
#![allow(unused)]
fn main() {
use omnist::osd::parse_schema;
let schema = parse_schema(
r#"record Person { "name": string, "age": integer } root Person"#,
).unwrap();
// schema.validate(&doc.root()).ok() == true for the document above
}
3. Infer a schema from example documents
#![allow(unused)]
fn main() {
use omnist::infer::infer;
use omnist::osd::to_osd;
let schema = infer(&samples, "Person").unwrap();
println!("{}", to_osd(&schema, Some(2)).unwrap());
// record Person {
// "name": string,
// "age": integer,
// "tags" [0,]: string,
// }
// root Person
}
That’s it – a Doc, a Schema, validate(), and infer(). From here:
- User guide – the full practical tour, including formats and the CLI.
- CLI reference – the
omnistbinary’s command surface. - Per-format pages – adjustment/lossy-conversion behavior for JSON, YAML, TOML, XML, and OML.
- Limitations & stability – this port’s
0.0.xalpha status and known scoping gaps.
Omnist (Rust) – user guide
Omnist gives you one canonical data model for JSON, YAML, TOML, XML, and
its own native OML, and a schema language
(OSD) to validate and compare shapes over it. This is the Rust port of
omnist; its public types (Doc,
Schema, error enums) are not identical to Python’s or TypeScript’s by
design, but the model and every format’s observable behavior are the same.
- The two ideas
- Documents
- OML – the native format
- Schemas – OSD
- Validation
- Schema algebra
- Reading & writing other formats
- Inferring a schema
- The CLI
The two ideas
- A Document ([
omnist::document::Doc]) is a tree: a node is either a scalar value or an ordered list of labeled edges. “Many” is a label that repeats, not a field pointing to an array. - A Schema ([
omnist::schema::Schema]) is built from namedrecorddefinitions (a closed set of named fields, each with a cardinality). A field’s type is exactly one of seven fixed scalar kinds (optionally nullable, e.g.string?) or aRefto a named record – never a composition of the two.Refs are how reuse and recursion work.
Documents
[Doc::of] builds a Doc from a [Value] (this crate’s plain-value input
type, analogous to a parsed JSON value). An object becomes an edge list; a
key whose value is a Value::Array expands into one edge per item (a
repeated label) – there is no separate array node type.
#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ann".to_string()));
fields.insert(
"tag".to_string(),
Value::Array(vec![Value::Str("x".into()), Value::Str("y".into())]),
);
let doc = Doc::of(&Value::Object(fields)).unwrap();
let root = doc.root();
root.labels(); // ["name", "tag"]
root.count("tag"); // 2 -- "tag" is a repeated label (an array)
root.get_one("name").unwrap(); // a Cursor whose .value() is Scalar::Str("Ann")
root.get("tag"); // both "tag" cursors, in order
}
Doc::to_grouped() projects a Doc back to a JSON-shaped [Value] (same-
label edges become an array); Doc::to_raw()/Doc::from_raw() go through
[RawNode], the interleaving-preserving representation OML and XML need
(see formats/oml.md).
OML – the native format
OML is omnist’s own serialization format: every Document round-trips through it exactly, with no adjustment ever needed (unlike JSON/YAML/TOML/ XML, which each have some lossy corner – see the per-format pages).
#![allow(unused)]
fn main() {
use omnist::oml::{read_oml, write_oml};
let text = write_oml(&doc.to_raw(), 2).unwrap();
// name: "Ada"
// age: 37
let doc2 = omnist::document::Doc::from_raw(read_oml(&text).unwrap()).unwrap();
assert!(doc.eq_doc(&doc2));
}
Schemas – OSD
OSD (Omnist Schema Definition) is the text language for [Schema]. A quoted
token is always a field label; an unquoted identifier is always a schema
name (scalar keyword or Ref).
record Person {
"name": string,
"age": integer,
}
root Person
omnist::osd::parse_schema parses this text into a Schema;
omnist::osd::to_osd writes one back out.
Validation
Schema::validate checks a document’s [Cursor] against the schema’s root
type and collects every problem found, not just the first.
#![allow(unused)]
fn main() {
use omnist::osd::parse_schema;
let schema = parse_schema(
r#"record Person { "name": string, "age": integer } root Person"#,
).unwrap();
let result = schema.validate(&doc.root());
assert!(result.ok());
}
Schema algebra
omnist::ops implements the schema-algebra operations: prune (drop
unreachable/unsatisfiable parts), normalize (canonical minimal form),
extract (a subschema over a subset of root fields), lint, is_isomorphic,
compatible_with/equivalent (subschema relations), and local_signature.
#![allow(unused)]
fn main() {
use omnist::ops::prune;
// `Broken` requires itself and can never be satisfied; `Dead` is
// unreachable from `Root`. `prune` drops both, keeping `Root`.
let pruned = prune(&schema);
assert!(pruned.env().contains_key("Root"));
assert!(!pruned.env().contains_key("Dead"));
assert!(!pruned.env().contains_key("Broken"));
}
Reading & writing other formats
Every format module (omnist::formats::{json,yaml,toml,xml}, plus
omnist::oml for OML) exposes a read_*/write_*/check_* triple.
write_*/check_* return a [WriteReport] cataloging every adjustment a
lossy write had to make (dropped nulls, stringified NaN, …); strict
mode turns any adjustment into an error instead of a silent write. See
formats/ for what each format actually adjusts, verified
against each codec’s own merged PR.
The format registry
Alongside the per-format functions, [Doc::from_format]/to_format/
check_format dispatch by format name through a runtime registry
(omnist::formats/register_format/get_format), so a new format can be
registered under an arbitrary name at runtime and used everywhere a format
name is accepted – not just the five builtins. register_format takes a
[Format] (name + read/write closures, plus an optional check); a
plugin with no check still works for from_format/to_format, but
check_format on it returns a clean error rather than panicking.
#![allow(unused)]
fn main() {
use omnist::document::Doc;
use omnist::{Format, formats, register_format};
let d = Doc::from_format("json", r#"{"a": 1}"#).unwrap();
assert_eq!(d.to_format("yaml").unwrap(), "a: 1");
register_format(Format::new(
"kv",
|text| {
let edges = text
.split(',')
.map(|pair| {
let (k, v) = pair.split_once('=').unwrap();
(k.to_string(), omnist::document::RawNode::Leaf(
omnist::document::Scalar::Str(v.to_string()),
))
})
.collect();
Doc::from_raw(omnist::document::RawNode::Edges(edges)).map_err(Into::into)
},
|doc| {
let omnist::document::RawNode::Edges(edges) = doc.to_raw() else {
return Ok(String::new());
};
Ok(edges.iter().map(|(k, v)| {
let omnist::document::RawNode::Leaf(omnist::document::Scalar::Str(s)) = v else {
unreachable!()
};
format!("{k}={s}")
}).collect::<Vec<_>>().join(","))
},
));
assert!(formats().contains(&"kv".to_string()));
assert_eq!(Doc::from_format("kv", "a=1,b=2").unwrap().to_format("kv").unwrap(), "a=1,b=2");
}
Inferring a schema
omnist::infer::infer drafts a record schema that accepts a set of sample
Documents, without an allow_any fallback (see
limitations.md for why).
#![allow(unused)]
fn main() {
use omnist::infer::infer;
let schema = infer(&samples, "Person").unwrap();
for doc in &samples {
assert!(schema.accepts(&doc.root()));
}
}
The CLI
The omnist binary (crate omnist-cli) wraps this library as a
command-line tool – format, convert, check, validate, infer, and
the schema subcommand family. See cli.md for the full command
reference.
API reference
Rustdoc API Reference: Generated rustdoc documentation for all workspace crates is available at
api/omnist/index.html.
This page enumerates the omnist crate’s public surface: every exported
type, function, and method, grouped by area. Signatures are copied from the
current source (omnist/src/*.rs); doc comments are condensed, not
reproduced verbatim. For why each operation behaves the way it does, see
the user guide (worked examples) or the linked
omnist-spec sections (the
normative behavior).
omnist is published on crates.io, so
docs.rs/omnist is the canonical generated API
reference; this page and the rustdoc mirror above are curated
alternatives, not a substitute for the real thing. All items below are
re-exported at the crate root or reachable through their listed module path
(omnist::document::Doc, omnist::materialize, etc).
#![allow(unused)]
fn main() {
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
}
VERSION is the crate’s own Cargo package version.
Documents (omnist::document)
The Document model: an ordered, possibly-repeated, possibly-interleaved
edge tree. See omnist-spec’s
docs/02-document-model.md
for the normative model.
#![allow(unused)]
fn main() {
pub const MAX_DEPTH: usize = 200;
pub const MAX_NODES: usize = 1_000_000;
}
The default depth and node-count guards, enforced on every construction path;
the limits in force are omnist::limits::Limits (below).
#![allow(unused)]
fn main() {
impl Doc {
pub fn of_with(value: &Value, limits: &Limits) -> Result<Doc, DocumentError>;
pub fn from_raw_with(root: RawNode, limits: &Limits) -> Result<Doc, DocumentError>;
}
}
Doc::of and Doc::from_raw under explicit limits; the document remembers
them for later add/set.
Safety limits (omnist::limits)
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct Limits {
pub max_depth: u32,
pub max_nodes: u32,
pub max_int_digits: u32,
pub max_input_bytes: u64,
}
impl Limits {
pub fn with_max_depth(self, n: u32) -> Self;
pub fn with_max_nodes(self, n: u32) -> Self;
pub fn with_max_int_digits(self, n: u32) -> Self;
pub fn with_max_input_bytes(self, n: u64) -> Self;
pub fn effective_max_depth(&self) -> u32;
pub fn effective_max_nodes(&self) -> u32;
pub fn effective_max_int_digits(&self) -> u32;
pub fn effective_max_input_bytes(&self) -> u64;
pub fn validate(&self) -> Result<(), DocumentError>;
pub fn check_input_len(&self, len: u64, hint: &str) -> Result<(), DocumentError>;
}
pub fn input_size_error(max_input_bytes: u64, hint: &str) -> DocumentError;
pub const DEFAULT_MAX_DEPTH: u32 = 200;
pub const MAX_DEPTH_CEILING: u32 = 250;
pub const DEFAULT_MAX_NODES: u32 = 1_000_000;
pub const MAX_NODES_CEILING: u32 = 10_000_000;
pub const DEFAULT_MAX_INT_DIGITS: u32 = 4_300;
pub const MAX_INT_DIGITS_CEILING: u32 = 43_000;
pub const DEFAULT_MAX_INPUT_BYTES: u64 = 64 * 1024 * 1024;
pub const MAX_INPUT_BYTES_CEILING: u64 = 1 << 30;
}
The three universal limits of spec section 2.4 and the maximum input size of
D-23 (bytes of the input as received, BOM counted, checked first by every
reader: an input of exactly the maximum is accepted, one byte more is refused
with document.limit.input-size at $). 0 selects the default; a value
above its ceiling is refused by validate, which every *_with entry point
calls first. Doc::from_format applies the default maximum to a registered
format as well. The explicit-limits readers are
oml::read_oml_with, formats::json::read_json_with,
formats::toml::read_toml_with, formats::xml::read_xml_with, and
formats::yaml::read_yaml_with with YamlReadOptions::with_limits; the plain
read_* functions use the defaults. See
Limitations.
#![allow(unused)]
fn main() {
pub struct NodeId(/* opaque */);
}
An opaque index into a Doc’s arena.
#![allow(unused)]
fn main() {
pub enum Scalar {
Null,
Bool(bool),
Int(num_bigint::BigInt),
Float(f64),
Str(String),
Date(String),
Time(String),
Datetime(String),
}
}
A leaf value. Implements Display (null, true, an integer, a float, a
debug-quoted string, or the bare temporal text for Date/Time/
Datetime). Int is arbitrary-precision (num_bigint::BigInt, issue
#104) – omnist’s algebra never does integer arithmetic, so there’s no
representational ceiling to impose. Date/Time/Datetime (issue #105)
each hold already shape-validated, canonical ISO text as a plain String
– deliberately no chrono/time dependency, since the algebra never does
temporal arithmetic either; nothing outside crate::schema’s
is_iso_date/is_iso_time/is_iso_datetime/canonicalize_iso_time/
canonicalize_iso_datetime constructs one, so the invariant holds by
construction-site discipline, not the type system.
#![allow(unused)]
fn main() {
pub enum Value {
Null,
Bool(bool),
Int(num_bigint::BigInt),
Float(f64),
Str(String),
Date(String),
Time(String),
Datetime(String),
Array(Vec<Value>),
Object(IndexMap<String, Value>),
}
}
A plain input value (JSON/YAML/TOML-shaped) – what Doc::of/Doc::add/
Doc::set turn into canonical nodes. Object uses IndexMap so key order
survives construction. impl From<Scalar> for Value converts the other
way. Int/Date/Time/Datetime carry the same invariants as their
Scalar counterparts above.
#![allow(unused)]
fn main() {
pub struct Doc { /* private arena + root */ }
impl Doc {
pub fn of(value: &Value) -> Result<Doc, DocumentError>;
pub fn root(&self) -> Cursor<'_>;
pub fn add(&mut self, at: NodeId, path: &str, label: &str, value: &Value)
-> Result<NodeId, DocumentError>;
pub fn set(&mut self, at: NodeId, path: &str, label: &str, value: &Value)
-> Result<NodeId, DocumentError>;
pub fn remove(&mut self, at: NodeId, path: &str, label: &str) -> Result<(), DocumentError>;
pub fn to_grouped(&self) -> Value;
pub fn to_data(&self) -> Value;
pub fn eq_doc(&self, other: &Doc) -> bool;
pub fn from_raw(root: RawNode) -> Result<Doc, DocumentError>;
pub fn to_raw(&self) -> RawNode;
pub fn from_format(name: &str, text: &str) -> Result<Doc, crate::error::OmnistError>;
pub fn to_format(&self, name: &str) -> Result<String, crate::error::OmnistError>;
pub fn check_format(&self, name: &str)
-> Result<crate::report::WriteReport, crate::error::OmnistError>;
}
}
A guarded handle on a Document tree.
ofbuilds aDocfrom aValue.rootreturns a cursor to the root, path"$".addappends an edge(label, value)under an internal node; a repeated label is how an array grows. Returns the new edge’sNodeId.setreplaces all edges underlabelwith a single new edge, positioned at the first old occurrence (set = remove + add).removeremoves every edge underlabel.to_groupedreturns a JSON-shaped projection: same-label edges grouped into an array.to_datareturns a lossless structural copy (does not re-group repeated labels; used foreq_doc).eq_docis structural equality: same shape, edge order, labels, leaf values.from_raw/to_rawconvert to/fromRawNode, preserving edge order and interleaving exactly.from_format/to_format/check_formatdispatch by registered format name throughomnist::registry(see Registry below).
#![allow(unused)]
fn main() {
pub struct Cursor<'a> {
pub path: String,
/* private doc + id */
}
impl<'a> Cursor<'a> {
pub fn id(&self) -> NodeId;
pub fn is_leaf(&self) -> bool;
pub fn value(&self) -> Result<&'a Scalar, DocumentError>;
pub fn edges(&self) -> Result<Vec<(String, Cursor<'a>)>, DocumentError>;
pub fn labels(&self) -> Vec<String>;
pub fn get(&self, label: &str) -> Vec<Cursor<'a>>;
pub fn get_one(&self, label: &str) -> Result<Cursor<'a>, DocumentError>;
pub fn count(&self, label: &str) -> usize;
pub fn child(&self, label: &str) -> Result<Cursor<'a>, DocumentError>;
pub fn to_raw(&self) -> RawNode;
}
}
A read-only cursor into a Doc’s tree, tracking its own path (built
incrementally, e.g. $.a.b[1], for error messages and equality with the
Python reference’s Doc.path).
valueerrors if called on an internal node (“not a leaf; use edges()”).edgeserrors if called on a leaf; returns every child cursor with its label.get/get_onefilter children by label;get_oneerrors unless exactly one match exists.childis an alias forget_one.to_rawreturns a losslessRawNodecopy of the subtree rooted at this cursor.
#![allow(unused)]
fn main() {
pub enum RawNode {
Leaf(Scalar),
Edges(Vec<(String, RawNode)>),
}
}
The raw canonical Document node: a leaf scalar, or an ordered edge list
that may repeat and interleave a label arbitrarily. Unlike Value::Object’s
IndexMap, RawNode::Edges can represent non-contiguous repeats of the
same label exactly, which is why OML’s reader/writer (omnist::oml) walks
Doc through this type instead of Value.
Before issue #105, this enum also had a TemporalLeaf(Scalar) variant –
a write-hint tag (schema- or OML-grammar-known to be date/time/
datetime-kinded, consumed only by omnist::oml::write_oml) that worked
around Scalar having no temporal variant of its own. Issue #105 gave
Scalar real Date/Time/Datetime variants, making the tag redundant,
and it was removed – OML’s writer now matches on Scalar’s own variant
directly.
Schema model (omnist::schema)
The Schema model: closed records of labeled, cardinality-bound fields.
See omnist-spec’s
docs/03-schema-model.md
for the normative model.
#![allow(unused)]
fn main() {
pub enum ScalarKind {
String,
Integer,
Number,
Boolean,
Date,
Time,
Datetime,
}
impl ScalarKind {
pub const ALL: [ScalarKind; 7];
pub fn as_str(&self) -> &'static str;
pub fn parse(name: &str) -> Result<ScalarKind, SchemaError>;
}
}
One of the seven predefined value kinds a Scalar can hold. parse reads
a kind name as it appears in schema text (e.g. "date").
#![allow(unused)]
fn main() {
pub struct Scalar { /* private kind + nullable */ }
impl Scalar {
pub const fn new(kind: ScalarKind, nullable: bool) -> Self;
pub fn named(name: &str, nullable: bool) -> Result<Self, SchemaError>;
pub fn kind(&self) -> ScalarKind;
pub fn is_nullable(&self) -> bool;
}
pub const STRING: Scalar;
pub const INTEGER: Scalar;
pub const NUMBER: Scalar;
pub const BOOLEAN: Scalar;
pub const DATE: Scalar;
pub const TIME: Scalar;
pub const DATETIME: Scalar;
pub fn nullable(scalar: Scalar) -> Scalar;
}
One of the seven predefined value types, optionally nullable. The seven
pub consts are the non-nullable form of each kind; nullable(scalar)
returns a copy that also accepts null (the ? form in OSD text).
Implements Display (e.g. integer, string?).
#![allow(unused)]
fn main() {
pub struct Ref {
pub name: String,
}
impl Ref {
pub fn new(name: impl Into<String>) -> Self;
}
}
A reference to a named record in a Schema’s environment. Display
renders as ref(name).
#![allow(unused)]
fn main() {
pub enum FieldType {
Scalar(Scalar),
Ref(Ref),
Any,
}
}
A field’s type. Any accepts every legal Document value (ported from
Python’s AnyType/ANY singleton, shipped since Python v0.5.0) – it is
neither a Scalar (no kind, no nullable flag) nor a Ref (names nothing).
impl From<Scalar> and impl From<Ref> construct a FieldType from
either.
#![allow(unused)]
fn main() {
pub struct Field {
pub label: String,
pub ty: FieldType,
pub min: usize,
pub max: Option<usize>,
}
impl Field {
pub fn new(label: impl Into<String>, ty: impl Into<FieldType>, min: usize, max: Option<usize>)
-> Result<Self, SchemaError>;
pub fn required(label: impl Into<String>, ty: impl Into<FieldType>) -> Result<Self, SchemaError>;
pub fn cardinality_str(&self) -> String;
}
}
One named, cardinality-bound field slot of a record: label of ty,
occurring [min, max] times (max = None is unbounded). new errors if
max < min. required is the common [1,1] case. cardinality_str
renders a human-readable cardinality description (“exactly 1”, “0 or 1”,
“at least N”, “between N and M”).
#![allow(unused)]
fn main() {
pub struct Record { /* private fields + label index */ }
impl Record {
pub fn new(fields: Vec<Field>) -> Result<Self, SchemaError>;
pub fn fields(&self) -> &[Field];
pub fn field(&self, label: &str) -> Option<&Field>;
}
}
A closed set of named fields. new rejects a duplicate field label.
fields preserves declaration order; equality (PartialEq) is
declaration-order-independent (a record is an unordered field set at the
model layer, per omnist-spec §3.1).
#![allow(unused)]
fn main() {
pub enum ErrorCode {
UnexpectedField,
Cardinality,
TypeMismatch,
NullNotAllowed,
ShapeMismatch,
}
impl ErrorCode {
pub fn as_str(&self) -> &'static str;
}
}
A stable, machine-readable validation failure code.
#![allow(unused)]
fn main() {
pub struct ValidationError {
pub path: String,
pub message: String,
pub code: ErrorCode,
}
pub struct ValidationResult { /* private errors */ }
impl ValidationResult {
pub fn new() -> Self;
pub fn ok(&self) -> bool;
pub fn errors(&self) -> &[ValidationError];
}
}
ValidationResult is the outcome of Schema::validate: empty on success,
one entry per problem found (validation collects every error, not just the
first). Display renders "valid" or an indented "invalid:" listing.
#![allow(unused)]
fn main() {
pub fn matches_kind(value: &document::Scalar, kind: ScalarKind) -> bool;
}
Does value match scalar kind kind? Validation only checks, never
converts (see materialize below for the upgrading counterpart). Date/
Time/Datetime match either the real document::Scalar variant
or a plain Str whose text independently shape-validates as that
kind’s ISO form (verified against Python’s own hybrid matches_kind,
issue #105) – so a JSON- or XML-sourced document with a date-shaped
string field genuinely satisfies kind: date at validate time with no
materialize upgrade required first. Integer/Number stay strict (no
string-shape fallback), matching Python there too.
#![allow(unused)]
fn main() {
pub enum Resolved<'a> {
Record(&'a Record),
Scalar(Scalar),
Any,
}
pub struct Schema { /* private root + env */ }
impl Schema {
pub fn new(root: Ref, env: IndexMap<String, Record>) -> Result<Self, SchemaError>;
pub fn root(&self) -> &Ref;
pub fn env(&self) -> &IndexMap<String, Record>;
pub fn resolve(&self, ty: &FieldType) -> Resolved<'_>;
pub fn validate(&self, cursor: &document::Cursor<'_>) -> ValidationResult;
pub fn accepts(&self, cursor: &document::Cursor<'_>) -> bool;
}
}
A schema: a root reference plus an environment of named records.
newchecks everyRef(the root’s, and every field’s) resolves withinenv, and enforces that no record is named after a scalar keyword orany(a bare name in type position always resolves to the builtin first, so such a record could never be referenced).resolvemaps aFieldTypeto aResolvedvalue: a bareScalarresolves to itself,AnytoResolved::Any, aRefto a single environment lookup (guaranteed to succeed once aSchemaexists).validatewalkscursoragainst the schema’s root type, collecting every problem found.acceptsisvalidate(cursor).ok().
Schema text – OSD (omnist::osd)
OSD (Omnist Schema Definition) is the text language for Schema. See
omnist-spec’s
docs/03-schema-model.md
for the grammar.
#![allow(unused)]
fn main() {
pub fn parse_schema(text: &str) -> Result<Schema, SchemaError>;
pub fn to_osd(schema: &Schema, indent: Option<usize>) -> Result<String, WriteError>;
}
parse_schema parses OSD text into a Schema. to_osd serializes a
Schema back to OSD text: indent: None renders a single-line,
machine-oriented form; Some(n) sets the pretty-printed indent width in
spaces. Both forms round-trip through parse_schema. Labels are escaped per
OSD-15 (a backslash is written \\, a double quote \", nothing else);
to_osd fails with write.unsupported-value (OSD-14) for a field label
containing a C0 control character, which OSD text cannot spell, and
(OSD-16, S-24) for a field with max = 0 ([0,0]: representable in the model,
rejected by every text reader) – both at the record path. Run
omnist::ops::prune first: it removes max = 0 fields from every record it
rebuilds (an unsatisfiable root is kept intact, spec 6.5).
Schema::new enforces S-8: a record name or Ref target name (the root’s and
every field’s) that is not [A-Za-z_][A-Za-z0-9_]* fails with
schema.invalid-name at the path $, the offending name in the message only.
Formats (omnist::formats and omnist::oml)
Codecs over the canonical Document model. Every builtin format follows the
same read_*/write_*/check_* naming convention. Unlike OML (always
lossless), JSON/YAML/TOML/XML writers are lenient by default – they adjust
values that don’t fit the target format and record the change in a
WriteReport (see Reporting); strict: true
makes the writer return WriteError instead. See omnist-spec’s
docs/04-formats.md
for the per-format lossiness rules.
Note: OML lives in its own top-level omnist::oml module, not under
omnist::formats – it is omnist’s own native format, not one of the four
lossy interchange codecs.
#![allow(unused)]
fn main() {
// omnist::formats::json
pub fn read_json(text: &str) -> Result<Doc, OmnistError>;
pub fn write_json(doc: &Doc, indent: Option<usize>, strict: bool, report: Option<&mut WriteReport>)
-> Result<String, WriteError>;
pub fn check_json(doc: &Doc) -> WriteReport;
}
JSON: write_json’s indent: None writes compact JSON; Some(n)
pretty-prints with n spaces per level. NaN/Infinity/-Infinity are
adjusted to null in lenient mode.
#![allow(unused)]
fn main() {
// omnist::formats::yaml
pub fn read_yaml(text: &str) -> Result<Doc, OmnistError>;
pub fn write_yaml(doc: &Doc, strict: bool, report: Option<&mut WriteReport>)
-> Result<String, WriteError>;
pub fn check_yaml(doc: &Doc) -> WriteReport;
pub fn read_yaml_with(text: &str, options: &YamlReadOptions) -> Result<Doc, OmnistError>;
pub struct YamlReadOptions { pub max_alias_expansion: u32, pub max_expanded_slots: u32 }
pub fn with_max_alias_expansion(mut self, n: u32) -> Self; // on YamlReadOptions
pub fn with_max_expanded_slots(mut self, n: u32) -> Self; // on YamlReadOptions
pub const DEFAULT_MAX_ALIAS_EXPANSION: u32 = 50;
pub const MAX_ALIAS_EXPANSION_CEILING: u32 = 10_000;
pub const DEFAULT_MAX_EXPANDED_SLOTS: u32 = 1_000_000;
pub const MAX_EXPANDED_SLOTS_CEILING: u32 = 10_000_000;
}
YAML: block style, 2-space indent, insertion order preserved. read_yaml
accepts exactly one YAML document (a multi-document stream errors, matching
Python’s yaml.safe_load); empty/blank input parses as a Null document.
Both readers refuse input whose alias expansion factor exceeds the maximum
(default 50) or that holds a self-referential anchor, with
document.limit.alias-expansion at $ and before anything is expanded
(spec D-18/D-19/D-20), and an input with an alias or merge key whose root
materializes more than the maximum expanded size (default 1,000,000 slots)
with document.limit.expanded-size (D-22); a malformed merge is
parse.codec-syntax. read_yaml_with takes a YamlReadOptions whose
max_alias_expansion and max_expanded_slots (0 selects the default;
above 10000 and 10,000,000 respectively is refused) change them. See
YAML.
#![allow(unused)]
fn main() {
// omnist::formats::toml
pub fn read_toml(text: &str) -> Result<Doc, OmnistError>;
pub fn write_toml(doc: &Doc, strict: bool, report: Option<&mut WriteReport>)
-> Result<String, WriteError>;
pub fn check_toml(doc: &Doc) -> WriteReport;
}
TOML: write_toml errors if the root isn’t an object (TOML documents are
always tables at the top level). null values are stripped, recorded in
the report.
#![allow(unused)]
fn main() {
// omnist::formats::xml
pub fn read_xml(text: &str) -> Result<Doc, OmnistError>;
pub fn read_xml_with_schema(text: &str, schema: &Schema) -> Result<Doc, OmnistError>;
pub fn write_xml(doc: &Doc, strict: bool, report: Option<&mut WriteReport>)
-> Result<String, WriteError>;
pub fn check_xml(doc: &Doc) -> WriteReport;
}
XML: write_xml requires a single-rooted Document (exactly one top-level
edge) and errors otherwise; check_xml does not enforce that shape (it
mirrors Python’s check_xml, a plain adjustment scan with no root-shape
guard).
#![allow(unused)]
fn main() {
// omnist::oml
pub fn read_oml(text: &str) -> Result<RawNode, ParseError>;
pub fn write_oml(node: &RawNode, indent: usize) -> Result<String, WriteError>;
pub fn write_oml_compact(node: &RawNode) -> Result<String, WriteError>;
pub fn check_oml(doc: &Doc) -> WriteReport;
}
OML (Omnist Markup Language), omnist’s own native format: every Document
round-trips through it exactly, with no adjustment ever needed. read_oml
supports the full OML-Core grammar plus OML-Extended raw-string ('...')
and triple-quoted ("""...""") string spellings on read only –
write_oml/write_oml_compact only ever emit OML-Core double-quoted
strings. write_oml_compact is the single-line form (edges joined by
"; ", no newlines); both round-trip through read_oml. check_oml
always returns an empty WriteReport (OML is lossless) – it exists only
so the "oml" registry entry has a check callable like the other four
formats.
Materialize (omnist::materialize)
#![allow(unused)]
fn main() {
pub fn materialize(node: &RawNode, schema: Option<&Schema>) -> Result<RawNode, MaterializeError>;
}
Schema-directed deserialization: a copy of node with leaf values upgraded
to match schema (e.g. 1.0 -> 1 for an integer field, 1 -> 1.0 for a
number field – upgrades are always value-exact), guaranteed to conform
to it – or every reason it can’t, collected into one MaterializeError
(never just the first problem found). schema: None is a no-op passthrough
– node is cloned back unchanged, with no validation performed. An
Any-typed field passes its node through completely untouched. See
omnist-spec’s
docs/03-schema-model.md
for the scalar-upgrade rules.
Infer (omnist::infer)
#![allow(unused)]
fn main() {
pub struct AnyFallback {
pub location: String,
pub reason: String,
}
pub fn infer(samples: &[Doc], root_name: &str) -> Result<Schema, SchemaError>;
pub fn infer_with_report(samples: &[Doc], root_name: &str, allow_any: bool)
-> Result<(Schema, Vec<AnyFallback>), SchemaError>;
}
infer drafts a record Schema (rooted at root_name) that accepts
every sample in samples; every sample’s root must be an object, and an
empty samples list errors. It always infers with allow_any: false –
a label whose samples disagree in a way that can’t resolve to one precise
type is a SchemaError.
infer_with_report additionally accepts allow_any: true: instead of
erroring on an ambiguous field, it opens the field as FieldType::Any and
records one AnyFallback (location reads RecordName.label; reason
says why). With allow_any: false it behaves exactly like infer, and the
returned Vec<AnyFallback> is always empty.
Neither function auto-normalizes – the raw result may contain
structurally-identical duplicate records; call omnist::ops::normalize (see
Operations / algebra below) where a
canonical minimal schema is wanted.
Operations / algebra (omnist::ops)
The schema compatibility and structural-transform algebra. See
omnist-spec’s
docs/03-schema-model.md
for the algebra’s normative rules and definitions.
#![allow(unused)]
fn main() {
// omnist::ops::subschema
pub fn compatible_with(a: &Schema, b: &Schema) -> bool;
pub fn equivalent(a: &Schema, b: &Schema) -> bool;
}
compatible_with(a, b) is true iff every document a accepts is also
accepted by b (a is a subschema of b / b is backward-compatible
with a). equivalent(a, b) is compatible_with(a, b) && compatible_with(b, a).
#![allow(unused)]
fn main() {
// omnist::ops::extract
pub fn extract(s: &Schema, keep: &[&str]) -> Result<Schema, SchemaError>;
}
The minimal subschema of s that only recognizes documents built from
labels in keep. Errors if deleting the other labels would invalidate the
root record (deleting a mandatory field is a hard error, never silently
loosened to optional).
#![allow(unused)]
fn main() {
// omnist::ops::prune
pub fn satisfiable_set(s: &Schema) -> IndexSet<String>;
pub fn is_empty(s: &Schema) -> bool;
pub fn prune(s: &Schema) -> Schema;
}
satisfiable_set returns the env record names that admit at least one
finite document (a least fixpoint: a record is satisfiable iff every
mandatory field is a Scalar/Any or a Ref to a satisfiable record).
is_empty is true iff the root record itself is unsatisfiable (the
schema’s language is empty). prune returns an equivalent schema with
everything that can never match removed: unreachable records, max == 0
fields, optional fields typed to an unsatisfiable record, and records left
unreachable/unsatisfiable afterward. If the root itself is unsatisfiable,
its fields are left untouched (stripping them would produce a different,
satisfiable schema) and only the rest of the environment is reduced.
#![allow(unused)]
fn main() {
// omnist::ops::minimize
pub fn equivalence_classes(s: &Schema) -> Vec<Vec<String>>;
pub fn normalize(s: &Schema) -> Schema;
}
equivalence_classes partitions s.env’s record names into
structural-equivalence classes via partition refinement (an initial
target-blind structural-key grouping, refined to a fixpoint by which block
each same-labeled ref field points to); it does not prune first. normalize
returns the canonical minimal schema equivalent to s: prunes first, then
collapses each equivalence class to its lexicographically smallest member
name. An unsatisfiable pruned root is returned unchanged (partition
refinement over an empty-language core isn’t meaningful).
#![allow(unused)]
fn main() {
// omnist::ops::lint
pub struct LintFinding {
pub code: &'static str,
pub severity: &'static str,
pub location: String,
pub message: String,
}
pub fn lint(s: &Schema) -> Vec<LintFinding>;
}
Non-destructive structural diagnostics for a schema – reports, never
mutates. Four checks: unsatisfiable-record (warning; a reachable record
no finite document can match), unreachable-record (warning; defined but
not reachable from root), duplicate-record (warning; two or more
structurally identical records under different names), and any-field
(info; an inventory of every any-typed field). Findings are sorted
deterministically by (code, location).
#![allow(unused)]
fn main() {
// omnist::ops::isomorphic
pub fn is_isomorphic(a: &Schema, b: &Schema) -> bool;
}
True iff normalized schemas a and b are isomorphic: there is a
bijection between their env record names under which the two root records
(and everything reachable from them) match exactly. Both inputs are
assumed already normalized by the caller. Empty-schema convention: if
both a and b are unsatisfiable, they’re treated as isomorphic (both
accept the empty language); if exactly one is, they’re not. This is not
a committed public surface the way equivalent is – it exists as an
independent cross-check oracle used in this crate’s own test suite,
mirroring the Python reference’s choice to keep the equivalent function
private there.
#![allow(unused)]
fn main() {
// omnist::ops::signature
pub enum ShapeKey {
Scalar(ScalarKind, bool),
Ref,
Any,
}
pub type FieldKey = (String, usize, Option<usize>, ShapeKey);
pub type LocalSignature = Vec<FieldKey>;
pub fn local_signature(rec: &Record) -> LocalSignature;
}
local_signature is a record’s target-blind structural key: every field’s
(label, min, max, shape), sorted by label, used as the initial partition
for minimize’s refinement and as the comparison key for isomorphic’s
signature matching.
Registry (omnist::registry)
Name-keyed runtime dispatch: read/write/check a Doc by format name, and
register your own format plugins.
#![allow(unused)]
fn main() {
pub type ReadFn = dyn Fn(&str) -> Result<Doc, OmnistError> + Send + Sync;
pub type WriteFn = dyn Fn(&Doc) -> Result<String, OmnistError> + Send + Sync;
pub type CheckFn = dyn Fn(&Doc) -> WriteReport + Send + Sync;
pub struct Format {
pub name: String,
pub read: Arc<ReadFn>,
pub write: Arc<WriteFn>,
pub check: Option<Arc<CheckFn>>,
}
impl Format {
pub fn new(
name: impl Into<String>,
read: impl Fn(&str) -> Result<Doc, OmnistError> + Send + Sync + 'static,
write: impl Fn(&Doc) -> Result<String, OmnistError> + Send + Sync + 'static,
) -> Self;
pub fn with_check(self, check: impl Fn(&Doc) -> WriteReport + Send + Sync + 'static) -> Self;
}
pub fn register_format(fmt: Format);
pub fn get_format(name: &str) -> Result<Format, OmnistError>;
pub fn formats() -> Vec<String>;
}
Format bundles a name with read/write callables and an optional
check – a plugin built with Format::new alone has no check, and
Doc::check_format returns a clean error (not a panic) if invoked on it.
register_format adds or replaces a format under fmt.name, usable
everywhere a format name is accepted (Doc::from_format/to_format/
check_format). get_format looks up a registered Format by name, or an
OmnistError::Format naming every currently-registered name, sorted.
formats lists all registered format names, sorted. The five builtins
(json, yaml, toml, xml, oml) are always registered.
Errors (omnist::error)
#![allow(unused)]
fn main() {
pub struct DocumentError {
pub path: String,
/// The spec taxonomy code (`document.*`, `format.*`), when one applies.
pub code: Option<String>,
pub message: String,
}
pub struct SchemaError {
pub path: String,
pub code: String,
pub message: String,
}
pub struct ParseError {
pub line: usize,
pub col: usize,
/// The spec's `parse.*` code, e.g. `parse.unexpected-token`,
/// `parse.codec-syntax`.
pub code: String,
pub message: String,
}
pub struct FormatError(pub String);
pub struct WriteError {
pub message: String,
pub report: Option<crate::report::WriteReport>,
/// The Document path and spec code (`write.unsupported-value`,
/// `format.multiple-roots`) of a failure the taxonomy names.
pub path: Option<String>,
pub code: Option<String>,
}
impl WriteError {
pub fn new(message: impl Into<String>) -> Self;
pub fn with_diagnostic(
path: impl Into<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> Self;
pub fn with_report(message: impl Into<String>, report: crate::report::WriteReport) -> Self;
pub fn report(&self) -> Option<&crate::report::WriteReport>;
}
pub struct MaterializeError(pub crate::schema::ValidationResult);
impl MaterializeError {
pub fn new(result: crate::schema::ValidationResult) -> Self;
pub fn result(&self) -> &crate::schema::ValidationResult;
pub fn errors(&self) -> &[crate::schema::ValidationError];
}
pub enum OmnistError {
Document(DocumentError),
Schema(SchemaError),
Materialize(MaterializeError),
Parse(ParseError),
Write(WriteError),
Format(FormatError),
}
}
OmnistError is the crate-wide top-level error; each leaf type is a
#[from] variant, mirroring the Python reference’s
OmnistError/SchemaError/ParseError/WriteError/DocumentError
hierarchy.
DocumentError– a Document operation is invalid, or a plain value is not a legal Document (construction/mutation outside the Document model, or an operation that doesn’t fit the node it’s called on).SchemaError– a Schema definition is invalid (bad cardinality, duplicate field label, unknown scalar/ref name).ParseError– source text could not be parsed (OML, or any of the four codecs); carries a “line N, col N: msg” position, the spec’sparse.*code, andposition()(theline:coltext-position path).FormatError– an unknown format name was looked up in the format registry.WriteError– an in-memory Document could not be written; carries an optionalWriteReportwhen a strict-mode format writer raised because of accumulated adjustments.MaterializeError– a freshly-read node could not be made to conform to aSchema; wraps the sameValidationResultSchema::validateuses.
Reporting (omnist::report)
#![allow(unused)]
fn main() {
pub enum Severity {
Warning,
Error,
}
pub struct Adjustment {
pub path: String,
pub code: String,
pub message: String,
pub severity: Severity,
}
pub struct WriteReport { /* private adjustments */ }
impl WriteReport {
pub fn new() -> Self;
pub fn add(&mut self, path: impl Into<String>, code: impl Into<String>,
message: impl Into<String>, severity: Severity);
pub fn adjustments(&self) -> &[Adjustment];
pub fn warnings(&self) -> Vec<&Adjustment>;
pub fn errors(&self) -> Vec<&Adjustment>;
pub fn is_ok(&self) -> bool;
pub fn is_empty(&self) -> bool;
pub fn len(&self) -> usize;
pub fn iter(&self) -> std::slice::Iter<'_, Adjustment>;
}
pub fn finish_write(text: String, rep: WriteReport, strict: bool, report: Option<&mut WriteReport>)
-> Result<String, WriteError>;
}
Adjustment reports for lossy writes. Writing a Doc to a format that can’t
hold every value losslessly means the writer has to adjust the data; each
adjustment is recorded as an Adjustment in a WriteReport rather than
lost silently.
Severity::Warningis conventional/recoverable (e.g. a date written as a string);Severity::Erroris likely to surprise or corrupt (e.g.NaNwritten as JSONnull).WriteReport::is_okis true iff there are no error-severity entries – warnings alone don’t flip it (this is the report’sboolconversion in the Python reference).finish_writeis the standardstrict/reporthandling every format writer applies to its own accumulatedWriteReport: ifreportis given,rep’s adjustments are copied into it; ifstrictandrephas any adjustments, returnsWriteErrorcarryingrep; otherwise returnstext.
Rustdoc (cargo doc)
CLI reference
The omnist binary (omnist-cli, issue #24) wraps the library crate as a
command-line tool. This page mirrors the actual clap command surface in
omnist-cli/src/lib.rs; every example below is
drawn from a real assertion in omnist-cli/tests/cli.rs.
omnist <COMMAND>
Commands:
format Canonicalize an OML document (the only format with no other tool for this)
convert Convert a document between formats (one in, one out)
check Report what writing as --to would adjust, without ever writing
validate Check a document against a schema (no schema-directed upgrading)
infer Draft a schema from example documents (all the same format)
schema Operate on a Schema (OSD)
Every subcommand accepts - for its input file meaning stdin, --output
(-o)/stdout for where the result goes, and --json to wrap CLI-level
errors as structured JSON on stderr instead of plain text. Exit codes: 0
success, 1 a conformance/adjustment failure the command itself reports,
2 a usage or parse error.
Every subcommand that reads a Document (format, convert, check,
validate, infer) accepts --max-input-bytes N (1 to 1 GiB, default 64
MiB): an input of more than N bytes is refused with
document.limit.input-size at $ (spec D-23), exit 2. The count is bytes of
the input as received, a leading BOM included, taken before decoding; the CLI
stops reading at N + 1 bytes. The message says how to raise it:
error: input exceeds the maximum input size (40 bytes); use --max-input-bytes N to raise it
Schema files (--schema, the schema subcommands) are not Documents and are
not bounded by it.
format
Canonicalizes an OML document – the only format with no other canonicalization tool.
$ omnist format doc.oml
a: 1
b: "x"
--compact prints single-line OML (; -separated) instead of one edge per
line.
convert
Converts one format to another in a single pass (--from/--to are
required; json, yaml, toml, xml, oml).
$ omnist convert doc.json --from json --to oml
a: 1
b: "x"
--schema <file> materializes the document against an OSD schema before
writing (schema-directed deserialization, e.g. numeric-string coercion);
--strict turns any lossy-write adjustment into an error instead of a
silent write; --report prints the adjustment report to stderr;
--result-format {text,json,oml} controls how a report (or --schema
outcome) is rendered. Each adjustment carries the spec’s section 8.3.8 code
(format.value-stringified, format.string-line-break-char,
format.temporal-stringified, format.interleaving-lost, …). Through
0.7.0-alpha format.value-stringified was spelled value.stringified and
format.string-line-break-char was spelled string.line-break-char; a
script matching the old output must change. Through 0.8.0-alpha an XML null
was written as an empty element with a null.omitted warning; from
0.9.0-alpha convert --to xml fails on a null leaf (exit 2, spec C-10),
--strict or not, and check --to xml reports it as an error entry with
code write.unsupported-value (see XML).
check
Like convert, but never writes – reports what a --to write would
adjust.
$ omnist check doc.json --from json --to toml
no adjustments
validate
Checks a document against an OSD schema (--schema <file>, required). No
schema-directed upgrading happens here – validate only checks, convert --schema is what materializes.
$ omnist validate doc.json --from json --schema person.osd
valid
An invalid document exits 1 and prints every collected ValidationError;
--json emits {"ok": false, "errors": [{"path", "message", "code"}, ...]}
with the same stable, family-namespaced code strings
schema::ErrorCode::as_str returns per omnist-spec §8.3.1
("validate.type-mismatch", "validate.cardinality", …; convert --schema’s materialize path uses the same validate.* codes for shape and
cardinality problems, and "materialize.inexact-conversion" for a leaf that
cannot be converted exactly – omnist-spec §8.3.5).
infer
Drafts a schema from one or more sample documents (--from, required; all
samples must be the same format).
$ omnist infer doc.json --from json
record Root {
"a": integer,
"b": string,
}
root Root
--allow-any is accepted by the argument parser (matching Python’s
surface) but returns a clear “not supported yet” error – this port’s
[omnist::infer::infer] has no any-fallback (see
limitations.md).
schema <subcommand>
Operates on an OSD schema file (all subcommands take a .osd file, - for
stdin):
format– canonicalize OSD text.normalize– canonical minimal form (structurally identical records collapsed to one).prune– drop unreachable/unsatisfiable records and fields.is-empty– exit0if the schema’s language is empty,1otherwise (and printstrue/false).extract --keep <labels>– a subschema over a subset of root fields.lint [--severity {info,warning}]– structural lint findings.compatible-with <a> <b>/equivalent <a> <b>– subschema relations between two schema files.
$ omnist schema prune schema.osd # Dead (unreachable) record is gone
record Root {
"a": string,
}
root Root
Known scope gaps (library limitations, not CLI bugs)
--arrays: accepted wherever the Python CLI accepts it (OML output paths), but this port’somnist::oml::write_omlhas noarraysmode yet – passing it where it would apply returns a clear “not supported yet” error (exit2) rather than being silently ignored or faked. Where--arrayshas no effect per spec, it is accepted and silently ignored, matching Python.- schema-directed
--schemaonconvert: implemented viaomnist::materialize::materializeon the raw node after a schema-less read, mirroring Python’s own_materialize(node, schema)call inside each reader.
Formats
omnist reads and writes five formats through one canonical Doc model:
JSON, YAML, TOML, XML, and omnist’s own native OML. Each format page below
documents that format’s specific round-trip behavior and any lossy
corners it has.
JSON
omnist::formats::json::{read_json, write_json, check_json}. Ported from
~/dev/omnist/omnist/formats.py’s read_json/write_json/check_json;
see omnist/src/formats/json.rs’s
module doc for the full detail behind every claim below.
#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::json::{read_json, write_json};
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();
let text = write_json(&doc, Some(2), true, None).unwrap();
let doc2 = read_json(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}
The one lossy JSON adjustment: NaN/Infinity
JSON’s grammar has no token for NaN/Infinity/-Infinity. Writing one of
these floats substitutes null and records a temporal.stringified-style
adjustment via WriteReport; strict mode turns this into an error instead
of a silent substitution.
NaN and Infinity are not JSON on read (C-19)
The bare tokens NaN, Infinity and -Infinity are parse.codec-syntax:
NaN and Infinity are numbers only where the format spells them (YAML .nan
.inf; TOML and OML nan inf). Through 0.11.1-alpha the reader accepted
them as floats, a document it could not write back as JSON.
No native temporal type
JSON has no native date/time/datetime literal, so a genuine
Scalar::Date/Time/Datetime (issue #105) writes the same way a
Scalar::Str does – its raw ISO text, quoted – and check_json records
a format.temporal-stringified warning, matching Python (which
stringifies a live datetime.date/time object and records its own
temporal.stringified-equivalent warning). This is a real adjustment now,
not merely unreachable: before issue #105, Scalar had no temporal
variant at all, so this codec’s decoder path (in the conformance harness)
collapsed such values to a plain string before the writer ever ran,
making the adjustment structurally unreachable in this port
(formats-json/basic/temporal-leaf-is-stringified-on-write, previously
skipped, now passes for real).
The text is the value’s ISO-8601 spelling with its seconds always written
(12:00 is written 12:00:00) and the offset kept (E-37).
Integer digit cap (arbitrary-precision, matching Python – issue #104)
A JSON integer literal over 4300 digits is a DocumentError
(document.limit.int-digits, at the Document path of the integer, for example
$.a; mirrors CPython’s sys.set_int_max_str_digits guard, which fires inside
json.loads before Python ever sees the value). The cap is
Limits::max_int_digits and read_json_with changes it; see
Limitations. Under that cap, this
port’s Scalar::Int/Value::Int hold the value exactly, at any size –
num_bigint::BigInt, not a fixed-width integer – matching Python’s own
arbitrary-precision int with no additional ceiling. (Previously
i64-backed, ~19 significant digits; that was a real spec-conformance
bug, not a disclosed representational gap – see
Limitations.)
YAML
omnist::formats::yaml::{read_yaml, write_yaml, check_yaml}. Ported from
~/dev/omnist/omnist/formats.py’s read_yaml/write_yaml/check_yaml;
see omnist/src/formats/yaml.rs’s
module doc for the full detail.
#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::yaml::{read_yaml, write_yaml};
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();
let text = write_yaml(&doc, true, None).unwrap();
let doc2 = read_yaml(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}
Scalar-tag resolution: PyYAML’s rules, not yaml_rust2’s
yaml_rust2’s own resolver only recognizes true/false for booleans
(YAML 1.2 core schema). This module ignores that and re-implements PyYAML’s
YAML-1.1 implicit-resolver regexes instead, live-checked against PyYAML
(this project’s Python reference): yes/no/on/off (and case
variants) are also booleans; bare y/n are not and stay strings.
Quoted scalars are never auto-typed, matching PyYAML (implicit resolution
only applies to plain-style scalars).
Merge keys (<<)
yaml_rust2 has no built-in merge-key support; this module implements the
YAML merge-key spec directly (a << key’s value must be a
mapping or sequence of mappings; anything else is parse.codec-syntax, see
the alias expansion limit).
An explicit tag decides what a << is (D-27): a plain << and a << tagged
!!merge are merge keys, with the same shape rule and the same alias
accounting. A quoted << and a !!str << are the string <<, an ordinary
key that merges nothing ({!!str <<: *p} is an edge << holding a copy of
p). A !!merge on anything but << is not a supported tag.
The order is normative (omnist-spec formats/yaml.md): merged entries come
first, in source order (<<: [*a, *b] is a’s entries, then b’s – never
reversed), then the mapping’s own. A key supplied more than once resolves to
one edge at the position of its first occurrence, carrying the mapping’s own
value when it writes one, otherwise the earliest alias’s. A merged mapping’s
own << is flattened first, so a grandparent’s entries arrive before the
parent’s.
Duplicate keys are rejected (C-12)
A mapping that writes the same key twice, in block or flow style and at any
depth, fails the read with parse.codec-syntax: a: 1 / b: 2 / a: 3 is
an error, not the edges a = 3, b = 2. Keys compare as strings, so a and
"a" are one key; the same key in two different mappings is fine; a key a
merge key supplies is not a repeat of the same key written in the mapping,
which overrides it. This is the opposite of JSON, where the last value wins
(C-11). The position reported is 1:1: the reader finds the repeat after
the event stream has been built into a tree, which keeps no positions, and
E-31 lets an implementation report that when it has no better one.
!!omap, !!pairs and !!set are refused (C-13)
A node tagged !!omap, !!pairs or !!set, whatever its kind or content, is
refused with the code format.tag-forbidden at path $. It is well-formed
YAML outside the supported profile, so it is a DocumentError and not a
syntax error. Write the plain sequence of single-key mappings, or the plain
mapping, instead; every other tag keeps the reading it had.
Byte-order marks
A leading U+FEFF is stripped once, before the YAML library sees the text
(D-15); a second is rejected at 1:1 as parse.codec-syntax (D-21), because
yaml_rust2 would otherwise keep it as part of the first key. The one shape
this costs is an unquoted first key beginning with the mark; quote it. The
writer quotes any string that starts with the mark, so written YAML always
reads back.
Alias expansion limit (D-18, D-19, D-20)
The reader enforces omnist-spec section 2.4.1: for every candidate node a
it computes E(a) = W(a) / S(a), where S is the number of value slots
written in a’s own definition (an alias counts as one slot) and W the
number of slots materialized when a is expanded (an alias contributes its
target’s whole W). It rejects the input with document.limit.alias-expansion
at path $ when any E exceeds the maximum, which is 50 by default
(omnist::formats::yaml::DEFAULT_MAX_ALIAS_EXPANSION).
- Candidates are every anchored node, and every mapping and sequence
whether anchored or not: the document root, an unanchored container that
merely holds aliases, and a mapping written inline as a merge source
(
<<: {a: 1}). Scalars are never checked. Each candidate is measured over its own subtree, so one amplifying mapping is rejected where it sits, not diluted by a large document around it. - A plain alias (
*b) is one slot inSand contributesW(b)toW. A merge-key reference (<<: *b) is one slot inSand contributesW(b) - 1(the container is flattened into the referring mapping, not reproduced).<<: [*p, *q]is one slot inS, no slot of its own inW, and each member contributesW - 1. - A sequence in merge-value position is a carrier, anchored or not
(D-18a).
<<: &s [*p, *q]is the unanchored carrier with a name attached: no slot inWorS, and not a candidate.<<: *s, an alias to a sequence, contributes the sum over its members ofW - 1(notW(s) - 1) and one slot, the<<entry, inS. An ordinary values: &s [*p, *q]stays an ordinary candidate withW = 1 + sum W(member), and a plain alias to a carrier (t: *s) materializes that list. A mapping written inline in a carrier adds its written slots, less its container, to the referrer’sS; one anchored there is a candidate where it is defined. - Malformed merges are syntax errors (D-18a). A merge value must be a
mapping or a sequence of mappings. A scalar value, a scalar member of a
carrier, a sequence inside a carrier (
<<: [[{a: 1}]]or<<: [*s]wheresis a sequence) and an alias to a scalar or to a sequence that holds anything but mappings areparse.codec-syntaxat the offending node’sline:col, found in the same pass and before anything is counted. They win over everydocument.limit.*code, so a document holding both a bomb and a malformed merge reports the syntax error.<<: []merges nothing and is accepted. Wis a structural upper bound, deliberately blind to key collisions:q: &q {<<: *p, k: 9}counts the mergedkeven though the local one displaces it. The reader never refinesWdownward.- The check runs before anything is materialized (D-19). It consumes
yaml_rust2’s event stream in one pass with each anchor’sWmemoized, so the work is linear in the input however often a definition is referred to; the events are buffered and only replayed into the tree builder once every candidate has passed.WandSareu64with saturating arithmetic, andEis checked as each container completes, so no storedWexceedsmax x Sof the node in hand. - A self-referential anchor (
a: &A {b: *A},a: &A {<<: *A}, or a cycle through other definitions) has unboundedWand is rejected under the same code (D-20); no finiteEis computed and nothing cyclic is built.
The maximum is configurable
#![allow(unused)]
fn main() {
use omnist::formats::yaml::{YamlReadOptions, read_yaml_with};
let options = YamlReadOptions::default().with_max_alias_expansion(200);
let doc = read_yaml_with("a: &x [1, 2]\nb: *x\n", &options).unwrap();
assert_eq!(doc.root().get("b").len(), 2);
}
read_yaml uses the default. YamlReadOptions::max_alias_expansion is a
u32: 0 selects the default 50 (a zero or unset value never widens the
limit), and a value above 10000 is refused by read_yaml_with (and by
YamlReadOptions::validate) with an uncoded DocumentError rather than
clamped. “No limit” is not a legal setting (D-10). The command-line tool
always reads with the defaults.
YamlReadOptions::max_expanded_slots (the expanded size
limit) follows the same convention: 0 selects the
default 1,000,000, and a value above 10,000,000 is refused.
#![allow(unused)]
fn main() {
use omnist::formats::yaml::{YamlReadOptions, read_yaml_with};
let options = YamlReadOptions::default()
.with_max_alias_expansion(100)
.with_max_expanded_slots(5_000_000);
assert!(options.validate().is_ok());
let doc = read_yaml_with("a: &x [1, 2]\nb: *x\n", &options).unwrap();
assert_eq!(doc.root().get("b").len(), 2);
}
What the default accepts and rejects
Ordinary configuration reads far below the default: a <<: *defaults block
reads E = 1.00 for the anchored defaults, 100 compose services each merging
a 20-key defaults anchor read about 7, and a scalar anchor aliased 500 times is
exactly 1. But a mapping that merges an n-key anchor and writes one key of
its own has W = n + 2 over S = 3, so E = (n + 2) / 3: merging a
150-key anchor into such a mapping reads E ~ 50.67 and is rejected at the
default, while 148 keys (E = 50.00) is accepted (only E > max rejects).
Raise max_alias_expansion (up to 10000) for configurations that large.
It is a ratio, not a size cap
E bounds the expansion of each node relative to what that node writes. It
bounds the whole document too: W(root) <= max x S(root). It does not
bound a scalar-heavy document that is large without any aliasing, and the
node cap below (Limits::max_nodes, 1,000,000 containers by default, reported as
document.limit.nodes at $) is a separate, absolute size limit that the reader
still applies after the check has accepted the input. Neither limit stands in
for the other: a document can sit far under the node cap and still be refused
for its ratio (a 100-key block aliased 100 times at the document root), and a
document of unaliased scalars can pass the ratio check at E = 1.00 and still
be refused for its size. Parse cost of the input text itself is linear and is
paid before and independently of both. The ratio is not the only absolute
bound on a document with aliases, though: see the next section.
Expanded size limit (D-22)
The ratio bounds amplification, not absolute size: a large document in which
every container sits just under the maximum E passes D-18 and can still
allocate gigabytes (omnist-spec#125 measured 30,000 such containers, about 3.2
million slots, at 2.5 GB in the Go port). So the reader also enforces a
maximum expanded size: when the input contains at least one alias or merge
key, W(root), the value slots the whole document materializes, greater
than the maximum is rejected with document.limit.expanded-size at $;
equal to it is accepted. The default is 1,000,000 slots
(DEFAULT_MAX_EXPANDED_SLOTS), configured with
YamlReadOptions::max_expanded_slots (0 selects the default; above
10,000,000, MAX_EXPANDED_SLOTS_CEILING, the spec’s recommended ceiling, is
refused).
- Alias-free documents are exempt, however large. An input with neither an
alias nor a merge key (an anchor nothing refers to is not an alias) is read
as a plain JSON or OML file of the same size would be: its size is the
node cap’s and the caller’s own input-size bound’s business. This is a
cliff by design: a plain file of two million slots passes, and adding one
alias subjects it to the cap. A
<<merge key with no alias, such as<<: {a: 1}, also subjects the document to it. A quoted or explicitly tagged<<is not decided by the spec and is not counted as a merge key here. - It runs after the ratio check. The check is made at the root once every
candidate has passed D-18, and before anything is materialized, from the
same single memoized pass with the same saturating counts: a saturated
W(root)exceeds every maximum. An input that crosses both limits reportsdocument.limit.alias-expansion; neither limit implies the other. Wis the structural count, blind to key collisions, so a document whose merged keys are overridden can be refused though it materializes fewer slots.- The node cap counts containers. This port’s materialization cap is
1,000,000 nodes (
document.limit.nodes), the spec’s reference default, and a node is what D-9 counts: a mapping or a sequence. Keys and scalar values are not nodes, so a flat mapping of any number of scalar entries is one node, and the spec’s 1,000-service x 60-key compose example (W= 62,063) is accepted. An alias is charged for the containers it clones. The cap isYamlReadOptions::limits.max_nodes(with_limits). Through 0.6.0-alpha it was 100,000 and counted keys and values (DIV-11, omnist-rs#189).
Native temporal type on read, but no bare-time literal, and a looser input grammar than JSON
A bare YAML timestamp reads as a genuine Scalar::Date (no T) or
Scalar::Datetime (has one) (issue #105) – never Scalar::Time: YAML’s
own normalize_timestamp grammar always requires a date component, so
there’s no bare-time literal to produce one from. YAML’s timestamp grammar
is looser than JSON’s (space-separated date/time, single-digit month/day,
a bare Z suffix, no zero-padding); this module normalizes any such
spelling to the same canonical, zero-padded, T-joined ISO shape PyYAML’s
own datetime.isoformat() would produce – so 2001-12-14 21:59:43.10 -5
round-trips to 2001-12-14T21:59:43.100000-05:00, not its original
spelling. A timestamp-shaped string naming a calendar/clock value that
doesn’t exist (2024-13-01) is a ParseError, matching PyYAML’s own
construction-time failure.
On write, only a genuine Scalar::Date/Datetime writes bare; a plain
Scalar::Str that merely looks like one always writes quoted, matching
Python exactly (see Python
divergences).
A genuine Scalar::Time (e.g. from OML’s own bare-time grammar, or a
schema-directed upgrade) has no native YAML spelling at all and always
writes quoted.
Writing: escapes and temporals (C-14, E-37)
A string holding a character YAML 1.1 does not print is written
double-quoted with an escape, never raw in a plain scalar: U+0000 to U+0008,
U+000B, U+000C, U+000E to U+001F, U+007F and U+0080 to U+009F (except U+0085,
which keeps \N) as \xXX, and U+FFFE and U+FFFF as \uXXXX, always with
lowercase hex digits.
A time has no YAML 1.1 type (an unquoted 12:00:00 reads back as a
sexagesimal integer and 12:00:00.5 as a float), so every time is written as
a double-quoted string, seconds included ("12:00:00+01:00"), and check_yaml
and write_yaml report format.temporal-stringified at its path (E-37). A
datetime is written bare with the T separator and its seconds
(2024-01-15T12:00:00).
Native NaN/Infinity – no lossy adjustment here (unlike JSON)
YAML’s float grammar has native .nan/.inf/-.inf tokens, so unlike
write_json, write_yaml never substitutes null for a special float.
The only adjustment check_yaml ever records is forcing double-quoted
style for a string containing U+0085 (NEL), which PyYAML’s default styles
would otherwise normalize away as a line break.
Legacy sexagesimal integers (H:M:S-shaped)
YAML 1.1’s implicit-int resolver also recognizes a colon-separated
sexagesimal form – 12:00:00 resolves to Scalar::Int(43200), not a
string. This module’s resolver folds each :-separated group as
acc*60 + group (checked arithmetic; overflow reports the same
out-of-range ParseError as an oversized plain integer), requires no
leading zero on the first group, and constrains later groups to 0..=59
– so 01:20, 1:60, and 0:0:1 all stay plain strings (each violates
one of those rules), matching PyYAML’s own grammar. Confirmed by omnist-rs
issue #87, found while building the conformance harness against
omnist-spec.
The sexagesimal float is read too (C-17): 1:30.5 is the number 90.5
(^[-+]?[0-9][0-9_]*(:[0-5]?[0-9])+\.[0-9_]*$, folded as acc*60 + group
with the fraction on the last group). The spellings YAML 1.1’s core schema does
not resolve stay strings: 0o17, 08, 09, 0:5, 1e3, 1.0e3.
Mapping keys are implicitly typed too (the “Norway problem”)
Every mapping key is run through the same implicit-type resolver as
values, matching PyYAML: a key like on: is rejected (it resolves to
Bool(true), not a string), and so is any other non-string-resolving key
shape (int-, float-, sexagesimal-shaped, null-shaped). The rejection is
a DocumentError (document.unlabeled-element) at the Document path of
the mapping that holds the key ($ for the top-level mapping, $.a for
a: {on: 1}, $.a.b deeper, and for a mapping inside a sequence the index
appears only when the label repeats, E-10: $.a for a: [{on: 1}], $.a[1]
for a: [{x: 1}, {on: 1}]; through 0.7.0-alpha it was $ at every depth),
with a Python-parity message (e.g.
object key True is not a string, object key 1.0 is not a string –
including keeping the .0 on whole-number floats). Ordinary string keys,
and non-boolean words like bare y/n, are unaffected. Confirmed by
omnist-rs issue #88, found and fixed alongside #87 above.
Integer digit cap
Same 4300-digit cap as json.rs/oml.rs/toml.rs (Limits::max_int_digits,
set through YamlReadOptions::with_limits; an over-long integer is a
DocumentError document.limit.int-digits at its Document path), applied to a
plain decimal integer scalar’s digit run before parsing; arbitrary-precision
above that (see formats/json.md).
The legacy sexagesimal fold (above) enforces the identical cap on its
folded result, not any one group – issue #104: an unbounded
BigInt fold with no such check would let a many-group literal build an
arbitrarily large integer, a resource-exhaustion regression the fold’s
old i64 overflow used to prevent as an unplanned side effect.
TOML
omnist::formats::toml::{read_toml, write_toml, check_toml}. Ported from
~/dev/omnist/omnist/formats.py’s read_toml/write_toml/check_toml;
see omnist/src/formats/toml.rs’s
module doc for the full detail.
#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::toml::{read_toml, write_toml};
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();
let text = write_toml(&doc, true, None).unwrap();
let doc2 = read_toml(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}
No null – the one lossy TOML adjustment
TOML has no null at all. Writing a node that contains a null fails the write
unconditionally with write.unsupported-value (spec sections 8.3.8 and 8.3.9),
whatever strict says: dropping the field, as this port once did and recorded
as a null.omitted warning, erased the edge’s existence with no trace on
read-back (omnist-rs#160 retired that behaviour). The XML writer refuses a
null the same way (spec C-10); see XML.
Integers are arbitrary precision; the digit limit counts decimal digits
A TOML integer is arbitrary precision here, in every radix (spec D-2). The
limit is Limits::max_int_digits (default 4300; read_toml_with changes it),
and it counts the decimal digits of the value (D-28), whatever the
spelling: 0xFFF is 4095, four digits; underscores are separators, not digits.
A value over the limit is a DocumentError with code
document.limit.int-digits at the integer’s Document path, never a syntax
error. A 3,600-digit hex literal is about 4,335 decimal digits, so it is
refused at the default; a 4,301-digit binary literal is about 1,295, so it
reads.
toml_edit holds an integer in 64 bits and refuses a longer one with no value
to read. This reader works around that: it takes the byte range from the
refusal, reads the literal itself (any radix, optional sign, underscores),
swaps in a one-character stand-in of the same width so every other offset keeps
its place, parses again, and puts the real value back by offset when it builds
the Document. A decimal literal already longer than the limit in its own
spelling is not converted at all; a placeholder stands in for it, so it is
refused without paying the superlinear conversion. A syntax error elsewhere
in the text still wins over a limit. The literals are found in one scan, so the
cost stays linear however many there are.
Python’s tomllib exempts hex, octal and binary literals from CPython’s
4,300-digit string-conversion guard; this port does not, because D-28 counts
the value’s decimal digits for every radix.
A float whose magnitude overflows (1e999, -1e999) reads as positive or
negative infinity (D-29), by the same route.
Writing an oversized integer to TOML needs no workaround: the writer renders integers as plain digit text.
Nesting past about 80 levels
toml_edit has a recursion limit of its own, about 80 levels of table header,
inline table, array or dotted key. TOML nested between about 81 levels and the
configured maximum depth (200 by default) therefore fails with
document.limit.depth at $ even though the configured limit has not been
reached; the message names toml_edit as the cause. This is a limit of the
parser library, not of the format or of the Document model, and raising
max_depth does not lift it.
Native temporal types, at most six fractional digits
TOML has four first-class temporal literal forms (local date, local
time, local datetime, offset datetime), stricter-shaped than YAML’s –
toml_edit itself fully validates calendar/clock fields at parse time
(2024-02-30, 25:00:00, and a +25:00 offset are all parse errors, not
accepted-then-rejected-later). A fractional
second holds at most six digits (the OML grammar’s 1*6DIGIT): zeros beyond
the sixth are insignificant and dropped, but a non-zero seventh or later
digit fails the read (00:32:00.9999999 is a parse.codec-syntax error)
rather than being truncated as tomllib does (fail, don’t invent; through
0.11.0-alpha it was silently truncated). A numeric UTC offset is preserved exactly
across read+write (no offset-erasure bug).
On read, toml_value_to_value constructs the real Value::Date/Time/
Datetime variant directly from toml_edit::Datetime’s own already-
validated date/time fields (issue #105) – previously this discarded
into a plain Value::Str. On write, only a genuine Date/Time/
Datetime variant is written as a native, unquoted TOML literal; a plain
Scalar::Str that merely looks like a date always writes as a quoted
string, matching Python’s write_toml exactly (a plain Python string that
looks like a date, e.g. tomli_w.dumps({'a': '1979-05-27'}), also writes
quoted). This resolves the shape-guessing divergence issue #99 first
introduced for OML and issue #105 now closes here too – see Python
divergences.
A Time value carrying a UTC offset has no native TOML spelling at all
(only datetime can carry an offset) and always writes double-quoted, with
format.temporal-stringified reported at its path (E-37). Every writer writes a
time’s seconds (12:00 is written 12:00:00) and keeps the offset and every
fractional digit; a datetime uses the T separator.
XML
omnist::formats::xml::{read_xml, read_xml_with_schema, write_xml, check_xml}. Ported from
~/dev/omnist/omnist/formats.py’s read_xml/write_xml/check_xml; see
omnist/src/formats/xml.rs’s module doc
for the full detail.
XML needs exactly one document element, so (unlike JSON/YAML/TOML) an example document must be wrapped under a single top-level element.
#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::formats::xml::{read_xml, write_xml};
// `age` is a `Value::Str`, not `Value::Int`: XML text carries no type
// information (see this doc's "Text stays untyped" note below) -- keeping
// it a string in the first place is what makes this round trip lossless.
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Str("37".to_string()));
let mut root = IndexMap::new();
root.insert("person".to_string(), Value::Object(fields));
let doc = Doc::of(&Value::Object(root)).unwrap();
let text = write_xml(&doc, true, None).unwrap();
let doc2 = read_xml(&text).unwrap();
assert!(doc.eq_doc(&doc2));
}
Structural difference: interleaving-preserving, not grouped
Unlike the other three codecs, this module goes through Doc::from_raw/
Doc::to_raw (RawNode), not Doc::to_grouped – XML element order can
interleave distinct labels arbitrarily (<b/><c/><b/>), which a JSON-shaped
IndexMap can’t represent.
quick-xml: no advisory, no DTD/entity-expansion at all
cargo audit against this crate’s full dependency tree (29 crates,
including quick-xml 0.41.0) on 2026-07-26 against the RustSec advisory
database found zero matches – unlike TS’s port, which carried an
unfixable fast-xml-parser advisory (omnist-ts#38). quick-xml also has
no DTD/external-entity expansion support at all (only the five predefined
XML entities are recognized) – XXE-safe by construction, not by
configuration (Python’s read_xml needs defusedxml instead of the stdlib
ElementTree for the same protection).
The data-XML profile: what the reader refuses
read_xml reads a deliberately narrow subset (omnist-spec’s
data-XML profile)
and refuses the rest, rather than ignoring it. Each refusal is a
DocumentError at path $ whose code is:
| Construct | code |
|---|---|
any DOCTYPE declaration (refused on sight, even if nothing uses it) | format.dtd-forbidden |
| an entity reference other than the five predefined ones, in text or in an attribute value | format.entity-forbidden |
| text alongside child elements | format.mixed-content |
These are refusals, not syntax errors: the input is well-formed XML. The
refusal is raised only after the whole document has proved well-formed, so
malformed XML is always parse.codec-syntax (never misreported as a
refusal). Numeric character references and the five predefined entities stay
legal; comments, CDATA sections and processing instructions are inert.
Characters and character references on read (C-15, C-16)
XML 1.0’s Char production admits U+0009, U+000A, U+000D, U+0020 to U+D7FF,
U+E000 to U+FFFD and U+10000 to U+10FFFF. A character outside it, written
directly anywhere in the input (text, a CDATA section, an attribute value, a
comment, a processing instruction, a name), is parse.codec-syntax, as is a
numeric character reference that is malformed (&#;, &#x;, A without
its ;, A, a sign, a non-digit, a value too large for an integer) or
names a code point outside Char (�, , , a surrogate,
, , � and above). The check covers an attribute
value’s references too, although attributes are otherwise dropped. A valid
reference reads as the one character it names. An encoding= pseudo-attribute
in the XML declaration is ignored whatever it names (C-15): the bytes are read
as UTF-8.
Illegal characters fail the write
XML 1.0 cannot represent a raw C0 control character other than tab, LF and CR
(nor U+FFFE/U+FFFF). A string holding one fails write_xml with
write.unsupported-value at that string’s path, strict or not; it is no
longer replaced with U+FFFD.
Namespaces: a disclosed simplification
quick_xml runs in non-namespace-aware mode here; a namespaced tag’s local
name is taken by stripping a lexical prefix: up to the last :, which
coincides with Python’s ElementTree-based behavior for the common case
(a declared, in-scope prefix) but does not resolve prefixes through
xmlns declarations. Namespaces are outside this issue’s spec.
Text stays untyped until materialize
XML’s grammar carries no type information – <m>1</m> and <m>hi</m> are
syntactically identical, a bare text node. read_xml builds every leaf as
a plain string unconditionally; no int/float/bool inference happens at
parse time.
Writing a non-string scalar (bool/int/float) to XML now honestly
reports it: XML has no native typed literals, so it reads back as a
string, not its original type (check_xml’s format.value-stringified
adjustment; named value.stringified through 0.7.0-alpha, renamed to the
code the spec’s section 8.3.8 names).
A date, time or datetime leaf is reported as format.temporal-stringified
instead, never as format.value-stringified as well (E-5): a leaf takes one of
the two codes, once, at the leaf’s own path (indexed when the label repeats).
The text is the value’s ISO-8601 spelling with its seconds always written
(12:00 is written 12:00:00, E-37).
null has no XML form
XML has no null token, and a childless element reads back as the empty string
(<root><s/></root> is s holding "", not a null). A written null would
therefore be indistinguishable from the different, valid Document holding the
empty string, so the writer fails (spec C-10): write_xml returns a
WriteError with code write.unsupported-value and the Document path of the
null leaf, E-10 indexed ($.root.item[1] for the second of three item
edges), whatever strict says. check_xml previews the same refusal as an
error-severity write.unsupported-value entry.
Through 0.8.0-alpha this port wrote an empty element and recorded a
null.omitted warning, a code the spec’s table never had. The code is gone
with the behaviour (0.9.0-alpha); a script that matched null.omitted, or
relied on a null becoming <a />, must change. TOML’s null fails the same
way.
Schema-guided pretyping (spec §2.2 / issue #114)
Because XML text is untyped and materialize strictly rejects coercing plain strings to boolean/integer/number, [read_xml_with_schema] performs schema-guided pretyping on the raw XML tree before materialization (spec §2.2 / issue #114).
An integer leaf with more decimal digits than the D-9 limit
(Limits::max_int_digits, default 4,300; D-28 counts the digits of the value)
is refused with document.limit.int-digits at the leaf’s Document path
($.r.n, or $.r.n[1] when the label repeats, E-10), not left a string for
materialize to reject as materialize.inexact-conversion (omnist-rs#204).
A number leaf is exempt from the limit however long its integer part (D-28):
past binary64 it reads as +Infinity/-Infinity (or a zero on underflow, D-29),
never as a string and never as materialize.inexact-conversion (through
0.11.1-alpha a number leaf over the limit stayed a string).
#![allow(unused)]
fn main() {
use omnist::formats::xml::read_xml_with_schema;
use omnist::osd::parse_schema;
let schema_osd = r#"
record Address { "street": string, "city": string }
record LineItem { "sku": string, "qty": integer, "price": number }
record Order {
"id": string,
"status": string,
"total": number,
"address": Address,
"items" [1,]: LineItem,
"coupon" [0,1]: string,
}
record Root { "order": Order }
root Root
"#;
let schema = parse_schema(schema_osd).unwrap();
let xml = r#"<order>
<id>A1</id>
<status>shipped</status>
<total>29.97</total>
<address><street>1 Main</street><city>London</city></address>
<items><sku>W</sku><qty>3</qty><price>9.99</price></items>
<items><sku>G</sku><qty>1</qty><price>9.99</price></items>
</order>"#;
let doc = read_xml_with_schema(xml, &schema).unwrap();
assert!(schema.validate(&doc.root()).ok());
}
OML
omnist::oml::{read_oml, write_oml}. Ported from ~/dev/omnist/omnist/oml.py
(issue #10); see omnist/src/oml.rs’s module doc
for the full detail. OML (Omnist Markup Language) is omnist’s own native
format: every Document round-trips through it exactly, with no
adjustment ever needed – unlike JSON/YAML/TOML/XML, each of which has at
least one lossy corner (see their own pages).
#![allow(unused)]
fn main() {
use indexmap::IndexMap;
use omnist::document::{Doc, Value};
use omnist::oml::{read_oml, write_oml};
let mut fields = IndexMap::new();
fields.insert("name".to_string(), Value::Str("Ada".to_string()));
fields.insert("age".to_string(), Value::Int(37));
let doc = Doc::of(&Value::Object(fields)).unwrap();
let text = write_oml(&doc.to_raw(), 2).unwrap();
let raw2 = read_oml(&text).unwrap();
let doc2 = Doc::from_raw(raw2).unwrap();
assert!(doc.eq_doc(&doc2));
}
Core vs. Extended
read_oml implements the OML-Core grammar in full, plus the
OML-Extended raw-string ('...') and triple-quoted multiline-string
("""...""") spellings on read only. write_oml only ever emits OML-Core
double-quoted strings, matching the Python reference exactly – reading
back a raw-string or triple-quoted literal never round-trips to that same
spelling, only to an equivalent Core one.
RawNode, not Value – the only codec (with XML) that needs it
Value::Object’s IndexMap can only represent “repeated label” as a
contiguous run (an array under one key). OML must round-trip arbitrary
interleaving of repeated labels losslessly – its whole reason for
existing – so read_oml/write_oml work over RawNode’s literal edge
list, never Value.
Temporal literals: shape-checked, then validated, then canonicalized
date/time/datetime literals are recognized by shape in this module’s
own scanner, then validated by the same shared
crate::schema::is_iso_date/is_iso_time/is_iso_datetime checks
schema.rs and every other codec use. A recognized-but-invalid literal
(2024-02-30) is a ParseError, never silently accepted. A valid literal
becomes a genuine Scalar::Date/Time/Datetime (issue #105) – the
scanner threads which of the three kinds it matched (TemporalKind)
through to the parser so it constructs the exact right variant, not a
generic string.
time/datetime literal text is canonicalized on read (issue #90,
fixed while building the conformance harness against
omnist-spec): a missing :SS is filled to :00, and an under-padded
fractional-second component is zero-padded to 6 digits. A time or
datetime built through the API with a non-zero digit past the sixth is
written as a quoted string and reported as format.temporal-stringified
(the literal’s 1*6DIGIT cannot spell it); zeros past the sixth are dropped. date literals
have no optional grammar components and pass through unchanged. This
means a bare 12:00 reads as Scalar::Time("12:00:00"), not
"12:00" – see the corrected note in Python
divergences
for what changed from this port’s earlier, stronger byte-for-byte claim.
Bare vs. quoted on write: real variant, not shape-guessing
A leaf writes bare (no quotes) only when it genuinely holds a
Scalar::Date/Time/Datetime – never by guessing from a
Scalar::Str’s shape (issue #99). The pre-#99 writer wrote any
date/time/datetime-shaped string bare, regardless of provenance: a plain
JSON string like "2024-01-01" got silently promoted to a genuine OML
temporal literal on write, corrupting it on the next read (a different
Document, per ch.4’s grammar). Confirmed live and fixed.
Issue #99 fixed this with a RawNode::TemporalLeaf write-hint tag layered
on top of Scalar::Str, since Scalar had no temporal variant of its own
at the time. Issue #105 gave Scalar real Date/Time/Datetime
variants, making that tag redundant – it’s been removed, and the writer
now just matches on Scalar’s own variant directly. The two real sources
that produce a genuine temporal variant are unchanged:
- OML’s own bare-literal grammar.
read_oml’s parser constructs aScalar::Date/Time/Datetimefor a genuinely-read bare literal token; an ordinary quoted string (however it’s shaped) stays a plainScalar::Str. - Schema-directed
materialize. Upgrading a field to a Date/Time/Datetime-kinded schema constructs the real variant too, so a materialized document writes its temporal fields bare through OML.
Temporal output (E-37, OML-30)
A time or datetime is always written with its seconds (12:00 is written
12:00:00), the offset after the seconds and any fraction, and a datetime with
the T separator. The value is never altered.
Integer digit cap and i64
Same 4300-digit cap as json.rs/yaml.rs/toml.rs (Limits::max_int_digits,
changed with read_oml_with), and the same i64-backed representational
ceiling – see formats/json.md. An over-long integer is a
DocumentError (document.limit.int-digits) at the integer’s Document path
through read_oml_with; read_oml keeps reporting it as a ParseError at the
text position. The depth and node limits are document.limit.depth and
document.limit.nodes at $; a node is a brace subtree or the document’s own
edge list, never a scalar.
--arrays is not yet implemented
Python’s write_oml supports an arrays=True mode that collapses runs of
same-label edges into [...] array syntax. This port’s write_oml has no
such parameter yet – separate, not-yet-ported library work tracked by the
CLI’s own --arrays “not supported yet” error (see cli.md).
Python divergences
A single index of every disclosed, live-checked behavioral divergence
from the Python reference (~/dev/omnist) found across this port’s
merged PR history. Each entry names what Python does, what this port
does, the reasoning from the PR that introduced it, and whether it’s a
permanent representational choice or something revisitable later.
This page is an index, not a replacement for
docs/limitations.md or the per-format pages under
docs/formats/ – those already carry the full detail for
the i64/temporal/any gaps. Where an entry below is already documented
there, this page states it briefly and links out rather than repeating
it.
Why this page exists alongside omnist-spec’s divergence ledger
(Sec9): the ledger
tracks current, named, cross-port divergences (D-1, D-2, …) from
the spec – behavior every implementation must agree on. This page
tracks something the ledger doesn’t: divergences from the Python
reference implementation specifically, each cited to the exact PR that
introduced or resolved it, with the live cross-check that found it. Most
of these are permanent, disclosed representational choices (i64 vs
arbitrary precision, ASCII-only XML digit coercion) that are conformant
with the spec on both sides – not spec violations, so they have no
ledger entry at all. Check the ledger for current cross-port status;
check this page for why this port’s own output differs from Python’s
on a specific input, and which PR made that call.
Integer representation: i64 vs Python’s arbitrary-precision int
Python: int is arbitrary precision; a decimal literal is only
rejected past sys.set_int_max_str_digits’s 4300-digit cap.
Rust: document::Scalar::Int is i64 (max ~19 significant decimal
digits), so any literal over that – even one comfortably under the
4300-digit cap – is a ParseError (“out of range for a 64-bit
integer”).
Origin: PR #5 (document.rs) chose i64 for the Document model itself
and, as a direct consequence, dropped Python’s _check_int_digits guard
entirely rather than ship “permanently-dead code with no reachable
branch to test” – the guard exists in Python to stop a superlinear
str() conversion of a huge int, which can’t happen when the type
tops out at 19 digits. PR #11 (oml.rs) then had to reintroduce a
4300-digit cap anyway, but at the text-scanning layer (MAX_INT_DIGITS
in oml.rs) rather than the Document model, because OML source text is
“the one place an arbitrarily-long digit run can actually reach the
parser” before the i64 conversion fails. PR #17 (json.rs) verified
live against CPython’s json.loads that the same 4300-digit cap fires
independently of the 64-bit ceiling, then applied the identical
text-layer cap. PR #23 (xml.rs) found a genuinely different Python
behavior for this same gap: Python’s XML _coerce falls through a
20-4300-digit numeral to a float rather than erroring, so the Rust port
matches that specific control flow (falls through to Scalar::Float)
instead of raising.
Permanent: yes. It’s a direct, disclosed consequence of #5’s
Document-model decision (Scalar has exactly five variants, Int is
i64), applied consistently rather than special-cased per format. See
limitations.md
for the format-by-format table.
TOML’s hex/octal/binary digit-cap
Python: tomllib applies the 4300-digit cap to decimal literals
(same sys.set_int_max_str_digits mechanism as JSON), but exempts
hex/octal/binary (power-of-two-base) literals from that cap entirely –
confirmed live: tomllib.loads("x = 0x" + "f"*10000) parses with no
error at all.
Rust: applies the spec’s rule (D-28) to every radix: the limit counts the
decimal digits of the integer’s value, so a 3,600-digit hex literal
(about 4,335 decimal digits) is refused with document.limit.int-digits and
a 4,301-digit binary literal (about 1,295) reads. toml_edit itself holds an
integer in 64 bits, so the reader works out a literal it refuses and puts the
value back (see TOML). The difference from Python is only
that Python’s radix exemption is not replicated.
Permanent: yes. It follows the spec, not Python.
Date-shaped strings as native temporal literals (TOML/YAML) – resolved by issue #105
Formerly: Scalar had no temporal variant, so toml.rs/yaml.rs
could not distinguish “a string that happens to look like a date” from “a
value that was genuinely read as a date,” and shape-guessed: any
Scalar::Str whose contents matched the temporal shape check wrote back
as a native temporal literal, unlike Python, which only ever promotes a
real datetime object.
Now: issue #105 gave Scalar/Value real Date(String)/
Time(String)/Datetime(String) variants. toml.rs/yaml.rs construct
the real variant directly from toml_edit’s own already-validated
date/time fields (TOML) or from normalize_timestamp’s output (YAML) –
never by shape-guessing a Str – and write only a genuine temporal
variant as a native literal; a plain string that merely looks like a date
now stays a quoted string on write, matching Python exactly. This
divergence is resolved, not permanent – see
limitations.md for
what temporal kinds still don’t do (arithmetic).
Bare time literal round-tripping (OML, PR #11; corrected by issue #90)
Python: parses a bare time literal like 12:00 into a real
datetime.time object, then always writes it back via
isoformat(), which normalizes to 12:00:00 (seconds always present).
Rust, as of PR #11: Scalar stored the literal’s exact source
spelling and round-tripped it byte-for-byte, so 12:00 stayed 12:00 –
characterized at the time as “a stronger round-trip guarantee than
Python’s own, not a bug against Python’s docs,” no upstream issue filed.
Rust, as of issue #90 (found while building the conformance
harness against omnist-spec): that stronger guarantee
was itself a spec violation – omnist-spec’s OML grammar treats
time/datetime literal text as canonicalized on read, the same way
Python’s isoformat() normalizes it. read_oml’s scanner now fills a
missing :SS to :00 and zero-pads under-padded fractional seconds to 6
digits, so 12:00 now reads as Scalar::Str("12:00:00") – matching
Python’s output exactly, not diverging from it. date literals (no
optional grammar components) are unaffected.
Permanent: yes, but now in the sense of “matches Python,” not “diverges from it” – this entry is kept for history; the actual current behavior is not a divergence.
OML’s UTC-offset preservation (PR #11) / TOML’s identical case (PR #21)
Both codecs preserve an explicit UTC offset (-07:00/+07:00) exactly
on round-trip, and both normalize a bare Z suffix to +00:00 rather
than preserving Z literally – a normalization choice inherited from
yaml.rs’s normalize_timestamp, applied consistently across format
codecs rather than re-decided per format. Not a divergence from Python’s
observable output; noted here because it was explicitly cross-checked
(the omnist-ts#51-derived regression class) in both PRs.
infer()’s API shape: one Python kwarg vs two Rust functions
Python: infer(samples, root_name, allow_any=False) – a single
function, allow_any is a keyword argument.
Rust: infer::infer(samples, root_name) always behaves as
Python’s allow_any=False path (any ambiguous field is a
SchemaError); infer::infer_with_report(samples, root_name, allow_any)
is a second function that also accepts allow_any: true and returns
every recorded AnyFallback alongside the schema, mirroring Python’s
infer_with_report/AnyFallback pair rather than its single
infer(..., allow_any=...) surface.
Origin: PR #15 (infer.rs) scoped any out of inference entirely,
leaving infer with no allow_any parameter at all – an unforced
scoping choice, not something the deferred any governance question
required (see
limitations.md).
Issue #29 later flagged that scoping-out as a mistake and tracked
porting any for real. PR #33 (closing #29) fixed it by porting
allow_any as a second function, infer_with_report, rather than
adding an optional parameter to infer itself – an intentional
API-shape split from Python’s single-function-with-kwarg design,
flagged during PR #33’s review as a deliberate divergence worth
recording rather than silently carrying forward.
Revisitable: yes, in principle – collapsing back to a single function with a defaulted parameter is a compatible API change if ever desired, but the two-function split is deliberate for now.
XML: scalar coercion narrowing (PR #23)
Python: _coerce’s rules, live-checked directly rather than
assumed. Two narrowings this port discloses:
- A 20-4300-digit numeral (too big for
i64, still under the security cap) falls through toScalar::Float, matching Python’s own int-then-float control flow – covered under the generali64gap above, not a separate issue. - Unicode decimal digits (e.g. Arabic-indic digits) are not recognized by this port’s coercion – ASCII-digit-only, a narrowing from whatever Python’s coercion accepts.
Permanent: yes, treated as a disclosed narrowing rather than a bug.
XML: DTD/XXE safety by construction vs Python’s defusedxml dependency
Python: read_xml requires defusedxml instead of the stdlib
ElementTree, specifically to guard against XXE/DTD-expansion attacks –
an explicit dependency choice to close a real vulnerability class.
Rust: uses quick-xml 0.41.0 (tokenization only, no serde
feature), which has no DTD/external-entity expansion support at all.
XXE safety is a structural property of the crate, not a guard that has
to be separately verified or maintained.
Not a behavioral divergence in observable output – both are safe against XXE – but a divergence in how that safety is achieved, worth recording because it removes an entire dependency Python’s implementation needs. Permanent (a consequence of the crate choice in PR #23).
YAML: bool tag spellings and calendar-invalid timestamps (PR #19, bugs found and fixed, not divergences)
Two items from PR #19 are not divergences from Python – they were bugs in this port’s own WIP checkpoint, found via live cross-check against PyYAML and fixed to match Python exactly, before merge:
- Explicit
!!booltag construction was initially too narrow (only true/false spellings); PyYAML’sbool_valuesaccepts yes/no/on/off case-insensitively regardless of implicit vs explicit tagging. Fixed to match. - A timestamp-shaped scalar naming an invalid calendar/clock value
(
2024-13-01,2024-02-30, hour 25, tz+25:00) initially fell back silently to a plain string; PyYAML actually raises and fails the whole document. Fixed to raiseParseError.
Listed here for completeness (issue #39 asked for both), but neither is a live divergence today – both match Python’s behavior as of PR #19’s merge.
Not a divergence: the any-type gap and the i64/temporal gaps documented elsewhere
Two structural gaps referenced above are covered in full in
limitations.md rather than repeated here:
- The
any-type scoping gap (deferred pending the siblingomnistproject’s openness decision, per issue #29 and PR #33) –limitations.md. - The
i64representational ceiling, per format –limitations.md. - Temporal kinds having no arithmetic (a real variant, but an opaque
canonical string, not a
chrono/timevalue) –limitations.md.
Summary table
| Divergence | Permanent or revisitable | Origin PR |
|---|---|---|
i64 vs arbitrary-precision int | Permanent | #5, #11, #17, #23 |
| TOML hex/octal/binary uncapped in Python, capped here | Permanent | #21 |
| Date-shaped string becomes native literal on write (TOML/YAML) | No longer a divergence – resolved by #105’s real temporal variants | #19, #21, #105 |
| Bare time literal round-trips exactly instead of normalizing | No longer a divergence – corrected by #90 to match Python | #11, #90 |
infer()/infer_with_report() split vs single allow_any kwarg | Revisitable | #15, #33 |
XML coercion: over-i64 numeral falls to float | Permanent | #23 |
| XML coercion: ASCII-only digit recognition | Permanent | #23 |
XML XXE-safety by construction (quick-xml) vs defusedxml | Permanent (crate choice) | #23 |
Conformance against omnist-spec
This port has its own conformance-test harness (tools/conformance/)
against omnist-spec, the
language-agnostic upstream specification. It vendors omnist-spec as a
pinned git submodule (vendor/omnist-spec, currently commit 7575727,
the v0.36.0-beta tag) and
runs entirely against this crate’s own library code – it does not depend
on the Python or TypeScript ports’ implementations.
Two tracks, both wired into CI as a dedicated conformance job
(.github/workflows/ci.yml), gated on real fail count only, never on
skip count, per the spec’s section
8.5.5
reporting rule:
- Track 1 (
vendor/omnist-spec/conformance/fixtures/, directory-per-fixture, 11 operations): 19 passed, 0 failed, 0 skipped. - Track 2 (
vendor/omnist-spec/test-suite/, JSON-vector suite, 14-operation vocabulary): 765 passed, 0 failed, 28 skipped (of 793 vectors), diagnostics compared as(path, code)sets (section 8.5.2), not in code-agnostic mode. The runner exits non-zero on any failing vector and never on skips (E-22); it has no list of tolerated failures.
Neither track has a fail. Run it yourself:
cargo run -p conformance --bin self-test
cargo run -p conformance --bin runner
cargo run -p conformance --bin vector_runner
Every Track 2 skip, and why
All 28 skips are one spec category, E-20 “not yet implemented”; none is an
E-21 documented divergence. The 6 document-model/limits vectors were skips
through 0.6.1-alpha (304 / 0 / 34); the limits became runtime-configurable in
0.7.0-alpha and they now run and pass (310 / 0 / 28).
Adopting v0.36.0-beta (0.12.0-alpha). The suite grew from 731 to 793
vectors and the pin moved from v0.35.0-beta. Before any change the runner
reported 757 pass / 8 fail / 28 skip, and the 8 were: 3 bare JSON NaN,
Infinity and -Infinity tokens read as numbers (C-19, DIV-45); 3
nan-is-a-number vectors the runner failed because it compared NaN with
== (E-33, DIV-47); and 2 OML 4,301-digit integer vectors reported at the
text position 1:4 instead of $.a (D-28, DIV-46: the runner called
read_oml, whose signature keeps a position; it now calls read_oml_with).
Also fixed, with port tests only: the CLI --json payload carries the code and
Document path of any document-level error (DIV-46), and an XML number leaf of
any length reads as an infinity past binary64 (D-28, D-29, DIV-42). After the
adoption: 765 / 0 / 28; the 28 skips are the same OSD-OML extension vectors.
Track 1 stays 19 / 19.
Bug wave after v0.35.0-beta (0.11.1-alpha). The pin does not move: the suite stays 731 vectors (703 / 0 / 28). The wave fixes what the vectors cannot pin (an XML integer pretyped over the digit limit, a TOML or YAML fraction read past six digits, a multi-megabyte radix integer), each with the port’s own tests.
Adopting v0.35.0-beta (0.11.0-alpha). The suite grew from 522 to 731
vectors and the pin moved from v0.34.0-beta. Before any change the runner
reported 642 pass / 61 fail / 28 skip, and the 61 were: 18 label-quoting path
vectors (E-36: a Document path printed a label raw), 19 temporal-writing vectors
(E-37: the seconds field dropped in every format, no
format.temporal-stringified for a TOML time with an offset or for a YAML time,
and a YAML time written as an unquoted plain scalar), 17 XML vectors (C-15,
C-16: a character outside XML 1.0’s Char and a malformed or out-of-range
numeric character reference were accepted), 6 YAML escape vectors (C-14: a raw
control character inside a plain scalar, and the U+007F, U+0080 to U+009F and
U+FFFE/U+FFFF escapes) and 1 YAML sexagesimal-float vector (C-17). After the
adoption: 703 / 0 / 28, and the 28 skips are the same OSD-OML extension vectors
as before. Track 1 stays 19 / 19.
Adopting v0.34.0-beta (0.10.0-alpha). The suite grew from 367 to 522
vectors (the C-12, C-13, D-27, D-28, D-29, E-5 and E-35 vectors, among others)
and the pin moved from v0.33.0-beta. Before any change the runner reported 467
pass / 27 fail / 28 skip, and the 27 were: 4 TOML number vectors (an integer
beyond 64 bits and 1e999, D-2, D-28, D-29), 6 YAML duplicate-key vectors
(C-12), 2 !!merge merge-key vectors (D-27), 6 !!omap/!!pairs/!!set
vectors (C-13), 4 XML temporal leaves reported as format.value-stringified
instead of format.temporal-stringified (E-5) and 5 OML compact vectors (E-35:
the runner dropped the compact key and compared the expanded layout). Track 1
now compares the lint code exactly (omnist-rs#200). After the adoption:
494 / 0 / 28, and the 28 skips are the same OSD-OML extension vectors as before.
The runner fails a vector that sets compact on a format other than OML.
Adopting v0.33.0-beta (0.9.0-alpha). The suite grew from 338 to 367 vectors
(7 repeated-label path vectors, 10 input-size vectors, 7 OML-29 vectors, 5 XML
null vectors) and the pin moved from v0.28.0-beta. Before any change the runner
reported 318 pass / 21 fail / 28 skip, and the 21 were: 5 repeated-label paths
(E-10: the first occurrence carried no index, DIV-16), 7 OML-29 (a separator
after the colon, DIV-20), 5 over-cap input-size vectors (D-23, DIV-17) and 4
XML null writes (C-10, DIV-23). The 5 at-cap input-size vectors passed falsely
at that point, because the runner ignored declared_max_input_bytes: it passed
the vector against the port’s own default, which E-20a forbids. The runner now
passes the declared maximum through Limits::max_input_bytes, and a vector
carrying any other declared_* key, or one the runner does not honour on its
operation or format, is an E-20 skip rather than a run against the default.
After the adoption: 339 / 0 / 28, and the 28 skips are the same OSD-OML
extension vectors as before. Two further rules have no vector: C-9 (a writer
refuses a string with no UTF-8 encoding) is vacuous here, see
Limitations,
and the two OML reader fixes of omnist-rs#193 and #194 are pinned by unit
tests only (no vector covers them).
Earlier history: (v0.28.0-beta adds no vectors (still 338): its four programmatic-schema
rules, S-8’s $ path, S-22, S-23 and OSD-16/S-24, are pinned by no vector
(DIV-5), only by omnist/tests/spec_v028.rs; Track 2 stays 304 / 0 / 34.
v0.27.0-beta (338 vectors) added 7 alias-expansion vectors for the empty merge
sequence <<: [] (D-18a); the port already merged nothing for it, so all 7
passed with no code change. v0.26.0-beta (331 vectors) grew
alias-expansion.json to 35 vectors (D-18a, D-22 and the malformed-merge
syntax errors); all run and pass, with declared_max_alias_expansion and
declared_max_expanded_slots passed through YamlReadOptions for the vectors
that carry them. Before this port adopted it the baseline was 289 pass / 8
fail / 34 skip: the anchored merge carrier and the alias to a merge sequence
were wrongly rejected, 4 expanded-size vectors parsed OK, and a merge after a
bomb and a merge sequence of sequences were not reported as syntax errors.
v0.25.0-beta (312 vectors) added the 16 D-18
alias-expansion vectors, which now all run and pass with the declared
maximum passed through YamlReadOptions::max_alias_expansion, and the 4
E-32 line:col placeholder vectors, which pass: a parse.codec-syntax
expectation whose path is the placeholder is satisfied by the same code at a
well-formed text position inside the input, and every other path is still
compared byte for byte. Before this change, with the alias skip removed and
nothing implemented, the baseline was 266 pass / 12 fail / 34 skip: the 8
alias vectors that expect a rejection and the 4 placeholders. v0.22.0-beta added 14 vectors (287 in all): 10
OML-26 with-a-separator vectors, the OML-25 scalar counterpart, OML-26’s
negative control and 2 E-28 code-point column vectors, all passing; before
this port adopted OML-26 and E-28 the baseline was 238 pass / 9 fail / 40
skip, the failures being 7 OML-26 vectors and the 2 column vectors, see
the CHANGELOG. The suite grew from 249 to 273 vectors across
v0.20.0-beta and v0.21.0-beta – 5 OML-26/27, 4 OSD-15 canonical-escaping and
1 S-21 infer vector at v0.20.0-beta, then 14 new bytes_hex D-14 vectors at
v0.21.0-beta – and this port now runs every one of them for real, including
bytes_hex through the CLI, its byte-oriented entry point, per E-27; see
“Byte inputs (D-14, E-27)” below. The 4 OSD-15 vectors were already passing
before the v0.21.0-beta work landed – the writer was already correct; see
below.) Each skip’s reason is generated by
tools/conformance/src/bin/vector_runner.rs and checked against the vector’s
own input by the every_skip_reason_is_true_for_its_vector test, so it
cannot drift from what is actually skipped. A vector whose actual error lacks
a structured code or path FAILS; it is never skipped for lack of one.
- 28
extensions-osd-oml/*vectors. The OSD-OML extension operations (parse_schema_oml,schema_from_document,schema_to_document,write_schema_oml) are not implemented; tracked in omnist-rs#175.
Where this port’s real ceiling differs from Python’s/TypeScript’s –
not implied parity
- Divergence ledger D-6 (integer/number-kind-collapse) is
TypeScript-only and does not apply to this port – confirmed
empirically, not assumed:
Scalar::Int(i64)/Scalar::Float(f64)are separate enum variants here, unlike TypeScript’s sharednumber, so the collapse D-6 describes structurally cannot happen in Rust. - This port’s
ParseError { line, col, code, message }is structured (unlike TypeScript’s message-onlyParseError), which let most syntax-failure vectors run for real here instead of blanket-skipping – a genuinely favorable per-language difference, found empirically while building the harness, not assumed going in. - Diagnostics are compared as
(path, code)sets (section 8.5.2), the strict mode. Up to spec v0.9.1-beta this runner compared paths only, which is how vectors reported green while the code was wrong (inferandextractfailures were not even compared, andextractreported a field path where the spec wants the record). Every error type now carries what the taxonomy needs:ParseError { line, col, code, message },SchemaError { path, code, message },DocumentError { path, code: Option<_>, message },WriteError { path, code, .. }, andWriteReportadjustments. Diagnostics this port cannot yet emit exactly are listed in omnist-rs#182.
Byte-order marks (D-15, D-21)
One leading U+FEFF is stripped on every read surface (OML, OSD, JSON, YAML,
TOML, XML); a second is rejected at 1:1 (parse.unexpected-token on OML and
OSD, parse.codec-syntax on the four codecs); a U+FEFF anywhere else is
ordinary content. The stripping lives in exactly one helper
(omnist/src/bom.rs) that every reader calls before it does anything else,
so no reader or library strips a second one. Writers never emit one. Before
this change (measured, not assumed): OML stripped one and rejected a second at
1:1; TOML and XML stripped one (inside their libraries) but rejected a
second at the wrong position (1:4); JSON and OSD rejected even a single
mark; YAML kept every mark as part of the first key, so one mark silently
renamed the key and two were accepted. Two tests guard it: one scans every tracked text
file’s bytes for a raw EF BB BF (the mark is written as \u{FEFF} in
source, never raw), and omnist/tests/bom.rs exercises all six readers and
every writer. The one shape YAML loses under D-21 is an unquoted first key
that begins with the mark; the YAML writer quotes any string that starts with
one, so a Document this port writes always reads back.
Byte inputs (D-14, E-27)
New in v0.21.0-beta: a read-side vector (parse, parse_schema,
parse_schema_oml) may give its input as bytes_hex (lowercase hex, two
digits per byte) instead of text, so D-14 – reject invalid UTF-8 with
parse.invalid-encoding at 1:1, before the BOM strip and doubled-BOM check
– can finally be pinned by a vector; a JSON-vector text field cannot hold
ill-formed bytes, so the rule was untestable before this.
This crate’s readers all take &str, which is always already-valid UTF-8 by
construction (omnist-spec section 2.5 lets a string-typed entry point treat
its input as decoded), so the check has to run earlier, on real bytes.
omnist-cli is this port’s byte-oriented entry point – it reads a file or
stdin – so it is where D-14 is enforced: omnist_cli::decode_input decodes
strictly and, on failure, returns the fixed diagnostic (1:1,
parse.invalid-encoding), never a lossy repair. Every CLI command that reads
document, OML or OSD input (format, convert, check, validate,
infer, every schema subcommand, and --schema) goes through it.
The vector runner (tools/conformance/src/bin/vector_runner.rs) presents a
bytes_hex vector’s bytes to that same entry point – omnist-cli’s
read_document_bytes/read_oml_bytes/parse_schema_bytes, added as a small
public surface on the omnist-cli crate for exactly this – never by
decoding with U+FFFD replacement and running the result through the
ordinary &str reader, which the spec calls out by name as the one wrong way
to run these vectors. tools/conformance/Cargo.toml depends on omnist-cli
for this; it is an in-process function call, not a spawned subprocess, since
Track 2 already links against the library crates directly. A text vector on
these three operations still goes straight to the &str reader, unchanged.
All 14 bytes_hex vectors pass: the 8 invalid-UTF-8 ones (a truncated
sequence, an overlong encoding, a lone continuation byte, an encoded
surrogate, a byte above the Unicode range, one pinning the offset-zero case
and one pinning line 2 – both still 1:1) and the 6 valid multi-byte
controls, proving the check does not also reject legitimate multi-byte text.
omnist-cli’s own test suite (omnist-cli/tests/cli.rs) drives the same
check directly through the compiled binary over stdin and files, on all six
surfaces, plus the D-21 boundary (a truncated BOM is D-14; a valid doubled BOM
is D-21, not D-14) and the “no panic on hostile bytes” sweep described below.
Canonical OSD escaping and the unwritable label (OSD-15, OSD-14)
New conformance vectors in v0.21.0-beta, but OSD-15’s rule – a backslash is
written \\, a double quote \", nothing else – was already satisfied by
this port’s omnist::osd::quote_label before this sweep. A draft of this
document previously claimed a fix that did not happen; verified directly
(git diff origin/main -- omnist/src/osd.rs at the v0.19.0-beta -> v0.21.0-beta
boundary never touches quote_label, and the pre-sweep code was measured
against the four new osd-grammar/canonical-output/label-* vectors on its
own: 219 pass / 14 fail / 40 skip, with all 14 failures being bytes_hex
D-14 vectors and zero being OSD-15 vectors).
OSD-14 (a field label with a C0 control character has no OSD spelling) has no
conformance vector – the suite’s schema field is always OSD text, and a
schema with such a label has none – so to_osd is unit- and property-tested
directly instead (omnist/src/osd.rs, omnist/tests/fuzz.rs): a schema built
programmatically with a control-character label fails with
write.unsupported-value at the record’s path, and a proptest property
(osd_write_read_round_trips_for_arbitrary_valid_labels) checks
parse_schema(to_osd(s).unwrap()).unwrap() == s for arbitrary non-empty
labels excluding [/] and C0 controls, generated with a distribution that
frequently includes backslashes, quotes and non-ASCII text (not just plain
ASCII words), so it actually exercises OSD-15’s escaping on every run.
to_osd returning Result rather than String is a breaking API change for
an alpha crate (see the crate’s own CHANGELOG); every caller in this
workspace (the CLI, examples, tests, both conformance runners) was updated.
The conformance harness’s own comparison bugs (found by independent review)
Two real gaps in tools/conformance itself, found by an independent review
of this PR and fixed with mutation evidence, not just re-reading the code –
and one over-correction in the first attempt at fixing them, caught by a
second independent review and corrected in turn:
-
run_parse_schema(Track 2) never comparedexpect.schema. A vector givingexpect.schemaalongside a successfulparse_schemaonly ever checkedok: true, never the schema’s canonical text – proven by deleting all escaping fromquote_labelin a scratch copy and observing no change in the reported pass count. Fixed: whenexpect.schemais present, the parsed schema is rendered throughto_osd(&schema, Some(4))(the canonical pretty form) and compared byte-for-byte, per omnist-spec section 8.5.3. -
Track 2’s schema-output comparisons (
normalize/prune/extract) were structural, not byte-for-byte, when section 8.5.3 requires byte-for-byte.referee::compare_schema’s"exact"mode re-parsed both OSD texts and comparedSchema == Schema, blind to any writer formatting difference that doesn’t change the parsed structure – proven by wideningto_osd’s pretty-mode indent from one space to three and observing both conformance tracks stay green.The first fix over-corrected: it made
"exact"itself byte-for-byte, which is right for Track 2 but wrong for Track 1. Track 1’s own spec (vendor/omnist-spec/docs/conformance-harness.mdsection 4’s reference pseudocode, and section 6’s self-test fixtures –01-schema-exact-equal-different-field-order,07-schema-exact-equal-different-env-declaration-order– one of them literally titled “two structurally identical schemas, written with different field order and different indentation, compare equal underexactmode”) defines"exact"as structural, deliberately, and Track 1’s ownrunner.rsself-test fixtures failed for real once"exact"was redefined globally. A second review caught this before it shipped.The corrected design:
referee::compare_schemanow has three modes."exact"stays structural (Schema == Schema), used only by Track 1’srunner.rs, matchingdocs/conformance-harness.md. A new"canonical"mode does the real byte-for-byte comparison Track 2 needs, used only byvector_runner.rsfornormalize/prune/extract/parse_schema’sexpect.schema(section 8.5.3 explicitly requires it fornormalizeandparse_schema, andextractinherits it by delegating its final step tonormalize, perdocs/06-schema-algebra.mdsection 6.9)."isomorphic"(infer) is unaffected in either track – correctly structural, since an inferred schema’s record names are implementation-derived and never expected to match byte-for-byte.
Both fixes were verified by mutation, twice: once against the (later
corrected) global change, and again against the final three-mode design. A
deliberate to_osd spacing change now fails 18 Track 2 vectors while
leaving Track 1 at 19/0 (exactly as docs/conformance-harness.md says it
should – Track 1’s "exact" is meant to tolerate this); deleting
quote_label’s escaping entirely fails the 4 OSD-15 vectors directly.
Neither mutation-and-revert cycle surfaced an actual writer bug – with the
corrected comparisons in place, both tracks were unchanged at 233/0/40 (of
273, v0.21.0-beta) and 19/0.
Real bugs this harness found and fixed
Building this harness against the real spec (rather than trusting the
Python/TypeScript ports as ground truth) found seven real product bugs,
all fixed across this port’s 0.1.0-alpha/0.1.1-alpha releases:
- XML reader was type-coercing leaf text (int/float/bool) at parse
time, contradicting the spec – XML has no typed literals. Fixed:
read_xmlalways produces string leaves now; see XML. - YAML’s implicit-int resolver was missing the legacy sexagesimal
form –
12:00:00stayed a string instead of resolving to43200. Fixed; see YAML. - YAML mapping keys were never run through the implicit-type
resolver (the “Norway problem”) –
on:wasn’t rejected as YAML 1.1 requires. Fixed to match Python’s reference behavior exactly (any non-string key is rejected, not just bool/null-shaped ones); see YAML. - OML’s tokenizer wasn’t canonicalizing temporal literal text –
missing seconds got dropped instead of filled to
:00, and sub-second fractions weren’t zero-padded to 6 digits; see OML. - OML’s writer shape-guessed date/time/datetime from string content
to decide bare-vs-quoted, since
Scalarhas no temporal variant (issue #16) and thus no real provenance signal – a plain JSON string that merely looked date-shaped got silently promoted to a genuine OML temporal literal on write. Found while directly verifying, not just trusting, this suite’s own reported numbers: the bug had a fully-green 117/0/22 run despite existing, because no vector at the time tested it. Fixed by tagging genuine provenance (OML’s own bare-literal grammar, or a schema-directedmaterializeupgrade) onRawNodeinstead of guessing from shape; see OML. omnist-spec’s ownv0.1.1-alphaadds the 6 vectors (formats-oml/oml.json) this fix now passes for real. - One harness-side false fail: a JSON temporal-write-report vector is
structurally unreachable given this port’s
any-scoping decision (seelimitations.md); reclassified from fail to a cited skip rather than a product fix. - A separate harness-side skip, since resolved by issue #105: the
formats-json/basic/temporal-leaf-is-stringified-on-writevector was structurally unreachable becauseScalarhad no temporal variant to preserve through the harness’s own vector decoder (issue #16/#89) – skipped, cited, not a product bug. Issue #105 gaveScalarrealDate/Time/Datetimevariants, the decoder now preserves them, and this vector passes for real; its skip detector has been removed fromvector_runner.rs. Scalar::Int(i64)rejected valid arbitrary-precision integer literals – omnist-spec section 2.2 definesintegeras arbitrary-precision (bounded only by the shared 4,300-digit cap), not fixed-width; a 20+ digit OML literal was rejected outright with no digit-cap override in play, a real grammar-acceptance bug (spec section 9.2), not a permitted narrower-limit variation. Not found by this harness on its own – surfaced by a maintainer-prompted ledger-legitimacy audit (“is this a genuine language limitation or an unexamined shortcut”) on theomnist-specside, which added the vector this fix now passes for real. Fixed by movingScalar::Int/Value::Intontonum_bigint::BigInt; see Limitations. Found and fixed along the way, not assumed mechanical: the YAML legacy sexagesimal literal’s fold used to rely oni64overflow as an incidental size bound – a naiveBigIntswap would have silently removed it, letting a many-:-group literal build an arbitrarily large integer with nothing stopping it. Fixed by enforcing the existing digit cap explicitly on the fold’s result instead.
None of these required filing against omnist-spec, Python, or TypeScript – every real fail traced back to an omnist-rs bug when checked against a live Python run first, per this project’s own cross-implementation triage rule.
Known non-blocking gap in the CI gate itself
cargo llvm-cov --workspace --fail-under-lines 100, this project’s
coverage gate, has an open, unexplained discrepancy where its exit code
doesn’t reliably correlate with its own printed Lines% column across
commits – see
omnist-rs#95. Not
specific to the conformance work; noted here because it surfaced while
landing it.
Limitations & stability
Alpha status: 0.12.0-alpha, per this project’s versioning rule
The Rust port’s first feature-complete milestone (issue #28) plus its own
conformance-test harness against
omnist-spec (issue #82 –
see Conformance against omnist-spec for the real,
measured results) are both now in place, and the maintainer has signed
off on moving past 0.0.x to mark that milestone. It still ships
-alpha, though: there is no beta until the maintainer explicitly
signs off on the scoping decisions below (the any-type gap chief among
them); accumulating further features or fixes alone never moves it past
-alpha on its own. Treat every public API in this crate as subject to
change without a deprecation cycle until that further sign-off happens.
Document paths quote a label that needs it (E-36)
Every Document path this crate prints (DocumentError.path,
ValidationError.path, Adjustment.path) writes a label as .label, unless
the label is empty or holds . [ ] " \, a control character, U+007F,
U+0085, U+2028 or U+2029: then it is ["label"] with no dot, escaping " as
\", \ as \\, \n, \r, \t, and any other of those as lowercase
\u001f. The E-10 index follows either form ($["a.b"][1]). Schema paths
(Record.label) keep the raw label.
Programmatic-schema rules from spec v0.28.0-beta (S-8, S-22, S-23, S-24)
These four rules take a schema built through the API, which no conformance
vector can supply (DIV-5), so the unit tests in omnist/tests/spec_v028.rs are
the only pin.
- S-8 and S-24/OSD-16 are implemented.
Schema::newreports a bad record orReftarget name asschema.invalid-nameat$;osd::to_osdfails withwrite.unsupported-valueat the record path on amax = 0field. Which violation is reported when several exist is unspecified. - S-22 (
schema.invalid-label) is vacuous in Rust. A field label is aString, so it cannot hold invalid UTF-8. Every public way to give a label takesimpl Into<String>(Field::new,Field::required, …); there is noOsString,Vec<u8>orCow<[u8]>route into a schema, so there is nothing to check and no code path emitsschema.invalid-label. Input bytes are a different rule (D-14,parse.invalid-encoding). - S-23 (
schema.unknown-record) has no Rust surface. No public function takes a caller-supplied ordering of records:to_osdand the other writers emitSchema::env()in its own order, andextract’skeepargument is a set of field labels, not records.schema.unknown-recordis therefore never emitted. - No OSD-OML schema writer. The port has no OSD-OML schema reader or writer, so the OSD-OML half of OSD-16/S-24 does not apply.
The any-type support (landed)
Python’s schema model has an AnyType/ANY type and an allow_any option
several APIs (osd, schema algebra, inference) use as a fallback when a
precise type can’t otherwise be resolved. In this Rust port, FieldType::Any
is fully supported across omnist::schema, omnist::osd parsing (record X { "a": any }),
and omnist::infer (with allow_any fallback mode when schemas have ambiguous
types or mixed structures, also wired into the CLI’s infer --allow-any flag).
Safety limits and the YAML alias expansion factor (D-10, D-11)
Every limit below is finite, documented here, and reported with its
document.limit.* code (spec section 2.4):
| Limit | Default | Configurable |
|---|---|---|
| Maximum nesting depth | 200 | yes: Limits::max_depth (0 = default, at most 250) |
| Maximum node count | 1,000,000 (a node is a container, not a key or a scalar value) | yes: Limits::max_nodes (0 = default, at most 10,000,000) |
| Maximum integer digits | 4,300 | yes: Limits::max_int_digits (0 = default, at most 43,000) |
| Maximum input size (bytes, D-23) | 64 MiB (67,108,864 bytes) | yes: Limits::max_input_bytes (0 = default, at most 1 GiB); CLI --max-input-bytes N |
| Maximum alias expansion factor (YAML) | 50 | yes: YamlReadOptions::max_alias_expansion (0 = default, at most 10000) |
| Maximum expanded size (YAML, inputs with an alias or merge key) | 1,000,000 value slots | yes: YamlReadOptions::max_expanded_slots (0 = default, at most 10,000,000) |
The first four are omnist::limits::Limits, taken by the explicit-limits
readers read_oml_with, read_json_with, read_toml_with, read_xml_with,
by YamlReadOptions::with_limits (for read_yaml_with) and by
Doc::from_raw_with / Doc::of_with; the plain read_* functions use the
defaults, unchanged. As for the YAML options, 0 selects the default (a zero
or unset value never widens a limit) and a value above its ceiling is refused,
not clamped, by Limits::validate (an uncoded DocumentError). The ceilings
are this port’s own, because the spec recommends none for these three; the
depth ceiling is what the recursive readers survive on a 2 MB thread stack in
a debug build (measured: 300 levels pass, 400 overflow; a release build passes
1,000), and all three bound the work a caller can opt into. On a smaller stack,
lower the depth. A limit equal to the document’s depth, node count or
digit length accepts it and one past refuses it. A Doc remembers the limits
it was built under, so a later add or set enforces the same ones.
write_oml and the other writers, which take a bare RawNode or an existing
Doc, still refuse nesting past the default depth of 200.
A limit crossed is a DocumentError (spec E-11), never a ParseError:
document.limit.depth and document.limit.nodes carry path $ (they are
properties of the whole input, as for document.limit.expanded-size), and
document.limit.int-digits carries the Document path of the over-long integer
($.n, $.a.n, and an index only when the label repeats: $.n[1]). Through
0.6.1-alpha the OML scanner and the JSON, TOML and YAML readers raised these as
a ParseError with a line:col position. read_oml, whose signature is
unchanged and returns a ParseError, still reports them that way, with the
position; read_oml_with and the registry’s oml format report the Document
form. A syntax error anywhere in the input wins over an over-long integer.
Two readers have a ceiling of their own below ours: toml_edit refuses nesting
past about 80 levels (inline tables, arrays, dotted keys and table headers
alike) and holds every integer in 64 bits, so a larger max_depth or a
max_int_digits above 19 does not make the TOML reader accept more than
toml_edit does. Its recursion cap is reported as document.limit.depth at
$, with a message naming toml_edit’s own cap (through 0.8.0-alpha a table
header deeper than 80 levels panicked the reader, omnist-rs#197, and the other
spellings were parse.codec-syntax). The XML reader counts an element as
a node once it has a child element, so a million leaf elements are one node;
its text is never an integer, so max_int_digits governs only the
schema-guided pretyping of read_xml_with_schema_and_limits: an integer
leaf over it is document.limit.int-digits at the leaf’s path (through
0.11.0-alpha it was materialize.inexact-conversion, omnist-rs#204). A
number leaf is exempt however long its integer part (D-28) and reads as an
infinity past binary64 (D-29); through 0.11.1-alpha a number leaf over the
limit stayed a string (DIV-42).
The alias expansion factor bounds the materialized-to-written value-slot
ratio of every anchored node, every other mapping and sequence, and the
document root; see YAML
for the counting rules and for what the default accepts (for example, merging
a 150-key anchor into a mapping that writes one key of its own reads
E ~ 50.67 and is rejected at the default). It is a ratio, so it does not
replace the node cap: a scalar-heavy document is bounded as a whole only by
max x S(root), and the YAML node cap stays in force.
Only YAML has an anchor mechanism, so the factor binds no other codec.
The ratio does not bound absolute size, so the reader also enforces a maximum
expanded size (D-22): an input that contains at least one alias or merge key
and whose root materializes more than 1,000,000 value slots is refused with
document.limit.expanded-size, after the ratio check (an input that fails
both reports document.limit.alias-expansion). Ratio and size are separate
options, and neither implies the other. An input with no alias and no merge
key is exempt however large, which is a cliff by design: a plain file of two
million slots passes, and adding one alias subjects it to the cap. See
YAML. The YAML node cap counts what
D-9 counts, containers only, and defaults to the spec’s 1,000,000 (it is
Limits::max_nodes, set through YamlReadOptions::with_limits), so the
spec’s own 1,000-service x 60-key compose example (W = 62,063) is accepted
(it was refused with document.limit.nodes through 0.6.0-alpha, DIV-11,
omnist-rs#189). A malformed merge (a scalar
merge value, a scalar or sequence member of a merge sequence, an alias to a
sequence of scalars) is parse.codec-syntax and wins over both limits.
Maximum input size (D-23, D-24)
The spec says an implementation SHOULD enforce a finite maximum input size in
bytes (D-23), names no default (D-24), and asks the implementer to measure the
slowest codec at the size chosen. This port enforces one, 64 MiB by default
(the same number the Python port chose), configurable with
Limits::max_input_bytes and, on the command line, --max-input-bytes N on
format, convert, check, validate and infer.
- Bytes, not characters:
éis two, and a leading byte-order mark is three, because the length is taken before the mark is stripped (D-15) and before any decoding or parsing. An input of exactly the maximum is accepted, one byte more is refused withdocument.limit.input-sizeat$. - First: the check precedes every other diagnostic, so an oversized input
that is also invalid UTF-8, malformed or doubly BOM-marked reports
document.limit.input-size. It covers all five formats, an alias-free YAML input included, andDoc::from_formatfor a registered (plugin) format too.read_oml, which returns aParseError, reports it at1:1; every other reader, andread_oml_with, report theDocumentErrorform with path$. - Streaming: the CLI reads a file or stdin through a reader limited to the maximum plus one byte, so it refuses an oversized input after reading max + 1 bytes rather than buffering the rest. The refusal says how to raise the limit.
- Not bounded: an OSD schema text (D-25 asks for a schema-size bound but
gives it no code and no vector; the CLI’s schema files and
--schemaare read whole), and a Document built from native values (it has no input bytes).
Why 64 MiB, and what it costs. The cap bounds parse cost; it does not make a parse fast. Measured on 2026-10-05 (release build, one run each, a flat mapping of integer entries filling 64 MiB; TOML and YAML also measured at 8 and 16 MiB and scale about linearly): OML 2.4 s and 0.5 GB peak, XML 7.1 s and 0.6 GB, JSON 6.1 s and 1.0 GB, TOML 15.8 s and 2.5 GB, YAML 18.8 s and 2.7 GB. The spec gives no default (D-24) and asks an implementation to document its value and to measure its slowest codec on a worst-case input before raising it; these measurements are why the default is documented here. A deployment that reads untrusted TOML or YAML on a small host should set a lower maximum (the same inputs at 10 MiB: TOML 2.1 s and 0.43 GB, YAML 2.7 s and 0.46 GB, the others under 1.3 s and 0.17 GB). Before 0.9.0-alpha a reader took any size, so a caller that reads more than 64 MiB now has to raise the limit explicitly: that is a behaviour change, not only a new option.
C-9: writers refuse a string with no UTF-8 encoding (vacuous here)
Spec C-9 (v0.32.0-beta) says every writer MUST fail with
write.unsupported-value when a Document holds a string value or an edge label
with no UTF-8 encoding. In this port the rule cannot fire. A Rust String,
&str and char always encode, so no Document, RawNode or Value can hold
such a string, and every API that takes text takes &str or String. The
audit of 2026-10-05 found:
- No library entry point takes bytes, an
OsStringor aCow<[u8]>. The only byte entry points are the CLI crate’sread_document_bytes(_with),read_oml_bytes(_with)andparse_schema_bytes, which decode withString::from_utf8and refuse invalid input withparse.invalid-encoding(D-14), never repairing it. - No lossy conversion on input. No production code calls
String::from_utf8_lossyon bytes that could be invalid,to_string_lossy, or an_uncheckedconstructor, except twofrom_utf8_lossycalls in the XML reader, which work on slices of an already valid&strcut at ASCII delimiters (a CDATA body, an attribute value) and so never see an invalid byte. Every reader refuses a lone surrogate escape (JSON\ud800, TOML, OML, YAML, XML�) as a syntax error and never turns one intoU+FFFD, which is the Go-style bug. - A tripwire test (
omnist/tests/c9_vacuous.rs) fails if a new lossy conversion appears in production code, so a future route is audited, not silent.
No vector pins C-9 (an input is UTF-8 text or bytes_hex, neither of which can
carry a surrogate), so the ledger’s row for it is “vacuous” for this port.
Scalar::Int is arbitrary-precision (issue #104)
omnist::document::Scalar::Int and Value::Int are backed by
num_bigint::BigInt, not a fixed-width integer – matching omnist-spec
section 2.2’s
requirement that integer be arbitrary-precision, and Python’s/Go’s own
representations (int, *big.Int). This was previously i64 (max ~19
significant decimal digits) – a real spec-conformance bug, not a
disclosed permitted variation, since a 20+ digit literal under the shared
4,300-digit security cap was rejected outright with no
declared_max_int_digits override in play (omnist-spec ledger entry D-9).
Fixed; see each format’s own page for anything still worth knowing:
- formats/toml.md – TOML integers read at full
precision too:
toml_edit, the crate this port’s TOML codec is built on, holds an integer in 64 bits and refuses a longer literal, so the reader works the literal out itself (any radix) and puts the value back. The digit limit counts the decimal digits of the value (spec D-28). No format has a 64-bit ceiling on read or on write.
TOML integers past 64 bits: cost of reading them
toml_edit refuses an integer past 64 bits, so the reader finds those literals
itself (see TOML). Doing that one literal per reparse was
quadratic: in a release build, an array of N copies of a 20-digit integer took
10.6 s at N = 4,000 (84 KB), 38 s at 8,000, 147 s at 16,000 and 324 s at 32,000,
where the previous release refused the first such literal at once. A single scan
now finds every such literal and the text is parsed once more, so the cost is
linear: 20,000 big integers in an array, 20,000 inline tables each holding one,
and 20,000 1e999 floats read together in 0.41 s (release; the test
many_big_integers_read_in_linear_time bounds it at 60 s in a debug build). Before the
fix the same three shapes at 6,000 each took 282 s.
Temporal kinds have no arithmetic
Scalar/Value carry real Date(String)/Time(String)/Datetime(String)
variants (added in issue #105), each holding an already shape-validated,
canonical ISO spelling – but the string is opaque data, not a chrono/
time value. There is no date arithmetic, comparison, or component
extraction anywhere in this crate; the algebra never needed it (mirroring
the same no-arithmetic reasoning Scalar::Int’s BigInt backing already
applies to integers). This means:
omnist::infer::inferinfersdate/time/datetimeonly from a genuinely temporal-kinded sample (one already read asScalar::Date/Time/Datetime– e.g. from OML’s or TOML’s own native temporal grammar); a plain ISO-shaped string sample still infers asstring, matching Python’s own strictvalue_kind()exactly.omnist::schema::matches_kind, by contrast, accepts either a real temporal variant or a shape-matching plain string for aDate/Time/Datetime-typed field – also matching Python’s own hybridmatches_kindexactly. A schema-directedmaterializeupgrade is what promotes a matching string to the real typed variant.- Formats with a native temporal type on the wire (TOML’s four temporal
literal forms, YAML’s looser timestamp grammar) now construct the real
typed variant directly on read and write it back bare on write – no
more silent collapse to
Scalar::Str; see each format’s own page for the exact behavior (particularly formats/toml.md, whose write-side shape-guessing divergence from Python is now resolved).
Architecture-freedom disclosures already made per codec
Beyond the two structural gaps above, each format module documents its own
disclosed, live-checked divergences from the Python reference (namespace
resolution in XML, ASCII-only digit parsing in XML’s coercion, strict
vs. non-strict OML-Extended string spellings, and more) – see
formats/ for the specifics, all checked against a live Python
interpreter or the Python reference’s own merged PR history, not assumed
from memory.