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.2.2-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)));
// 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 – see the workflow playbook’s “architecture freedom” note – 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;
}
Depth and total-node-count guards enforced on every construction path.
#![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>) -> String;
}
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.
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;
}
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.
#![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,
pub message: String,
}
pub struct SchemaError {
pub path: String,
pub code: String,
pub message: String,
}
pub struct ParseError {
pub line: usize,
pub col: usize,
pub message: String,
}
pub struct FormatError(pub String);
pub struct WriteError {
pub message: String,
pub report: Option<crate::report::WriteReport>,
}
impl WriteError {
pub fn new(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– OML source text could not be parsed; carries a “line N, col N: msg” position.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.
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.
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 its own materialize.* namespace, e.g.
"materialize.inexact-conversion").
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.
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).
Integer digit cap (arbitrary-precision, matching Python – issue #104)
A JSON integer literal over 4300 digits is a ParseError (mirrors
CPython’s sys.set_int_max_str_digits guard, which fires inside
json.loads before Python ever sees the value). 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 (an unquoted << key’s value must be a
mapping or sequence of mappings, merged in order, explicit keys taking
precedence).
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.
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.
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 at path "$" 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, 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 null-valued field drops the field
entirely ({"a": 1, "b": null} writes as a = 1\n, no trace of b); a
null inside an array drops just that element, shifting later elements down.
Each drop is recorded as a null.omitted/Severity::Warning adjustment,
live-confirmed against tomli_w.dumps (Python’s reference TOML writer) to
match exactly, path-for-path. strict mode raises even though the severity
is only Warning.
Integer digit cap, and a real, external i64 ceiling from toml_edit
Live-confirmed against tomllib.loads: a decimal integer literal
follows the identical 4300-digit sys.set_int_max_str_digits cap
json.rs/yaml.rs already apply. Hex/octal/binary literals are a
genuine exception in Python – tomllib.loads("x = 0x" + "f" * 10000)
parses successfully with no error at all, because CPython’s digit-limit
guard explicitly exempts power-of-two bases.
This port does not replicate that decimal/non-decimal split, but for
a different reason than it used to (issue #104: Scalar::Int/Value::Int
are arbitrary-precision BigInts now, not i64). The underlying
toml_edit crate’s own Integer type is i64-backed regardless of
radix (the TOML 1.0 format spec itself documents 64-bit signed
integers), so reading a >19-digit literal of any radix from TOML
source text fails in toml_edit’s own parser, before this codec’s
Scalar conversion ever runs – a real, external, still-current
divergence from Python’s arbitrary-precision int, not something this
port’s own representation choice can lift. Live-confirmed:
convert --from toml on n = 99999999999999999999999999 fails with
"integer literal ... is out of range for a 64-bit integer".
Writing an oversized Scalar::Int to TOML, by contrast, succeeds
– this codec’s writer renders integers as plain digit text rather than
going through toml_edit’s typed API, so the ceiling is read-side only;
live-confirmed the same 26-digit value round-trips out via
convert --from oml --to toml with no error, it just can’t be read back
in through TOML afterward.
Native temporal types, truncated (not rounded) sub-microsecond precision
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). Fractional seconds beyond microsecond
precision are truncated, not rounded to six digits, live-confirmed
against tomllib: 00:32:00.9999999 (7 nines) reads as
datetime.time(0, 32, 0, 999999), matching this module’s integer-
truncating conversion exactly. 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 quoted.
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; an undefined entity is a parse error) – XXE
-safe by construction, not by configuration (Python’s read_xml needs
defusedxml instead of the stdlib ElementTree for the same protection).
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 value.stringified
adjustment).
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).
#![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. 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.
Integer digit cap and i64
Same 4300-digit cap (MAX_INT_DIGITS) as json.rs/yaml.rs/toml.rs, and
the same i64-backed representational ceiling – see
formats/json.md.
--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.
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 (corrected finding, PR #21)
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 same i64 bound to every radix uniformly, via
toml_edit’s parser. Any oversized hex/octal/binary literal is rejected
the same way an oversized decimal one is.
An earlier draft of PR #21 (and its module doc) incorrectly claimed hex
literals hit the identical ValueError as decimal ones in Python – that
claim was wrong and was corrected mid-review once live-checked against
tomllib directly. The corrected finding: Python’s radix exemption is
real, and this port does not replicate it, because Scalar::Int is
i64-backed regardless of the literal’s source radix – there’s no
representational path for an “uncapped hex” literal past 64 bits in this
port regardless of what the cap logic does.
Permanent: yes, for the same reason as the general i64 gap above –
revisiting it would require a non-i64 Scalar::Int.
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 f93c569,
past the v0.2.2-alpha tag – pinned to the exact commit adding issue
#104’s conformance vector, ahead of any tag that includes it yet) 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): 129 passed, 0 failed, 23 skipped (of 152 vectors).
Zero real fails on either track as of this writing. 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
Every skip below is cited in tools/conformance/src/bin/vector_runner.rs’s
own source (either “not yet implemented” or a numbered entry in the
spec’s divergence
ledger
section 9.4), per section 8.5.5’s requirement that no skip go unexplained.
The two structural categories:
- 6
limits.jsonvectors: each expects a vector-local configurable limit (a specific max-nodes/max-depth/max-int-digits value scoped to that one test case). This port’sMAX_NODES/MAX_DEPTHare general, crate-wide constants (omnist::document), not something the harness can override per-vector – there is no representational path to make these pass without adding a runtime-configurable limits API this port doesn’t otherwise need. Distinct from the divergence ledger’s D-1 entry in the specific reason (a vector-local knob, not a fixed-ceiling-value mismatch), even though both are filed under the same general “limits” heading. - ~16 remaining skips: OSD-grammar and OML-grammar/format-specific vectors exercising syntax this port’s parsers don’t yet accept, each cited individually in-source with the specific grammar gap.
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, 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 in code-agnostic mode (path-set only, not
exact error-code string). This port’s
ErrorCode::as_str()now does produce the spec’s family-namespaced codes ("validate.type-mismatch","materialize.inexact-conversion", per §8.3.1, fixed in issue #152) – but the vectors and fixtures are still compared code-agnostically regardless, since some still carry the pre-namespacing bare form recorded against the reference implementation (omnist-spec D-4, open). Same mismatch found independently on the TypeScript port; not Rust-specific.
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.2.2-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.
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).
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 – one real, external divergence
remains:
toml_edit, the crate this port’s TOML codec is built on, has its owni64-backed integer type (the TOML 1.0 format spec itself documents 64-bit signed integers), so a >19-digit integer literal in TOML source text is still rejected – bytoml_edit’s own parser, before this port’sScalaris ever involved. Writing an arbitrary-precisionScalar::Intto TOML still succeeds (this codec’s writer renders integers as plain digit text, not throughtoml_edit’s typed API), so the asymmetry is read-side only: such a value round-trips out but not back in through TOML specifically. Every other format (JSON, YAML, OML) has no such ceiling.
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.