BOVNAR.bvnr

Bovnar — Rust Bindings

Bovnar (BVNR) spec 1.2 documentation · Also available as Markdown and PDF.

Spec version: 1.2 Status: Reference — the Rust crate as implemented Scope: Installation, the high-level and streaming APIs, the DOM, Quantity and the exact numbers, JSON conversion, and the thread rules.

Rust bindings for the Bovnar (BVNR) typed serialisation library (library 2.1.0, format spec 1.2).

The crate is a thin wrapper around the C library: build.rs compiles the C sources into the crate, so there is no system library to install and nothing is looked up at run time. One module is not a wrapper: bovnar::codec, the incremental framing codec for live streams, is pure Rust (§4.8). The API stays close to the C library's own shapes: parse / to_string over a dynamic Value, Reader callbacks, Writer calls and a random-access DOM. There is no serde integration, no builder API for values and no typed deserialisation; the DOM has the C library's node builder (§12.5). Where Rust's types can say more at no cost, they do: the C enums are Rust enums, and an error carries the library's error kind.


Table of Contents

  1. Requirements
  2. Installation
  3. Building the C library
  4. Quick-start
  5. Unit helpers
  6. Quantity
  7. write_array helper
  8. JSON conversion
  9. Currency helpers
  10. Reader reference
  11. Writer reference
  12. DOM API
  13. Error handling
  14. Threads
  15. Running the test suite
  16. Crate layout
  17. Enums and BaseUnit
  18. Spec 1.1 additions
  19. License

1. Requirements

Requirement Notes
Rust ≥ 1.88 The crate's rust-version; CI builds and tests on it
A C compiler build.rs compiles the C library through the cc crate
Python 3 on PATH (only when building from a repository checkout) In the repository, the build runs the upstream table generator scripts, exactly as CMake does. Set the PYTHON environment variable to name a different interpreter. The crate from crates.io carries the generated tables and needs no Python (§3.3)
num-bigint, num-rational, num-integer, num-traits Pulled in by Cargo; they carry the exact numbers (arbitrary-width integers, rational numbers and the coefficient of a Decimal)
wasi-sdk Only for the wasm32-wasip2 target — see §3

The only optional features are the unit-profile notations, all on by default (§3.2). There are no optional dependencies and no integration with third-party unit or array libraries; see §16.


2. Installation

The crate carries the version of the C library it wraps (2.1.0: version in Cargo.toml is the project(bovnar VERSION x.y.z) of the top-level CMakeLists.txt, and CI fails when the two differ). It is published to crates.io with each release:

[dependencies]
bovnar = "2.1.0"

A repository checkout works too, as a path dependency or straight from Git, where Cargo finds the crate inside the repository. Built that way it needs Python 3 at build time (§1):

[dependencies]
bovnar = { path = "../bovnar/rust" }
# or
bovnar = { git = "https://github.com/sothis/bovnar" }

To work on the crate itself:

cd rust
cargo build
cargo test

3. Building the C library

The crate has no shared library to find: build.rs compiles the C library into the crate. The crate from crates.io carries the C sources and the generated unit tables in c/ and compiles those; in a repository checkout build.rs runs the table generators and compiles ../src and ../include instead. Either way a downstream crate needs a C compiler at build time (plus Python 3 in the checkout case, §1) and nothing at run time.

build.rs re-runs when the C sources, the generators, the interpreter override, the enabled features or WASI_SDK_PATH change.

3.1 WebAssembly (wasm32-wasip2)

A WebAssembly build needs a C compiler for WASI, wasi-sdk. Point WASI_SDK_PATH at it and build for wasm32-wasip2:

rustup target add wasm32-wasip2
export WASI_SDK_PATH=/opt/wasi-sdk        # where wasi-sdk is unpacked
cargo build --release --target wasm32-wasip2

The same applies to a crate that depends on this one and is built for wasm32-wasip2: set WASI_SDK_PATH for its build.

The crate's own tests run there under wasmtime, which must see the checkout (the examples the tests read, and the build's output):

CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run --dir $(git rev-parse --show-toplevel)" \
    cargo test --target wasm32-wasip2 --lib --tests

A non-default CARGO_TARGET_DIR outside the checkout needs a --dir of its own. WASI has no threads and a panic aborts, so the one test of each is ignored there; the doctests run on native targets only.

3.2 Unit-profile features

Each unit-profile notation of doc/11 is a Cargo feature, and all seven are on by default:

Feature Notation C switch
ucum ucum: BVNR_WITH_UCUM_PROFILE
unece unece: BVNR_WITH_UNECE_PROFILE
qudt qudt: BVNR_WITH_QUDT_PROFILE
qudt-qk qudt-qk: BVNR_WITH_QUDT_QK_PROFILE
udunits udunits: BVNR_WITH_UDUNITS_PROFILE
om om: BVNR_WITH_OM_PROFILE
cf cf: BVNR_WITH_CF_PROFILE

Leaving a feature out drops that notation's tables from the library, as -DBVNR_WITH_<NAME>_PROFILE=OFF does for the CMake build, and the reader then refuses its codes. THIRD_PARTY_NOTICES.md says why a distributor may want to leave out ucum or cf:

[dependencies]
bovnar = { version = "2.1.0", default-features = false, features = ["unece", "qudt", "qudt-qk", "udunits", "om"] }

3.3 How the published crate is made

build.rs reaches ../src, which a package made from rust/ would not contain, so rust/Cargo.toml keeps publish = false and the crate is published from a staged copy. rust/package.py builds that copy: the crate without tests/ and fuzz/ (they read ../examples and ../src/gendata), LICENSE, THIRD_PARTY_NOTICES.md, and in c/ the headers, the C sources build.rs compiles and the generators' output. It then runs cargo on the copy:

python3 rust/package.py                          # cargo package: pack and verify
python3 rust/package.py -- publish --dry-run     # what CI runs on every push

CI's rust_package job runs that dry run on every push, and publish_crate publishes the same way on a vX.Y.Z tag through crates.io Trusted Publishing.

The crate carries PROVENANCE.json: the repository, the commit and, on a release, the tag it was staged from, and for every file in the crate its SHA-256 and the repository path it was copied from ("generated" for the tables in c/generated/). A program that vendors the C library from c/ can tie what it compiles, and the notices it ships, to that one revision by comparing its copies against those hashes. Before cargo runs, package.py checks the staged copy with check_crate_provenance.py, which stages the named commit again and requires the same files byte for byte. rust_package checks the packed .crate the same way, and a publish refuses uncommitted changes and a commit without its vX.Y.Z tag. To check a published crate against the repository:

python3 check_crate_provenance.py bovnar-X.Y.Z.crate --tag vX.Y.Z

4. Quick-start

4.1 High-level API (parse / to_string)

The dynamic document type is Value. parse reads BVNR bytes into a Value and to_string writes a Value out as BVNR bytes:

use bovnar::{Value, parse, to_string};

fn main() -> Result<(), bovnar::Error> {
    // Serialise a document to BVNR bytes
    let data = Value::Struct(vec![
        ("sensor_id".into(), Value::Int(42)),
        ("temperature".into(), Value::Float(-3.7)),
        ("label".into(), Value::String("outdoor".into())),
        ("active".into(), Value::Bool(true)),
        ("payload".into(), Value::Null),
    ]);
    let bvnr_bytes = to_string(&data, true)?;

    // Deserialise it back
    let recovered = parse(&bvnr_bytes)?;
    assert_eq!(recovered, data);
    let Value::Struct(fields) = recovered else { unreachable!() };
    assert_eq!(fields[0], ("sensor_id".to_string(), Value::Int(42)));
    Ok(())
}

Value is what parse returns and to_string takes:

Variant Notes
Null
Bool(bool)
Int(i128) Any integer an i128 holds, every uint64 and sint64 included
BigInt(num_bigint::BigInt) An integer outside the i128 range (a <uint:256>, say); to_string writes it as <uint:N>/<sint:N>, N the smallest power of two from 128 that holds it
Float(f64) nan, inf and ninf too, under an integer type as well (spec §6.4)
String(String) Symbols and references read back as strings too
Bytes(Vec<u8>) An octet stream
Array(Vec<Value>) A multi-row array is an array of row arrays
Struct(Vec<(String, Value)>) Keys in document order; a struct is a Vec of pairs, not a map
Quantity(Box<Quantity>) A typed scalar with its literal, type and unit
Decimal(Decimal) Written as an exact float_dec:64 literal
Fraction(BigRational) Written as an exact float_dec:64 literal when it terminates

parse_typed is parse with typed values kept: they come back as Value::Quantity instead of being decoded to native scalars. That preserves the original text representation, exact type width, numeral base and physical unit for lossless round trips. What only the annotation carries comes back too: a quoted numeric literal under a default annotation (<float:64,_10> "5.7"), and a typed null, alone (<float:64> ;) or as an array element ([<uint:32>, <sint:64>]):

use bovnar::{Value, parse_typed, to_string};

fn main() -> Result<(), bovnar::Error> {
    let bvnr = b".pressure = <float:32,Pa> 101325.0;";
    let doc = parse_typed(bvnr)?;
    let Value::Struct(fields) = &doc else { unreachable!() };
    let Value::Quantity(q) = &fields[0].1 else { unreachable!() };
    assert_eq!(q.raw.as_deref(), Some("101325.0"));
    assert_eq!(q.unit_str(), "Pa");

    // to_string() accepts Quantity values — annotation and raw text are re-emitted as-is
    let out = to_string(&doc, true)?;
    assert_eq!(parse_typed(&out)?, doc);
    Ok(())
}

to_string(value, pretty) takes a Value::Struct — any other top-level value is an Error::Argument. Its write buffer starts at 4 MiB and doubles on overflow up to 256 MiB; there is no cap argument. It returns Vec<u8> rather than String, because an octet stream is written raw. write_to(value, pretty, out) does the same into any std::io::Write, and parse_reader(input) / parse_typed_reader(input) read from any std::io::Read (§4.7).

parse and its siblings read with the default ReadOptions; parse_with_options(data, typed, &options) and parse_reader_with_options(input, typed, &options) take any of them (§10.3) — a total cap, strict_version, text_only, the length and nesting caps — with typed choosing between parse and parse_typed:

use bovnar::{ErrorCode, ReadOptions};

fn main() {
    let untrusted = ReadOptions {
        max_file_size: 1 << 20,
        max_string_length: 4096,
        strict_version: true,
        ..ReadOptions::default()
    };
    let err = bovnar::parse_with_options(b"#!bovnar 9.0\n.a = 1;", false, &untrusted)
        .unwrap_err();
    assert_eq!(err.code(), Some(ErrorCode::UnsupportedSpecVersion));
}

to_string() writes what parse() reads — the streaming tier, not the document tier. parse() checks the grammar and each value's own type, unit and range and stops there; to_string() is its inverse and stops in the same place, so to_string(&parse(x)?) works for every x that parse() accepts. Three shapes are therefore written without complaint and then refused by the document tier (DomDoc::parse, bovnar validate), where an array is homogeneous and rectangular:

Rust value Written as Document tier says
Array(vec![Int(1), String("a".into())]) — an array mixing kinds [1, "a"] array_element_type_mismatch
a ragged array of arrays — rows of 1 and 2 elements [[1], [2, 3]] array_row_size_mismatch
structs that do not share their keys [{.a = <uint:64> 1;}, {.b = <uint:64> 2;}] struct_shape_mismatch

An array mixing Int and Float, or holding Null beside values, is fine at both tiers. If the output has to pass bovnar validate, run it through DomDoc::parse before shipping it.

What to_string() does refuse, with Error::Argument, is what neither tier reads: an array whose elements carry different units, an integer wider than 64 bits in an array that cannot take the width annotation it needs ([Int(1 << 70), Float(1.5)]), and a Decimal past the range of float_dec:64. A key that is not a string is not on the list: a struct key is a String, so the type system has already refused it.

4.2 SAX-style streaming reader

The callback receives exactly one argument, the EventData. There is no userdata/context argument — capture external state in the closure instead. The closure returns true to go on and false to stop the read (which then ends in an Error::Parse).

use bovnar::{Event, EventData, ReadOptions, Reader};

fn main() -> Result<(), bovnar::Error> {
    let bvnr_bytes = b".a = 1; .b = \"two\";";
    let mut keys = Vec::new();
    let mut values = Vec::new();
    let mut on_event = |d: &EventData<'_>| {
        if d.event == Event::AssignmentStart {
            keys.push(d.text());
        } else if d.event == Event::Data {
            values.push(d.raw.to_vec());
        }
        true // returning false stops parsing (an Error::Parse)
    };

    let mut r = Reader::new()?;
    r.read_mem(bvnr_bytes, &ReadOptions::default(), Some(&mut on_event), None)?;
    assert_eq!(keys, ["a", "b"]);
    assert_eq!(values[0], b"1");
    Ok(())
}

EventData is the payload passed to a callback:

Field / method Type Description
d.event Event The event code
d.token_type Option<TokenType> How the token was written: number, string, symbol, ...
d.value_type ValueTypeSpec The type annotation (family, width, base)
d.value_unit Unit The unit the value carries (dimensionless when it has none)
d.raw &[u8] The raw token bytes
text() String The token bytes as text, invalid UTF-8 replaced
d.frac &[u8] Verbatim ISO sub-second digits of a datetime literal (spec 1.1); empty when there are none
d.unit_source &[u8] The unit exactly as the document spelled it (library 2.0); empty when the value carries no unit
d.converted bool true when a want_unit conversion or a unit policy was applied
d.converted_text &[u8] The exact converted value as a positional string; empty if the conversion does not terminate in its base
d.converted_base u32 The base converted_text is rendered in
d.converted_unit Unit The unit the converted value is in
d.converted_rational() Option<(String, String)> The exact conversion as (numerator, denominator) decimal strings — they may exceed any machine integer
d.converted_in_base(base) Result<Option<String>> The exact conversion re-rendered in base

The borrow ends with the callback. EventData<'a> borrows the reader's memory, so a field cannot be kept past the call; copy what you need (text(), d.raw.to_vec()). The owned copy that can outlive its callback is EventPayload, which iter_mem collects for you.

4.3 Collecting events

Reader::iter_mem returns the events as a Vec of owned EventPayload values (the whole stream, read to the end):

use bovnar::Reader;

fn main() -> Result<(), bovnar::Error> {
    let bvnr_bytes = b".a = 1; .b = 2;";
    let mut r = Reader::new()?;
    for payload in r.iter_mem(bvnr_bytes, true, 0)? {
        println!("{:?} {}", payload.event, payload.text());
    }
    Ok(())
}

4.4 DOM (random-access) API

use bovnar::{DomDoc, DomType};

fn main() -> Result<(), bovnar::Error> {
    let bvnr_bytes = br#"
        .sensor_id = 42;
        .config = { .host = "example.com"; };
        .values = [1.5, 2.5, 3.5];
    "#;
    let doc = DomDoc::parse(bvnr_bytes)?;

    // Top-level key lookup
    let node = doc.get("sensor_id").expect("present");
    assert_eq!(node.as_i64(), Some(42));
    assert_eq!(node.value_type().width, 64);

    // Struct traversal — by member, or by dotted path
    let cfg = doc.get("config").expect("present");
    assert_eq!(cfg.get("host").and_then(|n| n.as_str()), Some("example.com"));
    assert_eq!(doc.lookup("config.host").and_then(|n| n.as_str()), Some("example.com"));

    // Array access
    let arr = doc.get("values").expect("present");
    assert_eq!(arr.dom_type(), Some(DomType::Array));
    let sum: f64 = (0..arr.len()).filter_map(|i| arr.at(i)?.as_float()).sum();
    assert_eq!(sum, 7.5);

    // Convert the entire document to a plain Value (drops type/unit info)
    let value = doc.to_value()?;
    assert!(matches!(value, bovnar::Value::Struct(_)));
    Ok(())
}

A DomNode is a Copy view that borrows its DomDoc, so the compiler — not a convention — keeps the document alive for as long as a node is in use.

4.5 Low-level writer

use bovnar::{Base, Writer};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.write_float("velocity", 9.81, 64)?;
    w.write_uint("count", 1024, 32, Base::DECIMAL)?;
    w.finish()?;

    let output: &[u8] = w.get_output();
    assert_eq!(bovnar::parse(output)?, bovnar::parse(b".velocity = 9.81; .count = <uint:32> 1024;")?);
    Ok(())
}

The scalar writers take no unit arguments: write a value with a unit through write_quantity or write_array, which take a Unit.

4.6 Streaming / framing (bovnar::stream)

Bindings for the streaming layer (see Streaming, Framing & Multiplexing for the full treatment). The functions work on in-memory buffers:

use bovnar::{Value, parse, stream, to_string};

fn main() -> Result<(), bovnar::Error> {
    let one = Value::Struct(vec![("id".into(), Value::Int(1))]);
    let two = Value::Struct(vec![("id".into(), Value::Int(2))]);

    // Multi-document record framing
    let blob = stream::dump_documents(&[one.clone(), two.clone()], true)?;
    let docs = stream::load_documents(&blob, false, false, 0)?;
    assert_eq!(docs, [Some(one.clone()), Some(two)]);
    // With continue_past_failed = true a document that fails is None and the rest are read
    let docs = stream::load_documents(&blob, false, true, 0)?;
    assert_eq!(docs.len(), 2);

    // Octet multiplexing: (channel, payload) of any size, interleaved & reassembled
    let multiplexed = stream::mux_dump(&[(1, b"hello".as_slice()), (42, b"world".as_slice())], "mux")?;
    let messages = stream::mux_load(&multiplexed, 0, None)?;
    assert_eq!(messages, [(1, b"hello".to_vec()), (42, b"world".to_vec())]);

    // Document-in-document
    let outer = stream::embed_document(&to_string(&one, true)?, "payload", true)?;
    let Value::Struct(fields) = parse(&outer)? else { unreachable!() };
    let Value::Bytes(inner) = &fields[0].1 else { unreachable!() };
    assert_eq!(stream::parse_embedded(inner, false)?, one);
    Ok(())
}

All arguments are positional:

Function Arguments
frame_documents(docs) already-serialised documents, &[impl AsRef<[u8]>]
dump_documents(values, pretty) &[Value]
load_documents(data, typed, continue_past_failed, max_document_size) max_document_size 0 is the default cap (BVNR_DOC_DEFAULT_MAX, 256 MiB)
load_documents_results(data, typed, max_document_size, options) every frame read, a failed document Err with its error code
mux_dump(messages, key) (u64, payload) pairs; the conventional key is "mux"
mux_load(data, max_message, key) max_message 0 is the default cap (BVNR_MUX_DEFAULT_MAX, 64 MiB); key None accepts any
embed_document(inner, key, pretty) the conventional key is "payload"
parse_embedded(inner, typed)

load_documents with continue_past_failed gives None for a document that failed, and the error is gone. load_documents_results (and load_documents_reader_results) keep it: every frame is read, as with continue_past_failed, and the document at index i is Ok(value) or the Err it failed with — an Error::Parse with the document's error code and no position, which the frame reader does not report. The outer Err is the stream's own failure: the framing, a frame over the cap. live::FrameParser hands each document's index and result to on_document already (§4.9).

use bovnar::{ErrorCode, ReadOptions, Value, stream};

fn main() -> Result<(), bovnar::Error> {
    let frames = stream::frame_documents(&[&b".a = 1;"[..], b".a = @;", b".b = 2;"])?;
    let docs = stream::load_documents_results(&frames, false, 0, &ReadOptions::default())?;
    assert_eq!(docs[0], Ok(Value::Struct(vec![("a".into(), Value::Int(1))])));
    assert_eq!(docs[1].as_ref().unwrap_err().code(), Some(ErrorCode::UnexpectedInputByte));
    assert!(docs[2].is_ok());
    Ok(())
}

parse_embedded returns an Error::Parse carrying the inner document's own error code and, where the library reported one, its position. mux_dump channels are u64, so the channel range is checked by the type.

The readers read with the default ReadOptions. Each has a twin that takes them as its last argument — load_documents_with_options, mux_load_with_options, parse_embedded_with_options, and the same for the *_reader functions — and reads under them: load_documents each document, with max_file_size capping one document and max_document_size one frame; mux_load the document around the stream; parse_embedded the embedded document, as a document of its own, with max_file_size capping its bytes.

Each function has a twin for std::io::Read or std::io::Write (§4.7).

The Reader and Writer types of §10 and §11 are the building blocks underneath: use them directly when a document is produced or consumed event by event rather than as one buffer.

4.7 Files and I/O streams (std::io::Read / Write)

Every entry point that reads or writes a whole document has a twin that takes any std::io::Read or std::io::Write: a File, a pipe, a socket, a Cursor. There is no separate path-based or file-descriptor form; open the File and pass it in.

use std::io::Cursor;
use bovnar::{Base, DomDoc, ReadOptions, Reader, Writer};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = std::env::temp_dir().join(format!("bovnar-doc-{}.bvnr", std::process::id()));

    // Write: the document streams into the file, the rest on finish()
    let mut w = Writer::to_writer(std::fs::File::create(&path)?, false)?;
    w.write_uint("port", 443, 16, Base::DECIMAL)?;
    w.finish()?;
    drop(w);

    // Random access over the file
    let dom = DomDoc::parse_reader(std::fs::File::open(&path)?)?;
    assert_eq!(dom.lookup("port").and_then(|n| n.as_u64()), Some(443));
    std::fs::remove_file(&path)?;

    // Streaming over any reader
    let mut keys = Vec::new();
    let mut on_event = |d: &bovnar::EventData<'_>| {
        if d.event == bovnar::Event::AssignmentStart {
            keys.push(String::from_utf8_lossy(d.raw).into_owned());
        }
        true
    };
    Reader::new()?.read_reader(Cursor::new(b".a = 1;"), &ReadOptions::default(), Some(&mut on_event), None)?;
    assert_eq!(keys, ["a"]);
    Ok(())
}

The plain-value and stream functions have twins too:

use std::io::Cursor;
use bovnar::{parse_reader, stream, write_to};

fn main() -> Result<(), bovnar::Error> {
    let value = parse_reader(Cursor::new(b".a = 1;"))?;

    // Serialise into any Write; here a Vec<u8>
    let mut out = Vec::new();
    write_to(&value, false, &mut out)?;
    assert_eq!(bovnar::parse(&out)?, value);

    // Framed documents round-trip through a Write and a Read
    let mut framed = Vec::new();
    stream::dump_documents_to(&[value.clone(), value.clone()], false, &mut framed)?;
    let docs = stream::load_documents_reader(Cursor::new(framed), false, false, 0)?;
    assert_eq!(docs, [Some(value.clone()), Some(value)]);
    Ok(())
}
In-memory entry point Reader or writer twin
parse(data) parse_reader(input)
parse_typed(data) parse_typed_reader(input)
parse_with_options(data, typed, &options) parse_reader_with_options(input, typed, &options)
to_string(value, pretty) write_to(value, pretty, out)
Reader::read_mem(data, …) Reader::read_reader(input, &options, on_verified, on_unverified)
Reader::read_mem_want_unit(data, …) Reader::read_reader_want_unit(input, &options, want_unit, on_verified)
Reader::read_mem_with(data, &options, handlers) Reader::read_reader_with(input, &options, handlers)
Reader::read_mem_mirror(data, &options, handlers, mirror) Reader::read_reader_mirror(input, &options, handlers, mirror)
canonicalize(data, pretty, &options) canonicalize_reader(input, out, pretty, &options)
DomDoc::parse(data) DomDoc::parse_reader(input)
DomDoc::parse_with_policy(data, &policy) DomDoc::parse_reader_with_policy(input, &policy)
— DomDoc::parse_reader_capped(input, max_bytes), a lower cap on what is read (§12.2)
Writer::to_mem(cap, pretty) Writer::to_writer(out, pretty)
Writer::to_mem_with(cap, pretty, unit_flags, emit_version) Writer::to_writer_with(out, cap, pretty, unit_flags, emit_version)
Writer::to_mem_with_options(cap, options) Writer::to_writer_with_options(out, cap, options)
stream::frame_documents(docs) stream::frame_documents_to(docs, out)
stream::dump_documents(values, pretty) stream::dump_documents_to(values, pretty, out)
stream::load_documents(data, typed, continue_past_failed, max_document_size) stream::load_documents_reader(input, typed, continue_past_failed, max_document_size)
stream::load_documents_with_options(data, typed, continue_past_failed, max_document_size, &options) stream::load_documents_reader_with_options(input, typed, continue_past_failed, max_document_size, &options)
stream::load_documents_results(data, typed, max_document_size, &options) stream::load_documents_reader_results(input, typed, max_document_size, &options)
stream::mux_dump(messages, key) stream::mux_dump_to(messages, key, out)
stream::mux_load(data, max_message, key) stream::mux_load_reader(input, max_message, key)
stream::mux_load_with_options(data, max_message, key, &options) stream::mux_load_reader_with_options(input, max_message, key, &options)
stream::embed_document(inner, key, pretty) stream::embed_document_to(inner, key, pretty, out)
stream::parse_embedded(inner, typed) stream::parse_embedded_reader(input, typed)
stream::parse_embedded_with_options(inner, typed, &options) stream::parse_embedded_reader_with_options(input, typed, &options)

They stream. A reader twin hands its Read to the C library as a callback source (bvnr_source_from_callback): the library pulls from it as it parses, so the events of what has arrived are delivered while the rest is still to come, and the input is never held whole (Reader::read_reader keeps memory flat however long the input is; the twins that return values hold those values). The results are those of the in-memory twin for the same bytes, file_too_long at the same offset for a document over ReadOptions::max_file_size and the max_document_size and max_message of load_documents_reader and mux_load_reader included. The input is read until it reports its end (Ok(0)), so these suit a file, a pipe or a blocking socket; for input that is handed over when it arrives, use the fed readers of §4.9. Only the DOM reads its input into memory first, up to DOM_READ_MAX_BYTES (256 MiB, the cap of bvn_dom_parse_fd), because it builds the whole document anyway.

A writer twin, and Writer::to_writer, push the document into out through a callback sink (bvnr_sink_to_callback) as it is serialised: the C writer batches its output as it does for an fd, so out receives 64 KiB blocks as they fill and the rest on finish(), which then flushes out once; a to_writer writer also hands over what it holds whenever flush() is called (§11.5). The bytes are those of the in-memory twin. Since output leaves as it is made, a value that cannot be serialised part-way through leaves the blocks before it in out (a value that is not a struct at all leaves nothing), and a to_writer writer dropped unfinished drops what it still holds. to_writer has no cap on the document's size; to_writer_with(out, cap, …) keeps cap as one, and output past it fails with sink_buffer_exhausted, as with Writer::to_mem -- from the call that pushes it into out (the write that fills a block, or flush() or finish()), not necessarily the write that made it. Writer::get_output() is empty for such a writer. out must be Send + 'static; the writer stays Send (§14).

Short reads and writes are continued and Interrupted is retried. A failing input or out is an Error::Io (§13) with the code the C library gives an unreadable fd source or unwritable fd sink (reading_from_source_fd, writing_to_sink), after the events of what had arrived, and a to_writer writer reports it from every later call too. A panic inside input or out reaches the caller, as one in an event handler does (§13); it never unwinds through the C library. No file descriptor is involved, so these work the same on Windows.

4.8 Live streams: the framing codec (bovnar::codec)

The stream functions of §4.6 work on whole buffers. bovnar::codec does the same two wire conventions — octet multiplexing and BVF1 frames (Streaming §2 and §3) — on a live stream: the writer hands out each message's bytes as it is sent, and the reader is fed bytes in pieces of any size, as they arrive from a socket or a pipe, and hands out each message as soon as its last byte is in. The module is pure Rust (#![forbid(unsafe_code)]): no message goes through the C library, and nothing is allocated per message once the reader's buffers have grown to the largest message.

use bovnar::codec::{MuxReader, MuxWriter};

fn main() -> Result<(), bovnar::Error> {
    // A stream under the key "media" that opens with ordinary values
    let mux = MuxWriter::with_preamble(b".meta = { .codec = \"av1\"; .rate = 30; };", "media")?;
    let mut wire = Vec::new(); // stands in for a socket
    wire.extend_from_slice(mux.opener());
    let mut head = Vec::new();
    for (channel, frame) in [(1, &b"key frame"[..]), (2, b"audio"), (1, b"delta")] {
        // Only the chunk headers are written, into `head`; the frame is
        // borrowed, ready for a vectored write
        for part in mux.encode(&mut head, channel, frame) {
            wire.extend_from_slice(part);
        }
    }
    wire.extend_from_slice(mux.terminator());

    // The C library reads the same bytes
    assert_eq!(bovnar::stream::mux_load(&wire, 0, Some("media"))?.len(), 3);

    // Fed in pieces, as from a socket
    let mut reader = MuxReader::with_options(0, Some("media"))?;
    let mut received = Vec::new();
    for piece in wire.chunks(7) {
        let mut input = piece;
        while let Some(m) = reader.read(&mut input)? {
            received.push((m.channel, m.payload.to_vec()));
        }
    }
    reader.finish()?; // the input ended where a stream may end
    assert_eq!(received[1], (2, b"audio".to_vec()));
    let meta = reader.preamble_value(false)?.expect("the stream is open");
    assert_eq!(meta, bovnar::parse(b".meta = { .codec = \"av1\"; .rate = 30; };")?);
    Ok(())
}

Writing. MuxWriter::new(key) or MuxWriter::with_preamble(preamble, key) validates the key (1 to 255 bytes, the identifier rules of spec §4.2: what the C writer accepts and a reader at its defaults reads) and the preamble, once. A stream is opener(), the messages, then terminator(); begin(&mut vec) and finish(&mut vec) append them to a Vec. Each message is one of:

Call What it writes
encode(&mut head, channel, payload) only the chunk headers, into head (cleared, then reused); returns Parts, an iterator over the message's wire bytes in order — header, payload slice, header, … — for a vectored write
write_message(&mut vec, channel, payload) the whole message, appended to a Vec
write_message_to(&mut out, channel, payload) the whole message to any std::io::Write, one vectored write per chunk, nothing allocated; out is not flushed, and must block (a failure part-way, WouldBlock included, leaves the stream broken; for a non-blocking socket write encode's parts yourself)

A message is cut into chunks exactly as bvnr_mux_send cuts it (a message of at most stream::BVNR_MUX_MAX_MESSAGE bytes is one chunk), so the bytes are those of stream::mux_dump for the same key and messages.

Reading. MuxReader::read(&mut input) takes input: &mut &[u8], the bytes not yet read, reads from its front and returns the next complete message as Message { channel, payload }. None means every byte of input was taken (an unfinished message is kept inside the reader), so the buffer can be refilled; after a message, call again with the same slice until None. A message that lies whole in input is borrowed from it; one that spans pieces or chunks is gathered in a buffer per channel that is reused. The slice is valid until the next call. finish() checks that the input ended after the document's ; (or before its first byte); reset() readies the reader for another stream, keeping the buffers it has grown. The reader reads every valid octet region, not only what bvnr_mux_send writes: several messages packed into one chunk, channels interleaved chunk by chunk, a length varint split across chunks.

Option Default
MuxReader::with_options(max_message, key) max_message 0 is 64 MiB, as for mux_load; with key, a stream under another key is an Error::Argument naming both (bvnr_demux_set_key would ignore it, but this reader reads one stream)
.with_max_preamble(bytes) 64 KiB of text before the octet stream, preamble and opening assignment together; more is text_data_too_long

Peek, skip, forward. For a router or relay, next_item(&mut input, n) also stops at the start of each message with Item::Peek, holding its channel, its declared length and its first n bytes; nothing else of the message has been read. Drop the peek to receive the message as Item::Message, call skip() to have its bytes passed over as they arrive, or forward() to receive it as Item::Wire: whole chunks carrying that message alone, which can be written into any other multiplexed stream between two messages. When those chunks lie consecutive in the input, Wire::bytes is the input itself, not copied; otherwise the reader gathers the message and writes it out again as MuxWriter would.

use bovnar::codec::{Item, MuxReader, MuxWriter};

fn main() -> Result<(), bovnar::Error> {
    let mux = MuxWriter::new("mux")?;
    let mut inbound = Vec::new();
    mux.begin(&mut inbound);
    for (channel, msg) in [(1, &b"for the relay"[..]), (2, b"local only"), (1, b"again")] {
        mux.write_message(&mut inbound, channel, msg);
    }
    mux.finish(&mut inbound);

    // Forward channel 1, drop the rest, decided on the peek alone
    let mut reader = MuxReader::new();
    let mut outbound = mux.opener().to_vec();
    let mut input = &inbound[..];
    while let Some(item) = reader.next_item(&mut input, 0)? {
        match item {
            Item::Peek(p) if p.channel == 1 => p.forward(),
            Item::Peek(p) => p.skip(),
            Item::Wire(w) => outbound.extend_from_slice(w.bytes),
            Item::Message(_) => unreachable!("every message was decided"),
        }
    }
    outbound.extend_from_slice(mux.terminator());
    let relayed = bovnar::stream::mux_load(&outbound, 0, None)?;
    assert_eq!(relayed, [(1, b"for the relay".to_vec()), (1, b"again".to_vec())]);
    Ok(())
}

The preamble. Text before the stream's assignment — complete statements, a #!bovnar directive first if needed — lets a stream carry ordinary values about itself in the same document. with_preamble checks it once with the full parser; it must be text, so an octet stream in it is octet_stream_forbidden (a reader takes the document's first 0x00 for the stream's opener). The reader gathers the text up to that 0x00, requires it to end with the assignment .KEY =, checks the preamble with the full parser (once per stream; a bare .KEY= or .KEY = skips the check), and then reads the octet stream in pure Rust. read_preamble(&mut input) reads only up to the opener and returns the preamble, so it can be acted on before any message; preamble() returns it afterwards and preamble_value(typed) parses it into a Value. A C-written document with values before bvnr_mux_begin reads the same way.

What the reader reads. A preamble (possibly empty), one octet stream as the value of the document's last assignment (.KEY =, with no comment between the = and the stream), ;, then only whitespace and comments, which may also come before the ;. A statement after the stream, or any other byte after it, is unexpected_input_byte: the codec reads multiplexed streams, not the grammar. Parse a general document with stream::mux_load or a Reader.

Errors are Error::Parse with the code the C demultiplexer gives (mux_load gives the same code for a desync, an oversized message, the 4097th channel and a cut-off stream; what only the codec refuses -- bytes after the stream, a preamble over its cap, a key other than the expected one -- has a code of its own), and they are latched:

Condition Code
A chunk tag other than 0x01/0x00, a channel varint not whole in its chunk, a varint over 10 bytes or 64 bits, a 4097th distinct channel octet_stream_out_of_sync
A declared length over max_message value_out_of_range
The octet stream closes inside a message or its length octet_stream_truncated
finish() inside the octet stream read_complete_chunk_failed
finish() before the stream opened, or before its ; got_incomplete_bvnr_stream
A comment after the ; that is not UTF-8, or that the input ends inside a character of (finish()) invalid_utf8_byte

Frames. codec::frame_header(len), write_frame(&mut vec, doc) and write_frame_to(&mut out, doc) (one vectored write, no copy) write what bvnr_frame_write writes. FrameReader::read(&mut input) returns each complete document's bytes, borrowed when whole in the input, gathered otherwise; parse each with parse, a Reader or DomDoc. FrameReader::with_max_document_size(n) caps a document (0 is 256 MiB, as in bvnr_doc_stream_read; the buffer only grows with bytes that arrived). A bad magic is unexpected_input_byte, a length over the cap value_out_of_range, and finish() inside a frame got_incomplete_bvnr_stream.

use bovnar::codec::{FrameReader, write_frame};

fn main() -> Result<(), bovnar::Error> {
    let mut wire = Vec::new();
    write_frame(&mut wire, b".id = 1;");
    write_frame(&mut wire, b".id = 2;");
    assert_eq!(wire, bovnar::stream::frame_documents(&[".id = 1;", ".id = 2;"])?);

    let mut reader = FrameReader::new();
    let mut ids = Vec::new();
    for piece in wire.chunks(5) {
        let mut input = piece;
        while let Some(doc) = reader.read(&mut input)? {
            ids.push(bovnar::parse(doc)?);
        }
    }
    reader.finish()?;
    assert_eq!(ids.len(), 2);
    Ok(())
}

Cost per message, from cargo bench --bench codec: a release build pinned to one core (taskset -c 3) of an Intel Core i7-2700K (2011), whose clock scales with load. Each figure is the median of seven runs (each run the median of nine passes over at least 64 MiB of messages), and in brackets the fastest run. The stream is a few MiB held in the cache, as the bytes of a socket read are when the reader sees them; it is opened once and fed the same messages every pass, as a live stream is fed, so opening it is not counted. A 400 KB message spans seven chunks.

Message write write + copy peek + skip forward read mux_dump mux_load
150 B 8.4 ns (8.3) 26 ns (25) 12 ns (12) 13 ns (13) 8.2 ns (8.1) 244 ns (242) 123 ns (122)
30 KB 8.6 ns (8.6) 1.5 µs (1.5) 12 ns (12) 14 ns (14) 8.8 ns (8.8) 8.4 µs (8.2) 2.7 µs (2.5)
400 KB 37 ns (37) 22 µs (21) 55 ns (55) 56 ns (56) 25 µs (23) 110 µs (110) 76 µs (70)

write is encode (the headers only, for a vectored write); write + copy is write_message into a reused Vec; peek + skip and forward are next_item, a look at the channel and the first payload byte, and the decision, the forwarded chunks borrowed from the input; read is read, which gathers (copies) a message that spans chunks, so the 400 KB one; mux_dump and mux_load are the batch functions through the C library, for comparison (mux_load copies every message into the Vec it returns, which the C demux alone does not: doc/10 §8.4). Counted in instructions, which the clock does not move, a 150-byte message is about 145 to write, 120 to read and 140 to peek at and skip, the benchmark's loop included. From a stream much larger than the cache, every chunk header a 400 KB message has is a cache miss, which puts peek and forward at about 130 ns for it, and small messages are read as fast as memory delivers them: about 24 ns to read a 150-byte one and 29 ns to peek at and skip it. The other figures move little.

4.9 Live documents through C (bovnar::live)

The codec of §4.8 reads the framing; it does not parse documents. bovnar::live wraps the C library's push-fed readers (Streaming §8) for that: they are handed bytes in pieces of any size, as they come from a non-blocking socket, an event loop, a frame or another document's octet stream, and are told separately when the input is over. Each keeps one C object for its whole life, so an endless stream costs one setup, and whatever the cuts, the events, values and errors are exactly those the batch functions give for the whole input (the frame readers' framing errors aside, below).

Type Over What comes out
DocumentFeed bvnr_open_read_feed the events of Reader::read_mem, from each feed and finish
ValueFeed the same each top-level assignment as a Value once it is complete
ValueBuilder — the same from events you hand it (those of a Demux, say)
EmbeddedFeed DocumentFeed the documents embedded under a key of another document, as their chunks arrive
FrameParser bvnr_frame_reader_create the events and the result of every framed document
FramePull bvnr_frame_reader_create_pull each framed document's bytes
Demux bvnr_demux_open_feed, bvnr_demux_next a multiplexed stream's messages, and the document's own events in order with them

Documents. DocumentFeed::new(&options) takes the ReadOptions of a Reader (and set_unit_policy its policy). feed(bytes, handler) delivers the events those bytes complete before it returns, so the buffer can be reused at once; running out of input is never an error there. finish(handler) declares the end: a document cut off fails there, with the code and position read_mem reports. reset() starts the next document on the same reader. An error ends the document, and every later feed or finish returns it until reset(). ValueFeed does the same and appends to a Vec each assignment the bytes complete, those before an error included: a scalar, a struct or an octet stream with its last event, an array (which a / may still continue) with the event after it. A key assigned twice comes twice, and nothing is kept once it is handed out, so an endless document is read in constant memory. Its text is capped by the library's default of 2 147 483 647 bytes per document (error_text_data_too_long, doc/10 §1), and each array at as many items: set ReadOptions::max_text_bytes (and max_array_items) to u64::MAX for a document that does not end, and max_file_size where a cap still matters.

use bovnar::live::ValueFeed;
use bovnar::{ReadOptions, Value};

fn main() -> Result<(), bovnar::Error> {
    // An endless document arriving from a socket, cut anywhere
    let pieces: [&[u8]; 4] = [b".temp = 21", b".5; .unit = \"C\"; .te", b"mp = 22.0;", b" .tag = [1, 2];"];
    let mut feed = ValueFeed::new(false, &ReadOptions::default())?;
    let mut got = Vec::new();
    for piece in pieces {
        feed.feed(piece, &mut got)?;
    }
    assert_eq!(got[0], ("temp".to_string(), Value::Float(21.5)));
    assert_eq!(got.len(), 3, "the array may still go on");
    feed.finish(&mut got)?; // the end of the document completes it
    assert_eq!(got[3].0, "tag");
    Ok(())
}

Every handler. feed and finish take the event handler alone. feed_with(bytes, handlers) and finish_with(handlers) take a ReadHandlers (§10.2), as Reader::read_mem_with does: on_verified, the raw lexer events of on_unverified, the want_unit hook and on_error — which sees each error a continue_on_error document recovers from — called inside the call that parses the bytes they concern. ValueFeed takes them through its own feed_with(bytes, out, handlers) and finish_with(out, handlers), its on_verified seeing each event after the value builder has. The C reader is given its callbacks when a document starts, so the first call of a document decides whether on_unverified and want_unit are called for the whole of it: a later call that needs one the document was not started with is an Error::Argument (reset() starts the next document, decided afresh), and a later call may pass fewer. A panic in a handler reaches the caller of the call, and the document has failed.

use bovnar::live::DocumentFeed;
use bovnar::{EventData, ReadHandlers, ReadOptions, Unit};

fn main() -> Result<(), bovnar::Error> {
    let mut feed = DocumentFeed::new(&ReadOptions::default())?;
    let (mut tokens, mut metres) = (0, Vec::new());
    for piece in [&b".d = <uint:64,k~m"[..], b"> 3; .e = 4;"] {
        let mut on_raw = |_: &EventData<'_>| { tokens += 1; true };
        let mut want = |d: &EventData<'_>| {
            (!d.value_unit.is_dimensionless()).then(|| (Unit::parse("m").unwrap(), 0))
        };
        let mut on_value = |d: &EventData<'_>| {
            if d.converted { metres.push(String::from_utf8_lossy(d.converted_text).into_owned()); }
            true
        };
        feed.feed_with(piece, ReadHandlers {
            on_verified: Some(&mut on_value),
            on_unverified: Some(&mut on_raw),
            want_unit: Some(&mut want),
            on_error: None,
        })?;
    }
    feed.finish(&mut |_| true)?;
    assert!(tokens > 0);
    assert_eq!(metres, ["3000"]);
    Ok(())
}

The mirror. set_mirror(w) gives the feed any std::io::Write + Send that receives every byte the reader consumes (src_mirror, a debugging aid): each piece fed, once the reader has parsed it, and a document that fails has mirrored the input up to the byte the error is at. It starts the next document, as reset() does, and stays for every document after; take_mirror::<W>() hands it back as the type it was given (None, and the mirror kept, for another type), and the rest of a document in progress is not mirrored. A failing mirror fails the document with an Error::Io with writing_to_sink; a panic in it reaches the caller. ValueFeed has the same two methods. For a Reader, read_mem_mirror and read_reader_mirror take a &mut dyn std::io::Write for the one read (§10.2). The frame readers and the demux take no mirror, as the C objects behind them do not: their caller holds the bytes it feeds.

use bovnar::ReadOptions;
use bovnar::live::DocumentFeed;

fn main() -> Result<(), bovnar::Error> {
    let mut feed = DocumentFeed::new(&ReadOptions::default())?;
    feed.set_mirror(Vec::<u8>::new())?;
    for piece in [&b".a = 1;"[..], b"  # note\n.b = 2;"] {
        feed.feed(piece, &mut |_| true)?;
    }
    feed.finish(&mut |_| true)?;
    assert_eq!(feed.take_mirror::<Vec<u8>>().unwrap(), b".a = 1;  # note\n.b = 2;");
    Ok(())
}

Frames. FramePull hands out each framed document's bytes, to be parsed by a reused DocumentFeed or ValueFeed (or parse); codec::FrameReader does the same in pure Rust. FrameParser::new(&options, continue_past_failed, max_document_size) parses every document as stream::load_documents does, streaming each frame's payload into one reused reader so that a frame is never held whole: feed(bytes, on_event, on_document) calls on_document(index, result) for each document it completes — Ok(()) or the Err with the document's error code — and its returning false ends the stream with scanner_callback_failed. feed_with(bytes, handlers, on_document) takes a ReadHandlers for the documents, as DocumentFeed::feed_with does; the frame reader is given its callbacks when the stream starts, so the first feed decides whether on_unverified and want_unit are called. A bad magic is unexpected_input_byte, a frame over the cap value_out_of_range, a stream that does not end on a frame boundary got_incomplete_bvnr_stream at finish.

use bovnar::live::{FramePull, ValueFeed};
use bovnar::{ReadOptions, stream};

fn main() -> Result<(), bovnar::Error> {
    let wire = stream::frame_documents(&[&b".id = 1;"[..], b".id = 2;"])?;
    let mut frames = FramePull::new(0)?;
    let mut values = ValueFeed::new(false, &ReadOptions::default())?;
    let mut ids = Vec::new();
    for piece in wire.chunks(5) {
        let mut input = piece;
        while let Some(doc) = frames.next(&mut input)? {
            values.reset()?;
            values.feed(doc, &mut ids)?;
            values.finish(&mut ids)?;
        }
    }
    frames.finish()?;
    assert_eq!(ids.len(), 2);
    Ok(())
}

Multiplexed streams. Demux::new(&DemuxOptions { max_message, key, deliver_events, read }) reads the document that carries the stream, with the key scope, max_message, the 4096-channel limit and every error of stream::mux_load. Each next returns a DemuxItem::Message { channel, payload } or, with deliver_events, a DemuxItem::Event for every event but the stream's chunks, in document order: the metadata before the stream comes out before its first message, and a ValueBuilder makes values of it (the demux withholds the stream's chunks but not its start and end, so the builder also hands out the stream's own key, as an empty Value::Bytes, once the stream ends; Python's DemuxFeed(values=True) leaves it out). After finish(), next on an empty slice hands out the closing events (more input is refused with an Error::Argument); reset() starts the next document: the channel buffers are kept, the channel ids (and with them the 4096-channel count) start afresh, as codec::MuxReader::reset does. recovery_count(), skipped_bytes() and declared_version() are those of the demux's reader, as on DocumentFeed. next_with(input, handlers) and finish_with(handlers) take a ReadHandlers for that reader, which parses inside next and finish: its on_verified sees every verified event, the stream's chunks included, before the demux does, and on_unverified, want_unit and on_error are a Reader's. The first call of a document decides which of on_verified, on_unverified and want_unit its reader calls, as for DocumentFeed.

use bovnar::live::{Demux, DemuxItem, DemuxOptions, ValueBuilder};
use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    // Metadata, the stream, and a value after it, as a C writer writes them
    let mut w = Writer::to_mem(1 << 16, false)?;
    w.begin_struct("meta")?;
    w.write_string("codec", "av1")?;
    w.end_struct()?;
    w.mux_begin("media")?;
    w.mux_send(1, b"key frame")?;
    w.mux_send(2, b"audio")?;
    w.mux_end()?;
    w.write_bool("complete", true)?;
    w.finish()?;
    let wire = w.get_output().to_vec();

    let options = DemuxOptions { key: Some("media".into()), deliver_events: true, ..DemuxOptions::default() };
    let mut demux = Demux::new(&options)?;
    let mut meta = ValueBuilder::new(false);
    let mut messages = Vec::new();
    for piece in wire.chunks(6) {
        let mut input = piece;
        while let Some(item) = demux.next(&mut input)? {
            match item {
                DemuxItem::Event(e) => meta.push(&e.data()),
                DemuxItem::Message { channel, payload } => {
                    if messages.is_empty() {
                        // Everything before the stream is here already
                        assert_eq!(meta.drain().next().map(|(k, _)| k).as_deref(), Some("meta"));
                    }
                    messages.push((channel, payload.to_vec()));
                }
            }
        }
    }
    demux.finish()?;
    while let Some(item) = demux.next(&mut &[][..])? {
        if let DemuxItem::Event(e) = item {
            meta.push(&e.data());
        }
    }
    assert_eq!(messages, [(1, b"key frame".to_vec()), (2, b"audio".to_vec())]);
    assert!(meta.drain().any(|(k, _)| k == "complete"));
    Ok(())
}

Draining. FramePull::next and Demux::next take &mut &[u8] and return one item per call; unlike the codec's readers, they do not advance the slice past each item. An item that lies whole in the input is borrowed from it without a copy; one that spans pieces or chunks is gathered in a buffer the reader keeps and reuses. Either way it borrows the reader until the next call, which the borrow checker enforces. The C object keeps a pointer into the bytes until they are drained, so pass the same slice to every call until one returns None (which empties it): the slice is not shortened meanwhile, another one is refused with an Error::Argument, and so is finish() while bytes are left. A warm Demux or FramePull allocates nothing per message or document.

Embedded documents. Writer::embed_document(key, doc) puts a document into an open writer (§11.5), one after another into a to_writer stream if you like. To read them while the outer document arrives, hand its events to an EmbeddedFeed: it feeds each embedded document's chunks to one reused DocumentFeed as they come and returns the document's value, or its error, when its octet stream closes; stream::parse_embedded gathers the bytes and starts a reader for every document instead.

use bovnar::live::{DocumentFeed, EmbeddedFeed};
use bovnar::{EventData, ReadOptions, Value, Writer};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, false)?;
    for n in 1..=3 {
        w.embed_document("payload", format!(".n = {n};").as_bytes())?;
    }
    w.finish()?;
    let outer = w.get_output().to_vec();

    let mut embedded = EmbeddedFeed::new("payload", false)?;
    let mut docs = Vec::new();
    let mut on_event = |d: &EventData<'_>| {
        docs.extend(embedded.push(d));
        true
    };
    let mut feed = DocumentFeed::new(&ReadOptions::default())?;
    for piece in outer.chunks(4) {
        feed.feed(piece, &mut on_event)?;
    }
    feed.finish(&mut on_event)?;
    assert_eq!(docs.len(), 3);
    assert_eq!(docs[2], Ok(Value::Struct(vec![("n".into(), Value::Int(3))])));
    Ok(())
}

live::Demux or codec::MuxReader? Both read a multiplexed stream as it arrives, with the C demultiplexer's limits and error codes.

codec::MuxReader live::Demux
Implementation pure Rust, no C call per message the C parser over the whole document
Document shape one octet stream, after an optional text preamble, ending the document any: values before and after the stream, other octet streams beside it (key)
The values around the stream the preamble's bytes, parsed on request every event, in order with the messages
Limits and policy max_message, the preamble cap max_message and every ReadOptions limit
Extras peek, skip and forward a message without gathering it —
Use it for a dedicated stream, a router or a relay documents of any other shape, or when the values around the stream matter

Errors are those of the batch functions for the same document or mux stream, as Error::Parse: with a position when the document failed, the demultiplexer's code alone (octet_stream_out_of_sync, value_out_of_range, octet_stream_truncated) when the stream did, as mux_load reports them. A frame reader's errors carry no position, as load_documents' do not, and its framing errors are load_documents' too: a bad magic is unexpected_input_byte, a frame over the cap value_out_of_range and a stream cut inside a frame got_incomplete_bvnr_stream. A panic in a handler reaches the caller, as for Reader (§13).

What it costs, from cargo bench --bench live: a release build pinned to one core (taskset -c 4) of the same i7-2700K as §4.8, load average under 2; the median of three runs, each the median of nine passes. The input is a few MiB held in the cache; pieces feeds it in 1460-byte pieces, a TCP segment's payload, so most messages and documents straddle a feed. The mux_load and MuxReader columns were measured in the same run as the rest of this table, not taken from §4.8's, which is a separate run; compare figures within one table. Per message:

Message Demux whole Demux pieces with deliver_events mux_load MuxReader whole MuxReader pieces
150 B 100 ns 109 ns 100 ns 123 ns 8 ns 14 ns
30 KB 150 ns 2.4 µs 182 ns 2.9 µs 11 ns 1.8 µs
400 KB 26 µs 50 µs 26 µs 68 µs 23 µs 28 µs

A message whole in the fed buffer is borrowed, so its cost does not grow with its size; one that straddles a feed or spans chunks is gathered, and copying it is most of the cost, in the codec as in the demux. mux_load copies every message into a Vec. The codec is about ten times cheaper per small message: no C call and no grammar. Per document (a struct of integers and strings per line, an octet stream every 512 lines), with the throughput in brackets:

Document DocumentFeed whole DocumentFeed pieces Reader::read_mem ValueFeed parse
196 B 16 µs (12 MB/s) 16 µs (12 MB/s) 16 µs (12 MB/s) 20 µs (10 MB/s) 27 µs (7 MB/s)
32 KB 2.0 ms (16 MB/s) 1.9 ms (16 MB/s) 2.0 ms (16 MB/s) 2.4 ms (13 MB/s) 2.5 ms (13 MB/s)
402 KB 24 ms (17 MB/s) 24 ms (17 MB/s) 24 ms (17 MB/s) 29 ms (14 MB/s) 30 ms (13 MB/s)

Feeding costs what reading the whole buffer costs, wherever the pieces are cut: the time is the parser's. ValueFeed hands each assignment out as it completes. parse builds the whole document, and past 32 entries keeps an index from key to entry instead of scanning the keys already there — which took its 402 KB column from 113 ms to 30 ms.

4.10 Canonical form

bovnar::canonicalize(data, pretty, &options) re-serialises a document from its verified events through the C library's canonicalising observer (bvnr_canon_observer_*, doc/08 §3.6) and returns the bytes; canonicalize_reader(input, out, pretty, &options) reads any std::io::Read as it parses and writes to any std::io::Write as the output is made, in 64 KiB pieces, so neither is held whole.

use bovnar::{ReadOptions, canonicalize};

fn main() -> Result<(), bovnar::Error> {
    let options = ReadOptions::default();
    let canon = canonicalize(b".a = 1 ;  # one\n.b = { .c = \"x\"; };", false, &options)?;
    assert_eq!(canon, b".a=<uint:64,_10,no_unit>1;.b={.c=<utf8>\"x\";};");
    assert_eq!(canonicalize(&canon, false, &options)?, canon); // idempotent
    assert_eq!(canonicalize(b".a=1;.b={.c=\"x\";};", false, &options)?, canon);
    let mut out = Vec::new();
    bovnar::canonicalize_reader(&b"#!bovnar 1.1\n.a = 1;"[..], &mut out, true, &options)?;
    assert!(out.starts_with(b"#!bovnar 1.1\n"));
    Ok(())
}

It is the form bovnar events -d prints (-p for pretty): the compact one is a single line with no whitespace outside literals, each value written with the annotation the reader gives it (1 as <uint:64,_10,no_unit>1). Two documents that differ only in layout, comments and the spelling of separators canonicalise to the same bytes, and canonicalising the output again gives it back unchanged; the order of the keys is kept. Units are written as the document spelled them: this is not bovnar pretty-print --canonical, which writes through a writer under UnitFlags::REDUCE | UnitFlags::NUM_CANONICAL and folds them to one form. A leading #!bovnar M.N directive is kept — from an std::io::Read it is found in the first bytes read, before the reader sees them.

The document is read under options, and one the reader refuses is its Error::Parse. Under continue_on_error a document the reader had to recover from is refused too, with the first error it recovered from (code and position): the observer writes what it is fed, and the statement a recovery leaves without its value would make the output unreadable. A failing input is an Error::Io with reading_from_source_fd, a failing out one with writing_to_sink, and a panic in either reaches the caller; after an error out may hold the start of the output. The observer's C function is the reader's on_verified itself, with the observer as its userdata, so no Rust runs per event.


5. Unit helpers

The unit functions live in the bovnar::units module; Unit itself is at the crate root.

use bovnar::Unit;
use bovnar::units::{unit_factor, unit_prefix_factor, unit_to_str};

fn main() -> Result<(), bovnar::Error> {
    // Parse a unit string into a Unit
    let u = Unit::parse("k~g·m/s²")?;

    // Convert a Unit back to its canonical string
    assert_eq!(unit_to_str(&u)?, "k~g·m/s²");
    assert_eq!(u.to_string(), "k~g·m/s²"); // Display is the canonical spelling too

    // Scalar SI/IEC PREFIX factor of a unit — the prefix only, never the
    // base unit's own factor.
    assert_eq!(unit_prefix_factor(&Unit::parse("M~Hz")?), 1_000_000.0);
    assert_eq!(unit_prefix_factor(&Unit::parse("in")?), 1.0); // NOT 0.0254 — the inch has no prefix
    assert_eq!(unit_prefix_factor(&Unit::parse("h")?), 1.0);  // NOT 3600.0

    // unit_factor() takes the unit as text and is the same prefix factor
    assert_eq!(unit_factor("M~Hz")?, 1_000_000.0);
    assert_eq!(unit_factor("in")?, 1.0);
    assert_eq!(unit_factor("h")?, 1.0);

    // The FULL factor to coherent SI is Unit::factor
    assert_eq!(Unit::parse("in")?.factor()?, 0.0254);
    assert_eq!(Unit::parse("h")?.factor()?, 3600.0);
    Ok(())
}

unit_factor is the prefix scale, not the full factor. It is unit_prefix_factor on a unit given as text, and reports only what the prefixes contribute: 1000000.0 for M~Hz, 1.0 for in and h. The full factor to coherent SI is Unit::factor on the parsed unit (or unit_to_si_factor); it fails for an affine unit (°C) or one with no SI factor.

Unit is the one unit type; every function that takes a unit takes a &Unit.

5.1 The unit-profile notations (spec 1.2)

These notations are spec 1.2: in a document they need a #!bovnar 1.2 directive, while the API entry points below have no document and take them unconditionally. Unit::parse takes a profile notation as readily as the native one, and returns the same Unit either way — so everything else in this chapter works on the result unchanged. Seven namespaces are accepted — ucum:, unece:, qudt:, qudt-qk:, udunits:, om: and cf: — but each is a compile-time switch of the C library, so which of them a given build carries is a question to ask rather than assume; see available_profiles() below.

use bovnar::{ErrorCode, Unit};
use bovnar::units::{
    available_profiles, units_compatible, unit_error_code, unit_is_profile_only, unit_to_profile,
    unit_to_str, unit_to_ucum,
};

fn main() -> Result<(), bovnar::Error> {
    let vu = Unit::parse("ucum:mm[Hg]")?;
    assert_eq!(unit_to_str(&vu)?, "mmHg");               // the native canonical form
    assert_eq!(unit_to_ucum(&vu)?, "mm[Hg]");            // back to a UCUM code
    assert!(units_compatible(&vu, &Unit::parse("k~Pa")?));

    // Which profiles THIS build carries. Unit::parse fails with the same
    // Error::Argument for an absent namespace as for a bad unit;
    // unit_error_code() is what tells the two apart (49 against 32).
    assert_eq!(available_profiles(), ["ucum", "unece", "qudt", "qudt-qk", "udunits", "om", "cf"]);

    // The same unit in other vocabularies — all one Unit
    let kg = Unit::parse("k~g")?;
    assert_eq!(unit_to_profile("ucum", &kg)?, "kg");
    assert_eq!(unit_to_profile("unece", &kg)?, "KGM");
    assert_eq!(unit_to_profile("qudt", &kg)?, "KiloGM");
    assert_eq!(unit_to_profile("udunits", &kg)?, "kg");
    assert_eq!(kg.to_profile("unece")?, "KGM");           // the method form

    let iu = Unit::parse("ucum:[IU]/mL")?;
    assert!(unit_is_profile_only(&iu));                   // no native spelling exists
    assert_eq!(unit_to_str(&iu)?, "ucum:[IU].mL-1");

    let packaging = Unit::parse("unece:XBX")?;
    assert!(packaging.is_profile_only());              // a countable package
    assert_eq!(unit_to_str(&packaging)?, "unece:XBX");    // in its OWN namespace

    assert_eq!(unit_error_code("ucum:B[SPL]"), ErrorCode::UnitProfileUnsupported as i32); // 50
    assert_eq!(unit_error_code("udunits:days since 1970-01-01"), 50); // valid UDUNITS, not carryable
    assert_eq!(unit_error_code("nosuch:m"), ErrorCode::UnitProfileUnknown as i32);        // 49
    assert_eq!(unit_error_code("m/s"), 0);                // it parses
    Ok(())
}

unit_to_profile and unit_to_ucum fail rather than inventing a code, in three situations. A native unit outside that vocabulary's transliteration table has no form in it — the Old German units, the water-hardness degrees, the turbidity kinds and every currency. An opaque unit belonging to a different profile has none either: [IU] is UCUM's, so unit_to_profile("unece", …) refuses it. And a flat vocabulary (unece, qudt, qudt-qk, om) can spell only a single component — no product, quotient or exponent, though a prefixed one is fine (k~g is unece:KGM) — because a flat code is one whole token with no operator to build it out of — so k~m/h has no UNECE form even though unece:KMH parses to exactly it.

In a document the notation additionally requires a #!bovnar 1.2 directive; without it parse fails with unit_illegal. Unit::parse has no document and therefore no declared version, so it accepts the notation unconditionally.

See Unit Profiles for the transliteration tables and the codes that have no representation.

5.2 Extended unit functions

The following functions operate on Unit values and live in bovnar::units.

use bovnar::{BaseUnit, Exponent, IECPrefix, Prefix, Unit};
use bovnar::units::{
    convert_value, exponent_to_int, int_to_exponent, prefix_unit_valid, unit_convert_factor,
    unit_dimension_vector, unit_prefix_exponent, unit_prefix_factor, unit_reduce,
    unit_to_si_factor, unit_to_str_ex, unit_valid, units_compatible, UnitFlags,
};

fn main() -> Result<(), bovnar::Error> {
    let km = Unit::parse("k~m")?;
    let gib = Unit::parse("Gi~B")?;

    // Validate a Unit
    assert!(unit_valid(&km));

    // Prefix scale factor (SI or IEC) for a Unit
    assert_eq!(unit_prefix_factor(&km), 1000.0);
    assert_eq!(unit_prefix_factor(&gib), 2f64.powi(30));

    // Prefix exponent (base-10 for SI, base-2 for IEC)
    assert_eq!(unit_prefix_exponent(&km), 3);
    assert_eq!(unit_prefix_exponent(&gib), 30);

    // Validate a prefix for a base unit (IEC prefixes are only valid on bit/byte)
    let p = Prefix::Iec(IECPrefix::Gibi);
    assert!(prefix_unit_valid(p, BaseUnit::Byte));
    assert!(!prefix_unit_valid(p, BaseUnit::Meter));

    // Full SI conversion including affine terms (e.g. Celsius → Kelvin)
    let c = unit_to_si_factor(&Unit::parse("°C")?)?;
    assert!(c.is_affine);
    assert_eq!(c.affine_offset, 273.15);

    // Check convertibility: same SI dimension vector AND same quantity kinds
    assert!(units_compatible(&km, &Unit::parse("m")?));
    assert!(!units_compatible(&Unit::parse("b")?, &Unit::parse("B")?)); // the dimensions agree, the kinds do not

    // Conversion factor between two compatible units
    let conv = unit_convert_factor(&km, &Unit::parse("m")?)?;
    assert_eq!((conv.factor, conv.requires_affine, conv.ok()), (1000.0, false, true));

    // 7-element SI dimension exponent vector [m, kg, s, A, K, mol, cd]
    assert_eq!(unit_dimension_vector(&Unit::parse("m/s")?)?, [1, 0, -1, 0, 0, 0, 0]);

    // Fold every prefix out and combine repeated bases. NOT the named-SI collapse:
    // the reduced unit of k~g·m/s² is "m·g/s²", and it is
    // unit_to_str_ex(.., UnitFlags::REDUCE) that gives "N". See the warning in §5.3
    // before applying r.scale to a value you are also going to serialise.
    let r = unit_reduce(&Unit::parse("k~g·m/s²")?)?;
    assert_eq!(r.unit.to_string(), "m·g/s²");
    assert_eq!(r.scale, 1000.0);

    // Convert a scalar value between units (handles affine conversions)
    let kelvin = convert_value(25.0, &Unit::parse("°C")?, &Unit::parse("K")?)?;
    assert!((kelvin - 298.15).abs() < 1e-9);

    // ... or EXACTLY, as the reader's want_unit does: (BigRational, exact),
    // exact false for an irrational factor (° to rad)
    let v = num_rational::BigRational::from_integer(42.into());
    let (ms, exact) =
        bovnar::units::convert_rational(&v, &Unit::parse("k~m/h")?, &Unit::parse("m/s")?)?;
    assert_eq!(ms, num_rational::BigRational::new(35.into(), 3.into()));
    assert!(exact);

    // Serialise with formatting options (see UnitFlags below)
    let force = Unit::parse("k~g·m/s²")?;
    assert_eq!(unit_to_str_ex(&Unit::parse("m/s²")?, UnitFlags::ASCII_EXP)?, "m/s^2"); // ^N instead of superscripts
    assert_eq!(unit_to_str_ex(&force, UnitFlags::REDUCE)?, "N");                      // reduce first — but see the warning below
    assert_eq!(unit_to_str_ex(&force, UnitFlags::REDUCE | UnitFlags::ASCII_EXP)?, "N");

    // Exponent ↔ integer conversions
    assert_eq!(exponent_to_int(Exponent::NEG_SQUARE), -2);
    assert_eq!(int_to_exponent(-2), Exponent::NEG_SQUARE);
    Ok(())
}

SI_DIM_NAMES is the array ["m", "kg", "s", "A", "K", "mol", "cd"] — the index positions used by unit_dimension_vector. Both SIConversion (factor, is_affine, affine_offset) and UnitConversion (factor, requires_affine, and the method ok(), §13) are small Copy structs, and ReducedUnit is { unit, scale }.

5.3 UnitFlags

use bovnar::units::UnitFlags;

fn main() {
    assert_eq!(UnitFlags::NONE, 0);           // no special formatting
    assert_eq!(UnitFlags::REDUCE, 1);         // reduce to a canonical named SI unit before serialising
    assert_eq!(UnitFlags::ASCII_EXP, 2);      // use ^N exponent notation instead of Unicode superscripts
    assert_eq!(UnitFlags::NUM_CANONICAL, 4);  // writer only: a float REDUCE rescaled keeps float shape
}

UnitFlags is a namespace of u32 constants, not an IntFlag type: OR them together and pass the u32. The same flags configure a writer: Writer::to_mem_with(cap, pretty, UnitFlags::REDUCE, false) rewrites each unit as it is written and rescales the value with it — 2.5 k~g goes out as <float:64,g> 2500. NUM_CANONICAL is meaningful only there: it appends .0 to a rescaled float that came out as an integer string, so the canonical document mode lands on one spelling.

UnitFlags::REDUCE returns the reduced unit, not a rescaled value. Reduction folds every prefix out, so unit_to_str_ex(&k~g, UnitFlags::REDUCE) is "g" — a string denoting a quantity 1000× smaller than the unit you passed. Moving the value with it is your job. (The writer does it for you; nothing else does.)

Do not use unit_reduce(&u)?.scale for that. It is the scale to the fully reduced unit, and this function does not always emit the fully reduced unit: where the reduction lands on a named SI unit the formatter re-attaches the prefix. k~N comes back "k~N" with nothing to rescale while unit_reduce still reports 1000.0, and k~g comes back "g" where the 1000.0 must be applied. The two are indistinguishable from outside — both a lone unit carrying a kilo prefix — so take the scale from the unit that is actually emitted:

```rust use bovnar::Unit; use bovnar::units::{convert_value, unit_to_str_ex, UnitFlags};

fn main() -> Result<(), bovnar::Error> { let vu = Unit::parse("k~g")?; let mut value = 2.5; let emitted = Unit::parse(&unit_to_str_ex(&vu, UnitFlags::REDUCE)?)?; value *= convert_value(1.0, &vu, &emitted)?; // 1.0 for k~N, 1000.0 for k~g assert_eq!((emitted.to_string().as_str(), value), ("g", 2500.0)); Ok(()) } ```

The collapse never substitutes one named unit for another, so a Sv stays a Sv rather than becoming a Gy. A lone base at an exponent other than 1 does still collapse (s⁻¹ → "Hz", m~s⁻¹ → "k~Hz"), and a reduction whose summed exponent leaves the ±100 range (EXPONENT_MIN / EXPONENT_MAX) fails with Error::Argument instead of returning a unit that lost a dimension — m⁵⁰·m⁵⁰ reduces to m¹⁰⁰, m⁵⁰·m⁵¹ fails.

5.4 Prefix

Prefix is one value for what the C struct value_unit_prefix_t splits into a system and an id: an enum with an SI and an IEC variant. It is what UnitComponent::prefix holds:

use bovnar::{IECPrefix, Prefix, SIPrefix, Unit};

fn main() -> Result<(), bovnar::Error> {
    let p_si = Prefix::Si(SIPrefix::Kilo);
    let p_iec = Prefix::Iec(IECPrefix::Gibi);
    assert!(!p_si.is_none() && !p_iec.is_none());
    assert_eq!(Prefix::default(), Prefix::Si(SIPrefix::None));

    let vu = Unit::parse("Gi~B")?;
    let comp = vu.components()[0];
    assert_eq!(comp.prefix, p_iec);             // the Prefix of a component
    assert_eq!(comp.prefix, IECPrefix::Gibi);   // compares equal to the bare IEC prefix as well
    Ok(())
}

Prefix::default() is no prefix (SIPrefix::None), and an unprefixed component equals whatever its prefix system.

5.5 Inline unit suffix

In addition to the unit embedded in a type annotation (<float:64,m/s>), the Bovnar format supports an inline unit suffix placed directly after a scalar value, before the terminating ;:

.speed = 9.81 m/s;           # inline suffix, no annotation
.mass  = <float:64> 70.5 k~g;# annotation without unit, inline adopted
.dist  = <float:64,m> 1.5 m; # annotation and inline both say 'm' — valid

From the Rust layer, inline and annotation units are identical: both reach the application as d.value_unit in the Event::Data payload. No extra code is required to consume inline units.

The validator fails with ErrorCode::UnitMismatch (38, an Error::Parse) when an inline suffix is present and a type-annotation unit is also present but the two do not resolve to the same unit. Inline unit suffixes inside array elements always fail with ErrorCode::UnexpectedInputByte.

5.6 UnitPolicy — validation and conversion without a callback

Reader::set_unit_policy states what the document must contain and what unit values should arrive in. Everything is unit text, so unlike want_unit it costs no per-value trampoline across the FFI boundary — the C side parses the strings once, when the policy is set.

use bovnar::{EventData, ReadOptions, Reader, UnitPolicy};

fn main() -> Result<(), bovnar::Error> {
    let mut r = Reader::new()?;
    r.set_unit_policy(Some(&UnitPolicy {
        targets: vec![("m/s".into(), 0), ("°C".into(), 0)], // convert to the first one that fits
        normalise_si: true,    // ...and everything else to SI base units
        leave_inexact: true,   // step over what cannot be exact
        require_unit: true,    // reject any bare numeric value
        ..UnitPolicy::default()
    }))?;

    let mut delivered = Vec::new();
    let mut handler = |d: &EventData<'_>| {
        if d.converted {
            delivered.push(String::from_utf8_lossy(d.converted_text).into_owned());
        }
        true
    };
    r.read_mem(b".v = <float:64,k~m/h> 36.0;", &ReadOptions::default(), Some(&mut handler), None)?;
    assert_eq!(delivered, ["10"]);
    Ok(())
}

A policy survives re-reading the same reader on another document — it describes the consumer, not the document — and set_unit_policy(None) clears it. Every unit string is parsed by the call, so a typo fails with Error::Argument before a byte is read, and a rejected policy leaves the previous one in force. The struct is plain data: UnitPolicy { rules, targets, normalise_si, base, leave_inexact, require_unit, require_dimension_of }, so ..UnitPolicy::default() fills what a policy leaves out. At most MAX_UNIT_TARGETS (8) targets, MAX_UNIT_RULES (8) rules, and MAX_UNIT_PATH (96) bytes of path are accepted.

Conversion. Each numeric value takes the first target it can validly convert to, so order is significant: ["m", "k~m"] never selects k~m. A value that matches nothing, or that is already in the unit a target names, arrives untouched — d.converted is what tells the two apart. Each target carries its own output base as the second tuple element: ("m".into(), 16); 0 is decimal.

normalise_si catches whatever the targets did not, delivering it in coherent SI base units with prefixes folded out. Currencies and every dimensionless unit (%, ppm, dB, pH, rad, °) are left as written rather than reinterpreted.

A value with no unit only ever matches a target that is itself no_unit. A bare number is dimensionally compatible with % and ppm, so without that fence a policy naming "%" would deliver 0.25 as 25.

Exactness. Nothing approximate is ever delivered. leave_inexact: true hands over a value the conversion cannot deliver exactly — an irrational factor, a non-terminating expansion (42 km/h is 35/3 m/s), an exponent past the work limit — in its native unit instead of failing. It applies only to policy-chosen targets; a want_unit hook keeps its strict all-or-nothing contract.

Validation. require_unit rejects a document containing any bare numeric value; require_dimension_of: vec!["m".into()] requires every numeric value to be validly convertible to at least one listed unit. Both are evaluated on the unit the document wrote, before any conversion, and both fail with ErrorCode::UnitMismatch.

Per-field rules name one field by its key path, and are consulted before everything else:

use bovnar::{UnitPolicy, UnitRule};

fn main() {
    let policy = UnitPolicy {
        rules: vec![
            UnitRule::new(".inlet.temperature", "°C"), // convert this field
            UnitRule { convert: false, ..UnitRule::new(".inlet.*", "m") }, // assert the subtree
        ],
        ..UnitPolicy::default()
    };
    assert_eq!(policy.rules.len(), 2);
}

A path ending in ".*" names a subtree at any depth, and a prefix only matches at a component boundary (".in.*" does not claim ".inlet.a"). Unlike a whole-document target, a rule is an assertion: a value it cannot be applied to fails with UnitMismatch, because silence would defeat the point of naming the field. convert: false asserts without converting.

The DOM takes the same policy, so a random-access consumer gets both halves:

use bovnar::{DomDoc, UnitPolicy, UnitRule};

fn main() -> Result<(), bovnar::Error> {
    let text = ".inlet = { .temperature = <float:64,°F> 212.0; };".as_bytes();
    let policy = UnitPolicy {
        rules: vec![UnitRule::new(".inlet.temperature", "°C")],
        ..UnitPolicy::default()
    };
    let doc = DomDoc::parse_with_policy(text, &policy)?;
    let t = doc.lookup("inlet.temperature").expect("present");
    assert_eq!(t.as_float(), Some(100.0)); // from a document in °F
    Ok(())
}

A value the policy converted is STORED converted — digits, unit and base — and an integer that converts to a fraction (5 g in kilograms is 0.005) is stored as a float. A policy the library refuses is an Error::Argument — the same error, naming the same entry, as Reader::set_unit_policy — before anything is parsed, rather than a parse error, so a mistake in the policy is never mistaken for a fault in the document.

The writer takes the same policy, validation half only. A reader can only reject a document somebody already wrote; Writer::set_unit_policy is what stops a bare number reaching the file:

use bovnar::{BaseUnit, Exponent, Quantity, SIPrefix, Unit, UnitPolicy, ValueTypeFamily, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.set_unit_policy(Some(&UnitPolicy {
        require_unit: true,
        require_dimension_of: vec!["m".into()],
        ..UnitPolicy::default()
    }))?;

    let float64 = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 64, base: 0 };
    let inches = Unit::parse("in")?;
    w.write_quantity("tank_level", &Quantity::new("12.0", float64, inches))?; // a length — written
    let err = w.write_quantity("spare", &Quantity::new("0.25", float64, Unit::default())).unwrap_err();
    assert!(matches!(err, bovnar::Error::Write { .. })); // code UnitMismatch
    let _ = (BaseUnit::Meter, Exponent::LINEAR, SIPrefix::Kilo); // see §5.7 for building units
    Ok(())
}

A policy carrying targets, normalise_si, base or leave_inexact fails with Error::Argument there rather than being half-honoured: write-side unit rewriting is UnitFlags::REDUCE's job — open the writer with Writer::to_mem_with(cap, pretty, UnitFlags::REDUCE, false) — and two rewriting modes with different rules about exactness is how a document ends up meaning two things.

Module-level helpers back the same decisions outside the reader:

use bovnar::Unit;
use bovnar::units::{unit_si_normal_form, units_compatible, units_convertible};

fn main() -> Result<(), bovnar::Error> {
    let (kusd, usd) = (Unit::parse("k~$USD")?, Unit::parse("$USD")?);
    // units_compatible is the wrong screen for a conversion target: a currency
    // carries no dimension, so it is reported incompatible with itself.
    assert!(!units_compatible(&kusd, &usd));
    assert!(units_convertible(&kusd, &usd));

    assert_eq!(unit_si_normal_form(&Unit::parse("in")?), Some(Unit::parse("m")?));
    assert_eq!(unit_si_normal_form(&Unit::parse("g")?), Some(Unit::parse("k~g")?)); // the SI base unit for mass
    assert_eq!(unit_si_normal_form(&Unit::parse("%")?), None);                      // dimensionless
    assert_eq!(unit_si_normal_form(&Unit::parse("lm")?), None);                     // carries the steradian's kind
    Ok(())
}

Three questions, in decreasing strictness. units_equal asks whether the two are the same unit, units_compatible whether their dimensions and quantity kinds line up, units_convertible whether a conversion can actually be performed. Only the last is the right screen for a conversion target.

use bovnar::Unit;
use bovnar::units::units_equal;

fn main() -> Result<(), bovnar::Error> {
    let p = Unit::parse;
    assert!(units_equal(&p("m")?, &p("m")?));
    assert!(!units_equal(&p("m")?, &p("k~m")?));             // convertible, not equal
    assert!(!units_equal(&p("N")?, &p("k~g·m/s²")?));        // same SI form
    assert!(units_equal(&p("mmHg")?, &p("ucum:mm[Hg]")?));
    Ok(())
}

units_equal is also what == means between two Units, with one addition: "no unit at all" (a typed value's empty unit) and a dimensionless unit (a bare literal's) are both the absence of one and compare, and hash, equal, as the Python binding's do. Unit is Eq and Hash with the two agreeing — equal units are one HashMap key regardless of the order their components were written in. The hash follows the equality in the one place the two could part: a component with no prefix is the same component whether its prefix system says SI or IEC, so Unit::parse("B") and Unit::iec(BaseUnit::Byte, IECPrefix::None, Exponent::LINEAR) are equal and one key.

A Unit also compares equal to a string that parses to it (unit == "k~m", via PartialEq<str> and PartialEq<&str>); a string that does not parse is simply unequal. That is a convenience for comparison only and is not hash-compatible — "km", "k~m" and "ucum:km" are three strings with three hashes and one unit. Key a map or set on Unit values (or on unit.to_string()), never on a mix of the two.

Unit::convert_factor(&other) and Unit::factor() return a bare number, so they fail with Error::Argument where there is no such number: an affine pair (°C → K) has no multiplicative factor and an affine unit no SI factor. Use convert_value() there, or unit_to_si_factor() for the scale and the offset.

5.7 Building a Unit

Unit::parse builds a unit from text. To build one programmatically — without going through the notation — use the constructors on Unit:

use bovnar::{BaseUnit, Exponent, IECPrefix, SIPrefix, Unit, UnitComponent};

fn main() -> Result<(), bovnar::Error> {
    let kg = Unit::si(BaseUnit::Gram, SIPrefix::Kilo, Exponent::LINEAR);
    assert_eq!(kg.to_string(), "k~g");
    let m2 = Unit::si(BaseUnit::Meter, SIPrefix::None, Exponent::SQUARE);
    assert_eq!(m2.to_string(), "m²");
    let gib = Unit::iec(BaseUnit::Byte, IECPrefix::Gibi, Exponent::LINEAR);
    assert_eq!(gib.to_string(), "Gi~B");

    let newton = Unit::compound(&[
        UnitComponent::si(BaseUnit::Gram, SIPrefix::Kilo, Exponent::LINEAR),
        UnitComponent::si(BaseUnit::Meter, SIPrefix::None, Exponent::LINEAR),
        UnitComponent::si(BaseUnit::Second, SIPrefix::None, Exponent::NEG_SQUARE),
    ])?;
    assert_eq!(newton.to_string(), "k~g·m/s²");
    Ok(())
}

The C constructors make_unit_si, make_unit_iec and make_unit_compound are Unit::si, Unit::iec and Unit::compound; the prefix and exponent are always passed (SIPrefix::None, Exponent::LINEAR for a plain base unit), and Unit::compound fails with Error::Argument beyond MAX_UNIT_COMPONENTS (32) components.

Unit::default() and Unit::dimensionless() are not the same thing, even though both render as no_unit. The first (the C make_unit_none()) is a unit with no components at all — a value carrying no unit. The second (the C make_unit_dimensionless()) is one component whose base is BaseUnit::None — a value carrying no_unit explicitly. That is exactly the pair UnitPolicy { require_unit: true, .. } refuses together (§5.6):

use bovnar::Unit;
use bovnar::units::unit_to_str;

fn main() -> Result<(), bovnar::Error> {
    let (none, dimensionless) = (Unit::default(), Unit::dimensionless());
    assert_eq!(unit_to_str(&none)?, "no_unit");
    assert_eq!(unit_to_str(&dimensionless)?, "no_unit");
    assert_eq!((none.num_components(), dimensionless.num_components()), (0, 1));
    assert!(Unit::is_dimensionless(&none) && Unit::is_dimensionless(&dimensionless));
    // Display, unlike unit_to_str, spells a unit-less value as nothing at all
    assert_eq!(none.to_string(), "");
    Ok(())
}

Unit::is_dimensionless() is true for both of them and for any unit made only of none components; % and ppm are real units and answer false.

5.8 Validators and literal helpers

bovnar::literals binds the C library's checks and literal helpers (bovnar.h): the tests the writer applies to keys, symbols, references and numbers, the integer and float parsers and formatters behind it, and the parser of a type annotation. Each function is the C function of the same name without its bvn_ prefix (escape_repl_table is bvn_get_escape_repl_table), and answers as it does. A string holding NUL is none of the things they check: a validator answers false for it, a parser fails — except validate_string, which takes bytes and checks only that they are UTF-8, so NUL passes.

Type annotations. parse_type_annotation(text) parses the text between the angle brackets, as the reader does, into a TypeAnnotation of value_type (a ValueTypeSpec), unit (a Unit, Unit::default() when there is no unit or it is spelled no_unit) and unit_source (the unit parameter as written, "" when there is none). The struct is #[non_exhaustive], so that a field can be added to it without breaking anyone.

use bovnar::literals::parse_type_annotation;
use bovnar::{ErrorCode, ValueTypeFamily, ValueTypeSpec};

fn main() -> Result<(), bovnar::Error> {
    let a = parse_type_annotation("uint:32,_16,k~g")?;
    let spec = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 32, base: 16 };
    assert_eq!(a.value_type, spec);
    assert_eq!(a.unit.to_string(), "k~g");
    assert_eq!(a.unit_source, "k~g");
    let ml = parse_type_annotation("float:64,ucum:mL{total}")?;
    assert_eq!((ml.unit.to_string(), ml.unit_source.as_str()), ("m~L".into(), "ucum:mL{total}"));

    // A base given twice; a unit parameter over 255 bytes.
    let e = parse_type_annotation("uint:8,_16,_16").unwrap_err();
    assert_eq!(e.code(), Some(ErrorCode::IllegalValueType));
    let e = parse_type_annotation(&format!("uint:8,{}", "m".repeat(256))).unwrap_err();
    assert_eq!(e.code(), Some(ErrorCode::UnitTooLong));
    Ok(())
}

A refusal is an Error::Parse (with no position) whose code is the one the reader gives the same annotation in a document, so it tells the C function's three flags apart:

Code C flags Cause
IllegalValueType type_ok false An unknown family, an empty or repeated parameter, a parameter the family does not take (a base on float_fix, a width on bool), a width or base out of range
UnitTooLong unit_ok false, unit_too_long true A unit parameter over 255 bytes
UnitIllegal, UnitProfileUnknown, UnitProfileUnsupported unit_ok false A unit parameter that is not a unit this build knows; the code is the one units::unit_error_code gives it

The reader's version gates are not applied: datetime (spec 1.1) and a profile unit (spec 1.2) parse here whatever version a document declares. A datetime's epoch is no unit: its index is in value_type.base (datetime_epoch_index, §18), as a float_fix's q is.

Validators. Each returns a bool:

Function true for
validate_identifier(s) A key: an ASCII letter, _ or a character from U+00C0 on, then no space, control character, U+0080 to U+00BF, or punctuation spec §4.2 forbids (- and + are allowed). No length limit: the reader's max_identifier_length (255) is separate
validate_symbol(s) A symbol, which is spelled as a key is
validate_reference(s) A path such as .a.b[0], without the &: identifier segments, each with optional [digits] indexes
validate_number(s) A decimal number: an optional -, digits with at most one ., an optional e/E exponent. No +, and not inf
is_special_number_string(s) Exactly inf, ninf or nan
validate_digits_for_base(s, base) An integer literal: an optional - and digits of base (no sign at all in bases 64 and 85, where + and - are digits)
validate_number_in_base(s, base) A quoted float literal: either sign, digits of base with at most one ., and an exponent marked e (bases up to 14) or p (the power-of-two bases up to 16)
validate_uint_range(s, width, base) / validate_sint_range(s, width, base) A literal that fits width bits (0: any width); inf, ninf and nan pass
validate_string(bytes) Bytes that are well-formed UTF-8

Two of them are narrower than their names. validate_digits_for_base does not check the base against the ones a type may declare: in base 1 the one digit is 0. And validate_string is the same test as std::str::from_utf8; NUL and the other control bytes the writer refuses in a string pass it. The range checks are meant for text the digit check has passed: they refuse an empty literal, and in base 10 anything but digits after an optional sign (validate_uint_range(" 5", 8, 10) is false), but they take a leading +.

Digits. char_to_digit(ch, base) is the value of one digit, None when it is no digit of base: up to base 36 a letter's case does not matter, above it A to Z are 36 to 61, and bases 64 and 85 use the Base64 and Ascii85 alphabets. min_digits_for_type(vt) is how many digits of the type's base the largest value of an integer type of up to 64 bits takes; 0 for a width of 0 or above 64, and a float type is answered as if it were an unsigned integer of its width.

Parsing and formatting. These are the functions the writer and the DOM use. Each returns Err(Error::Argument) where the C one returns false or -1:

Function Result
parse_int64(s, vt) / parse_uint64(s, vt) The integer literal in the type's base; refused when it does not fit the type's width (64 when it declares none), and for a + or a space before it
parse_double(s, vt) / parse_double_in_base(s, base) The float literal (base 10 or 16; inf, ninf and nan, not -inf) as the nearest f64, rounded once from its exact value
format_uint64(value, base, min_digits) / format_int64(...) The digits in base 2 to 62, 64 or 85, padded with zero digits to min_digits (which the C function caps at 64). A negative value has no form in bases 64 and 85
format_double(value, vt) The literal the writer emits under vt: the shortest digits that read back to the same f64, in base 10 or 16 (any other base fails). A float:16 or float:32 value is first rounded to IEEE binary16 or binary32 (2049.0 is 2.048e+3 under float:16) and printed with the digits of a 16- or 32-bit significand, so it reads back as that value without rounding to the format (65504.0 is 6.5504e+4); a finite value the format cannot hold fails
escape_repl_table() A &'static [u8; 256]: for each byte, the byte the escape \ + that byte stands for, 0 for none. The escapes are t, n, v, f, r, " and \; the \x and \u escapes of spec 1.1 take digits and are not in it
use bovnar::literals;
use bovnar::{ValueTypeFamily, ValueTypeSpec};

fn main() -> Result<(), bovnar::Error> {
    assert!(literals::validate_identifier("max-speed"));
    assert!(!literals::validate_identifier("max.speed"));
    assert!(literals::validate_reference(".axis[2].unit"));
    assert!(!literals::validate_number("+1"));
    assert!(!literals::validate_uint_range("256", 8, 10));
    assert_eq!(literals::char_to_digit('Z', 62), Some(61));

    let u8t = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 8, base: 0 };
    let f64t = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 64, base: 0 };
    assert_eq!(literals::parse_uint64("255", u8t)?, 255);
    assert!(literals::parse_uint64("256", u8t).is_err());
    assert_eq!(literals::format_double(0.1, f64t)?, "1e-1");
    assert_eq!(literals::parse_double("1e-1", f64t)?, 0.1);
    assert_eq!(literals::format_uint64(5, 2, 8)?, "00000101");
    assert_eq!(literals::escape_repl_table()[usize::from(b't')], b'\t');
    Ok(())
}

No 0x in base 16. The integer parsers take what the reader takes, so parse_uint64("0x10", …) under uint:64,_16 fails, though strtoull, which they read with, would skip the prefix.

bvn_looks_like_double is not bound. It answers whether the text contains one of ., e, E, p and P, so deadbeef looks like a float to it and inf does not: a shortcut for the writer's own output, not a test of text from anywhere else.


6. Quantity

Quantity is a typed, unit-annotated scalar value that preserves the original text representation, type width, numeral base, and physical unit across a parse_typed / to_string round trip.

6.1 Construction

Quantity is normally produced by parse_typed rather than constructed by hand, but direct construction is supported. Its fields are public. The type is a ValueTypeSpec — a plain struct of the family (a ValueTypeFamily as its C integer), the bit width and the numeral base:

use bovnar::{Quantity, Unit, ValueTypeFamily, ValueTypeSpec};

fn main() -> Result<(), bovnar::Error> {
    let vt = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 32, base: 0 };
    let q = Quantity::new("101325.0", vt, Unit::default());       // dimensionless
    let q2 = Quantity::new("9.81", vt, Unit::parse("m/s²")?);     // with a unit
    assert_eq!(q.unit_str(), "");
    assert_eq!(q2.unit_str(), "m/s²");
    Ok(())
}

For exact numeric input there is also Quantity::from_number, which stores the value as an exact decimal literal (so writing it with to_string is lossless to the format's precision):

use bovnar::{Decimal, Quantity, Unit, ValueTypeFamily};
use num_rational::BigRational;

fn main() -> Result<(), bovnar::Error> {
    let price: Decimal = "19.99".parse()?;
    Quantity::from_number(price, ValueTypeFamily::FloatDec, 64, None, Unit::default())?;

    let one_point_one: Decimal = "1.1".parse()?;
    Quantity::from_number(one_point_one, ValueTypeFamily::Float, 128, None, Unit::default())?;

    // A float_fix takes its fractional bits: 837/256 is 3.26953125
    let ratio = BigRational::new(837.into(), 256.into());
    Quantity::from_number(ratio, ValueTypeFamily::FloatFix, 32, Some(8), Unit::default())?;
    Ok(())
}

It takes an impl Into<ExactNumber>: a literal (&str or String, used verbatim), a Decimal, a terminating BigRational, a BigInt, or an f64 (only as precise as the double). It validates the width for the chosen family. For float_dec it also refuses a finite value past the width's range — 1e400 does not fit float_dec:64 and would materialise to infinity — and a non-finite f64 is stored under the format's own spelling (inf, ninf, nan). A float_fix needs its frac (Some(q)).

6.2 Fields and methods

Quantity is a plain struct: the properties that merely hold a value are public fields and the others are methods:

Name Type Description
q.raw Option<String> Original text token as it appeared in the BVNR stream; None for a typed null
q.vtype ValueTypeSpec Type family, bit width, and numeral base
q.unit Unit Physical unit; q.unit.to_string() is its canonical text
q.token_type TokenType How the literal was written: number, quoted string, symbol, ...
q.frac Option<String> For a datetime written as a literal with a fractional second, the verbatim sub-second digits (spec 1.1)
q.unit_source Option<String> The unit exactly as the document spelled it (library 2.0) — ucum:mL{total} where q.unit is m~L. Set by parse_typed; to_string() writes it back, and refuses one that does not parse to q.unit — including a spelling on a Quantity that has no unit at all. None writes the canonical form
value() Value Decode raw to the closest plain value (Int, Float, String, Bool) — lossy for float_dec / float_fix / float:128+ (goes through a C double)
q.family() Option<ValueTypeFamily> The type family, when it is one this binding knows
unit_str() String Canonical unit string (e.g. "m/s²"), or "" when dimensionless
decimal() Result<Option<Decimal>> Exact value as a Decimal from the verbatim literal — lossless at any width; an error for non-numeric families, None for a typed null
fraction() Result<Option<BigRational>> Exact value as a rational (for float_fix, the exact mantissa / 2**frac). An error for nan/inf, and for a literal whose decimal exponent is past bovnar::FRACTION_EXPONENT_CAP (§6.3)
fixed_point() Result<(BigInt, u32)> (mantissa, frac_bits) of a float_fix value; the value is mantissa / 2**frac_bits. An error for a typed null and for nan/inf/ninf, which a fixed-point datum has no encoding for
stored_value() Result<Option<Decimal>> The value materialised into the declared IEEE/fixed format (round-to-nearest-even) — differs from decimal only when the literal carries more precision than the format holds. None for a typed null, as decimal is
ieee_bits() Result<Vec<u8>> IEEE-754 interchange bytes (binary16…256 for float, decimal16…256 for float_dec), little-endian word order
epoch_name() Option<String> For a datetime, the epoch name ("unix", "tai", …); None otherwise
epoch_mjd() Option<i32> For a datetime, the epoch's Modified Julian Day; None otherwise
to_civil() Result<CivilDateTime> For a datetime, its UTC date and time (§6.5); an error otherwise
type_key() (i32, u32, u32) (family, width, base) with the defaults filled in: width 0 is 64, base 0 is 10; a float_fix Q and a datetime epoch as stored

Two Quantitys are equal when they carry the same literal, sub-second digits, effective type (type_key()) and unit: <float:0> 2.5, <float:64,_10> 2.5 and a bare 2.5 are one value, and the token form and unit spelling are presentation — the same rule as the Python binding's Quantity.__eq__.

6.3 Exact numbers (Decimal, BigRational, BvnFloat)

value() decodes through a C double, which loses precision for the decimal-float, fixed-point, and wide binary-float families. The accessors above instead use the verbatim literal text (and bovnar's arbitrary-precision bvn_float), so the full precision the format carries is reachable from Rust.

An integer is i128 in a Value (Value::BigInt past it) and num_bigint::BigInt where a number has no width limit, a rational is num_rational::BigRational, and a decimal is bovnar::Decimal: an exact decimal — sign, coefficient (BigUint) and exponent, or an infinity or a NaN — that compares numerically and prints in the General Decimal Arithmetic scientific-string form (1.50 stays 1.50). Decimal is the exact value only; it does no arithmetic.

use bovnar::{Decimal, Value, parse_typed};

fn main() -> Result<(), bovnar::Error> {
    let doc = parse_typed(b".p=<float_dec:64> 3.141592653589793238462643383279503;")?;
    let Value::Struct(fields) = doc else { unreachable!() };
    let Value::Quantity(p) = &fields[0].1 else { unreachable!() };

    // Quantity::value is lossy: a C double
    assert_eq!(p.value(), Value::Float(3.141592653589793));
    // the literal, every digit of it ...
    let literal: Decimal = "3.141592653589793238462643383279503".parse()?;
    assert_eq!(p.decimal()?, Some(literal));
    // ... and the value decimal64 stores: 16 digits, rounded half to even
    assert_eq!(p.stored_value()?.map(|d| d.to_string()), Some("3.141592653589793".into()));
    assert_eq!(p.ieee_bits()?.len(), 8);

    let doc = parse_typed(b".x=<float_fix:32,q8> 3.27;")?;
    let Value::Struct(fields) = doc else { unreachable!() };
    let Value::Quantity(f) = &fields[0].1 else { unreachable!() };
    assert_eq!(f.fraction()?, Some(num_rational::BigRational::new(837.into(), 256.into())));
    assert_eq!(f.fixed_point()?, (837.into(), 8));
    Ok(())
}

These materialise over the full representable range, including exponents the C parser cannot otherwise reach (beyond ~1e9865). Overflow and underflow past the format's range are decided from the decimal exponent, so a 28-byte <float:64> 1e-99999999 is 0.0 at once rather than an exact rational with a hundred-million-digit denominator. fraction() has no format to round into: it refuses a literal whose decimal exponent, of its magnitude or of its last digit, is past bovnar::FRACTION_EXPONENT_CAP (100 000; float_dec:256, which reaches 10⁻⁶¹¹⁸⁶⁷, gets 611 937), the same cap as the Python binding's. decimal() is exact at any exponent. decimal / fraction work at every binary width bovnar allows (16, or a multiple of 32 up to 32768); the bit-exact stored_value / ieee_bits apply to the IEEE encodings (float:16/32/64/128/256), which are also exposed directly as bovnar::BvnFloat.

BvnFloat and the bvnfloat module reach the library's arbitrary-precision float: the IEEE binary and decimal interchange encodings and the fixed-point datum, plus the exact materialisers used past the C parser's exponent cap.

use bovnar::BvnFloat;

fn main() -> Result<(), bovnar::Error> {
    let f = BvnFloat::from_str("0.1", 10, 256)?;     // text, base, precision in bits
    assert!(!f.is_nan() && !f.is_inf() && !f.is_zero());
    assert_eq!(f.to_double(), 0.1);
    let bits = f.to_binary_bits(32)?;                // binary32, little-endian
    assert_eq!(bits.len(), 4);
    assert_eq!(BvnFloat::from_binary_bits(32, &bits)?.to_double(), 0.1f32 as f64);
    Ok(())
}

Exactness. The stored_value of a float_fix is exact, never rounded to a working precision, however wide the datum; the same goes for the literal of a terminating Fraction.

6.4 to_string() integration

to_string dispatches on Value::Quantity before the plain Int / Float path, so any value that came from parse_typed can be passed directly to to_string and will produce identical output:

use bovnar::{parse_typed, to_string};

fn main() -> Result<(), bovnar::Error> {
    let bvnr = b".speed = <float:32,m/s> 9.81;";
    let doc = parse_typed(bvnr)?;
    let out = to_string(&doc, false)?;
    assert_eq!(out, b".speed=<float:32,m/s>9.81;");
    assert_eq!(parse_typed(&out)?, doc);
    Ok(())
}

The annotation is left out where it says nothing the literal does not — i.e. when the type is float:64 with no unit and no non-decimal base (matching the C library's own default-annotation omission rules).

to_string also accepts a bare Value::Decimal (written as an exact float_dec:64 literal) and a terminating Value::Fraction — a non-terminating fraction (e.g. 1/3) fails with Error::Argument, as does a Decimal outside the range of float_dec:64. An array whose elements are all Decimal / Fraction is written under one <float_dec:64> annotation, so the elements mean what the scalars mean:

use bovnar::{Value, to_string};
use num_rational::BigRational;

fn main() -> Result<(), bovnar::Error> {
    let half = BigRational::new(1.into(), 2.into());
    let doc = Value::Struct(vec![("h".into(), Value::Fraction(half))]);
    assert_eq!(to_string(&doc, false)?, b".h=<float_dec:64>0.5;");

    let third = BigRational::new(1.into(), 3.into());
    let bad = Value::Struct(vec![("t".into(), Value::Fraction(third))]);
    assert!(matches!(to_string(&bad, false), Err(bovnar::Error::Argument { .. })));
    Ok(())
}

Arrays of Quantity. An array has one annotation. Elements that agree on type and unit are written under it. Elements that agree on the unit but not the type — or that carry no unit and differ in type, [<uint:8> 1, <float:64> 2.5] — are each written under their own annotation, as they were read. Elements that carry different units have no valid form and fail.

Octet streams. parse() returns an octet stream as Value::Bytes, and to_string() writes Value::Bytes back as one, so a document holding one round-trips.

What "can be passed directly to to_string()" does not promise is that the result passes the document tier: to_string() writes what parse() reads, and parse() reads heterogeneous and ragged arrays that DomDoc::parse and bovnar validate refuse. See the note in §4.1.

6.5 Civil time and calendars

A datetime value is a count of seconds since its epoch (§18). bovnar::timekeeping turns such counts into civil dates and times and back, and carries the rest of the C library's calendar: bvn_datetime.h and bvn_gregorian_date.h. Every result is computed by the C functions, so the crate gives the answers the reader and writer use when they read and print an ISO literal. Nothing is read from the host's time zone or clock, so the module behaves the same on every target, wasm32-wasip2 included.

use bovnar::timekeeping::{self, CivilDateTime, Epoch, GregorianDate};
use bovnar::{Value, parse_typed};

fn main() -> Result<(), bovnar::Error> {
    let doc = parse_typed(b"#!bovnar 1.1\n.t = <datetime:64,tai> 1861920036;")?;
    let Value::Struct(fields) = doc else { unreachable!() };
    let Value::Quantity(t) = &fields[0].1 else { unreachable!() };
    assert_eq!(t.to_civil()?.to_string(), "2016-12-31T23:59:60");

    let leap = CivilDateTime::new(2016, 12, 31, 23, 59, 60)?;
    assert_eq!(timekeeping::utc_to_tai_seconds(&leap)?, 1_861_920_036);
    let next = timekeeping::tai_seconds_to_utc(1_861_920_037)?;
    assert_eq!(next.to_string(), "2017-01-01T00:00:00");
    let unix = timekeeping::epoch_seconds_to_utc(1_750_000_000, Epoch::Unix)?;
    assert_eq!(unix.to_string(), "2025-06-15T15:06:40");

    // Germany: UTC+1, one hour more in summer
    let t = timekeeping::utc_to_tai_seconds(&CivilDateTime::new(2024, 7, 1, 12, 0, 0)?)?;
    let dst = timekeeping::tai_second_is_dls_in_europe(t);
    let local = timekeeping::tai_seconds_to_local_time(t, 3600, dst)?;
    assert_eq!(local.to_string(), "2024-07-01T14:00:00");

    assert_eq!(GregorianDate::new(2000, 1, 1)?.to_mjd(), 51544);
    assert!(GregorianDate::new(2024, 2, 30).is_err());
    assert_eq!(GregorianDate::normalize(2024, 2, 30)?.to_string(), "2024-03-01");
    assert_eq!(timekeeping::easter_sunday(2025)?.to_string(), "2025-04-20");
    let good_friday = GregorianDate::new(2024, 3, 29)?;
    assert!(good_friday.is_federal_holiday_in_germany());
    let march = GregorianDate::new(2024, 3, 5)?;
    assert_eq!(march.last_banking_day_in_month()?.to_string(), "2024-03-28");
    Ok(())
}

The types. GregorianDate is a date of the proleptic Gregorian calendar (bvn_gregorian_date_t), with astronomical years (1 BC is year 0) from YEAR_MIN to YEAR_MAX, about ±2.5·10¹⁶. GregorianDate::new(year, month, day) takes only a real date; GregorianDate::normalize(year, month, day) takes any i64 fields and carries an out-of-range month or day into the next field, as mktime does. Its accessors are year(), month(), day(), leap(), wday() (0 = Sunday) and yday() (0 = 1 January), the last three from the library. CivilDateTime::new(year, month, day, hour, minute, second) adds a time of day (bvn_datetime_t) and checks hour 0–23, minute 0–59 and second 0–60; date() is its GregorianDate and mjd() the date's Modified Julian Day. Both are Copy, Eq (through the C equality functions), Hash and Ord (chronological), and Display as ISO 8601 (2016-12-31T23:59:60). Epoch is bvn_epoch_t: each discriminant is its epoch's MJD (Epoch::mjd()), Epoch::from_mjd of a datetime Quantity's epoch_mjd() is its epoch, and Epoch::from_name resolves the library's names ("unix", "y2000", …).

Seconds and epochs. The functions are those of the Python binding (doc/09 §6.5), under the same names and with the arguments in the same order. Two things differ: an epoch is an Epoch here, where Python also takes its MJD or its name, and is_last_banking_day_in_quarter / is_first_banking_day_in_quarter return a Result<bool>, an Error::Argument for a quarter outside 1–4, where Python raises:

Function C function Result
epoch_seconds_to_datetime(seconds, epoch) bvn_dt_epoch_seconds_to_datetime The civil time seconds after epoch on a uniform scale (86400 s a day, no leap seconds); every i64, so no Result
datetime_to_epoch_seconds(&dt, epoch) bvn_dt_datetime_to_epoch_seconds Its inverse; second == 60 counts as the next minute's first second
convert_epoch_seconds(seconds, from, to) bvn_dt_convert_epoch_seconds The same count relabelled to another origin; the time scale does not change
utc_to_tai_seconds(&dt) bvn_dt_utc_to_tai_seconds UTC to TAI seconds with the leap-second table (10 s before 1972, 37 s since 2017)
tai_seconds_to_utc(tai_seconds) bvn_dt_tai_seconds_to_utc Its inverse; an inserted leap second is 23:59:60
tai_seconds_to_local_time(tai_seconds, utc_offset_seconds, dls_active) bvn_dt_tai_seconds_to_local_time Wall-clock time at a fixed offset, one hour later when dls_active; the offset must be whole minutes
tai_second_is_dls_in_europe(tai_second) bvn_dt_tai_second_is_dls_in_europe Whether German/European summer time is in force (none before 1980)
epoch_seconds_from_gps_time(time_of_week_ms, week, epoch), tai_seconds_from_gps_time(time_of_week_ms, week) bvn_dt_epoch_seconds_from_gps_time, bvn_dt_tai_seconds_from_gps_time GPS week + time of week (ms, rounded to the second) as seconds; GPS time except on Epoch::Tai, where the 19 s are added
epoch_seconds_from_galileo_time(…), tai_seconds_from_galileo_time(…) bvn_dt_epoch_seconds_from_galileo_time, bvn_dt_tai_seconds_from_galileo_time The same for Galileo System Time
epoch_seconds_to_utc(seconds, epoch), utc_to_epoch_seconds(&dt, epoch) — A datetime value's UTC time and back, as the reader and writer convert an ISO literal: the leap-second table on tai, the uniform scale on unix, mjd, ntp and y2000; the GNSS epochs are an error
Quantity::to_civil() — epoch_seconds_to_utc of a datetime quantity; the sub-second digits stay in frac
a == b, a.is_date_equal(&b), a.is_time_equal(&b) bvn_dt_is_equal, bvn_dt_is_date_equal, bvn_dt_is_time_equal Field-wise comparison of two CivilDateTime

GPS and Galileo seconds stay in their own scale: for UTC, take tai_seconds_from_gps_time to tai_seconds_to_utc. A datetime on a GNSS epoch has no civil form in the library, which is why epoch_seconds_to_utc and to_civil() refuse it.

The calendar. GregorianDate::from_mjd / to_mjd / normalize and == are bvn_gregorian_date_from_mjd / _to_mjd / _normalize / _is_equal; mjd_to_wday, mjd_to_yday and mjd_in_leap_year take a bare MJD; easter_sunday_mjd(year) and easter_sunday, good_friday, easter_monday, ascension_day, whit_monday give Easter and the feasts that hang off it. The German calendar is methods of GregorianDate: is_federal_holiday_in_germany, is_banking_day_in_germany, is_business_day_in_germany; last_banking_day_in_month, penultimate_…, mid_…, first_…, last_sunday_in_month, last_saturday_in_month; last_banking_day_in_quarter(q), first_banking_day_in_quarter(q); the is_… form of each banking-day getter; and is_daylight_saving_switch_in_germany, is_day_before_daylight_saving_switch_in_germany. The federal holidays are the nine that hold in all of Germany, by the rules in force that year (3 October from 1990, 17 June from 1954 to 1989, 1 May from 1933). A banking day is Monday to Friday, not a holiday and not 24 or 31 December; a business day is Monday to Saturday and not a holiday; the mid banking day is the first one on or after the 15th.

Errors. Where a C function signals failure with a sentinel, a false or an untouched output — an overflow, an MJD or year outside the supported range, a quarter outside 1–4 — the crate returns Error::Argument. Not bound: bvn_gregorian_date_to_mjd_fast (undefined behaviour out of range; to_mjd() is the checked form) and bvn_dt_dump / bvn_dt_print, which print to stdout (Display and Debug take their place).


7. write_array helper

write_array is a free function at the crate root. It handles both flat and multi-dimensional arrays, and takes the Writer to write into:

use bovnar::{Value, ValueTypeFamily, ValueTypeSpec, Writer, write_array};

fn main() -> Result<(), bovnar::Error> {
    let ints = |xs: &[i128]| xs.iter().map(|&x| Value::Int(x)).collect::<Vec<_>>();
    let mut w = Writer::to_mem(4096, true)?;

    // Single-row array
    write_array(&mut w, "primes", &ints(&[2, 3, 5, 7]), None, None, None)?;

    // Multi-row array (rows separated by /)
    let matrix = [Value::Array(ints(&[1, 2, 3])), Value::Array(ints(&[4, 5, 6]))];
    write_array(&mut w, "matrix", &matrix, None, None, None)?;

    // Typed array: whole-array type annotation
    let vt = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 16, base: 0 };
    write_array(&mut w, "ports", &ints(&[80, 443, 8080]), Some(vt), None, None)?;

    w.finish()?;
    let Value::Struct(fields) = bovnar::parse(w.get_output())? else { unreachable!() };
    assert_eq!(fields.len(), 3);
    Ok(())
}

The signature is write_array(w, key, rows, vt, unit, unit_source): vt is the whole-array annotation (Option<ValueTypeSpec>), unit an Option<&Unit>, and unit_source the unit's spelling if it is to be kept.

Element types accepted per element: Int, Float, String, Bool, Null, Bytes (an octet stream), Struct (written as a struct), Quantity, or a nested Array (written as a nested array). When rows is a flat slice it is treated as a single-row array; when all top-level elements are themselves Arrays it is treated as a multi-row array.

With vt, every element is held to it. An Int under an integer annotation is checked against the width and written in the annotation's base — quoted where the base requires it. An element the annotation cannot describe — 80800 under <uint:16>, a Float or a String under an integer type, a Struct under any — fails with Error::Argument instead of producing a document nothing reads. Null is a null hole and is always allowed; a Quantity element carries its own literal and is written as it is.


8. JSON conversion

The bovnar::json module mirrors the CLI's bovnar convert in both directions:

Function Description
json::from_json(&str) -> Result<Vec<u8>> JSON text to a BVNR document. The root must be an object. An integer wider than 64 bits is written as <uint:N>/<sint:N> (N the smallest power of two from 128 that holds it), byte for byte as bovnar convert writes it, and refused inside an array, whose elements share one type
json::to_json(&[u8]) -> Result<String> A BVNR document to pretty-printed JSON, as bovnar convert prints it
json::detect(&[u8]) -> Format Sniffs the format from the first significant byte: Format::Json, Format::Bovnar or Format::Unknown. The CLI goes by file extension; this is the content-based fallback for data without a name
use bovnar::json::{self, Format};

fn main() -> Result<(), bovnar::Error> {
    let text = r#"{"name": "example", "ports": [8080, 443]}"#;
    assert_eq!(json::detect(text.as_bytes()), Format::Json);

    let bvnr = json::from_json(text)?;
    assert_eq!(json::detect(&bvnr), Format::Bovnar);
    let back = json::to_json(&bvnr)?;
    assert_eq!(json::from_json(&back)?, bvnr);
    Ok(())
}

JSON arrays become a single bracket row with nested arrays as nested rows (never /-separated), as in the CLI. Nesting is limited to 255 levels — the writer's and the reader's limit — and a document deeper than any reader takes is refused as JSON rather than by the writer half-way through. NaN and the infinities have no JSON spelling and are written as null.

Two documented round-trip edge cases. JSON has one number type, so two values change on the way back, exactly as with the CLI:

use bovnar::json::{from_json, to_json};

fn main() -> Result<(), bovnar::Error> {
    let first = to_json(&bovnar::to_string(
        &bovnar::Value::Struct(vec![("big".into(), bovnar::Value::Float(1e6))]),
        true,
    )?)?;
    assert!(first.contains("1000000"));            // not 1e+6
    let back = from_json(&first)?;                 // ... and it reads back as an integer
    assert_eq!(to_json(&back)?, first);            // from here on the JSON is stable
    Ok(())
}

From the second trip on, the JSON is stable: the first to_json/from_json pair normalises, and nothing after it changes.


9. Currency helpers

The bovnar::currency module provides metadata for the ISO 4217 fiat and cryptocurrency BaseUnit members. The catalogue is read from the C library, so there is no table to keep in step.

use bovnar::BaseUnit;
use bovnar::currency::{
    all_crypto, all_fiat, currency_code, currency_info, from_code, is_crypto, is_currency, is_fiat,
    minor_unit,
};

fn main() -> Result<(), bovnar::Error> {
    assert!(is_currency(BaseUnit::Usd));
    assert!(is_fiat(BaseUnit::Usd));
    assert!(is_crypto(BaseUnit::Btc));

    let info = currency_info(BaseUnit::Usd)?;
    assert_eq!(info.code, "USD");
    assert_eq!(info.numeric_code, 840);   // ISO 4217 numeric; 0 for crypto
    assert_eq!(info.minor_unit, 2);       // decimal places: 1 major = 10^minor minor units
    assert_eq!(info.name, "US Dollar");

    assert_eq!(minor_unit(BaseUnit::Jpy)?, 0);
    assert_eq!(currency_code(BaseUnit::Eur)?, "EUR");
    assert_eq!(from_code("gbp")?, BaseUnit::Gbp);   // surrounding space and case are ignored

    assert!(all_fiat().any(|ci| ci.code == "CHF")); // all_crypto() / all_currencies() also exist
    assert!(all_crypto().all(|ci| ci.numeric_code == 0));
    Ok(())
}

CurrencyInfo is a plain struct (base, code, numeric_code, minor_unit, name) with an is_fiat() method; its Display is the code. currency_info, minor_unit, currency_name, currency_code and from_code fail with Error::Argument for non-currency bases or unknown codes. CURRENCY_FIRST, CURRENCY_LAST and CURRENCY_COUNT are re-exported from the module.


10. Reader reference

10.1 Construction

use bovnar::Reader;

fn main() -> Result<(), bovnar::Error> {
    let r = Reader::new()?;
    drop(r); // the C object is released when the Reader is dropped
    Ok(())
}

Reader::new calls bvnr_reader_create immediately. There is no context manager and no close(): the C object is released when the Reader is dropped, and a dropped reader cannot be used again because the compiler has moved it.

10.2 read_mem

read_mem(data: &[u8], options: &ReadOptions,
         on_verified: Option<&mut dyn FnMut(&EventData) -> bool>,
         on_unverified: Option<&mut dyn FnMut(&EventData) -> bool>) -> Result<()>

read_mem_want_unit(data, options, want_unit: &mut dyn FnMut(&EventData) -> Option<(Unit, u32)>,
                   on_verified) -> Result<()>

read_mem_with(data, options, handlers: ReadHandlers) -> Result<()>

read_mem_mirror(data, options, handlers: ReadHandlers,
                mirror: &mut dyn std::io::Write) -> Result<()>

Parse BVNR from a byte slice. The options are the ReadOptions struct, and the want_unit hook is the separate method read_mem_want_unit. read_mem_with (and read_reader_with over a std::io::Read) takes every handler at once, as a ReadHandlers whose fields are all optional: on_verified, on_unverified, want_unit — so a hook can sit beside an on_unverified callback — and on_error.

Parameter Type Description
data &[u8] Input buffer
options &ReadOptions See §10.3
on_verified Option<&mut dyn FnMut(&EventData) -> bool> Callback for validated events
on_unverified Option<&mut dyn FnMut(&EventData) -> bool> Callback for raw pre-validation events
want_unit &mut dyn FnMut(&EventData) -> Option<(Unit, u32)> Read-time lossless unit/base conversion hook. Called per numeric value; return Some((unit, base)) to request a conversion (the exact result arrives on the payload as converted and converted_text, with converted_rational() / converted_in_base() beside it — see §4.2), or None to leave the value untouched. An inexact conversion aborts the read (error_unit_inexact)
on_error &mut dyn FnMut(&Error) (in ReadHandlers) Called for every error the reader reports, as an Error::Parse with its position: under continue_on_error, each error it recovers from — reported nowhere else — and the error that ends a read, before the read returns it. A panic in it is parked: the read stops at the next call of an on_verified, on_unverified or want_unit handler, or, with none installed, runs to the end; then the panic resumes in the caller
use bovnar::{Error, ReadHandlers, ReadOptions, Reader};

fn main() -> Result<(), Error> {
    let mut recovered = Vec::new();
    let mut on_error = |e: &Error| recovered.push(e.position().unwrap().offset);
    let options = ReadOptions { continue_on_error: true, ..ReadOptions::default() };
    let _ = Reader::new()?.read_mem_with(
        b".a = 1; .b = @; .c = 2;",
        &options,
        ReadHandlers { on_error: Some(&mut on_error), ..ReadHandlers::default() },
    );
    assert_eq!(recovered.first(), Some(&13)); // the '@'
    Ok(())
}

read_mem_mirror (and read_reader_mirror over a std::io::Read) is read_mem_with with mirror receiving every byte the reader consumes — the input mirror of bvnr_open_read_source, a debugging aid — comments and octet-stream payloads included, so after a read that succeeded it holds the input. The bytes reach it a piece at a time, each once the reader has parsed it: a buffer pulled (up to 64 KiB), a piece fed, an octet-stream chunk. A read that fails, or that a handler stops, has mirrored the input up to the byte it stopped at. A failing mirror ends the read with an Error::Io with writing_to_sink; a panic in it reaches the caller. A live::DocumentFeed takes one with set_mirror (§4.9).

use bovnar::{ReadHandlers, ReadOptions, Reader};

fn main() -> Result<(), bovnar::Error> {
    let mut seen = Vec::new();
    Reader::new()?.read_mem_mirror(b".a = 1;  # note\n", &ReadOptions::default(),
                                   ReadHandlers::default(), &mut seen)?;
    assert_eq!(seen, b".a = 1;  # note\n");
    Ok(())
}

A reader opens at the 255-level struct and array nesting cap, in both directions, like the CLI and the DOM, unless ReadOptions lowers it.

The file-descriptor entry points read_fd and read_file are read_reader, read_reader_want_unit, read_reader_with and read_reader_mirror, over any std::io::Read — see §4.7.

10.3 ReadOptions

ReadOptions is plain data with a Default; fill what you need and leave the rest with ..ReadOptions::default().

Field Type Default Description
max_file_size u64 0 (unlimited) Hard limit on bytes consumed; past it the read fails with file_too_long
max_text_bytes u64 0 (default 2 147 483 647) Most bytes of text in a document (octet-stream payloads not counted); past it the read fails with text_data_too_long. u64::MAX lifts it, for an endless text document
max_array_items u64 0 (default 2 147 483 647) Most items in one array; past it the read fails with too_many_array_items. u64::MAX lifts it
continue_on_error bool false Enable resync mode
strict_version bool false Reject a declared spec version newer than this build (error_unsupported_spec_version)
text_only bool false Refuse a document that contains an octet stream (error_octet_stream_forbidden), for a channel that is not byte-transparent
want_unit_allow_nonterminating bool false Deliver an exact-rational-but-non-terminating conversion (e.g. km/h→m/s) as a rational — converted_text is empty, converted_rational() returns the exact pair — instead of aborting with error_unit_inexact
max_conversion_length u32 0 (default 1024) Longest converted text a conversion may produce, in characters; a longer exact result aborts with error_value_out_of_range
max_identifier_length u16 0 (default 255) Longest key, in bytes (identifier_too_long)
max_string_length u16 0 (default 65 535) Longest string value, in bytes (string_too_long)
max_number_length u16 0 (default 65 535) Longest number literal (number_too_long)
max_symbol_length u16 0 (default 255) Longest symbol (symbol_too_long)
max_reference_length u16 0 (default 65 535) Longest reference (reference_too_long)
max_struct_nesting u8 0 (255) Deepest struct nesting (struct_nesting_too_high). 0 is the format's limit of 255, what the DOM and the CLI read at — not the C reader's own default of 64
max_array_nesting u8 0 (255) Deepest array nesting, as max_struct_nesting (array_nesting_too_high)

Every field of bvnr_read_flags_t that is a limit or a switch is here, and every reading entry point takes the whole struct: Reader, the live feeds, parse_with_options / parse_reader_with_options (§4.1), and the *_with_options twins of the stream readers (§4.6). The five length caps and the two nesting caps are for untrusted input: lower them to what the consumer can take.

max_file_size is 0 by default, which means no cap; set it where a cap matters. max_text_bytes and max_array_items are 0 by default too, but 0 selects the library's finite default there, as in C (bvnr_read_flags_t), so an untrusted document stays bounded unless a caller lifts the cap.

10.4 EventData and EventPayload

EventData<'a> — the table in §4.2 — is the borrowed view a callback receives. EventPayload is the owned copy: the same fields without the borrow, with raw a Vec<u8>, converted_text an Option<String> (None when the conversion does not terminate in converted_base), and a text() method.

Field Type Description
event Event The event code
token_type Option<TokenType> How the token was written
value_type ValueTypeSpec Type annotation if present
value_unit Unit Unit if present
raw Vec<u8> Raw token bytes
text() String raw decoded as UTF-8, invalid bytes replaced
converted bool true when a want_unit conversion was applied to this value
converted_text Option<String> Exact converted value as a positional string; None if the conversion does not terminate in converted_base
converted_base u32 Base the converted text is rendered in

10.5 iter_mem

iter_mem(data, verified_only, max_file_size) -> Result<Vec<EventPayload>> reads data and returns all its events, in order. It is a Vec, not a lazy iterator: the read has finished when it returns.

Parameter Description
verified_only When false, both callbacks fire and events may appear twice
max_file_size Forwarded to read_mem; 0 is unlimited

For any other option, call read_mem with a collecting handler.

10.6 Error-state accessors

These query the C reader object after a failed parse. They describe the error that ended the read; the Error::Parse that read_mem returned carries the same code and position. An error recovered under continue_on_error: true does not appear here: after a read that succeeded, error_code() is Some(ErrorCode::None) and the line is 0, however many errors were recovered, and recovery_count() is how to tell that any were (spec §13.4).

Method Type Description
error_code() Option<ErrorCode> The error that ended the read; ErrorCode::None after a successful one
error_line() u64 1-based line number of the error
error_position() Position Line, character column, byte offset and offending byte of the error, all 0 after a clean read
recovery_count() u64 Number of times resync was entered (incremented at error entry, not at resync completion)
skipped_bytes() u64 How many input bytes a continue_on_error read skipped
declared_version() Option<(u16, u16)> The #!bovnar M.N directive of the last read, or None when the document carried none

The same position is on the Error itself — see §13 — as position(). To see the errors a continue_on_error read recovered from, pass an on_error handler (§10.2).


11. Writer reference

11.1 Construction

Method Description
Writer::to_mem(cap, pretty) Write to an in-process buffer of cap bytes; pretty indents the output. Writing past cap fails with sink_buffer_exhausted (DEFAULT_MEM_CAP is 4 MiB)
Writer::to_mem_with(cap, pretty, unit_flags, emit_version) to_mem with two more settings: unit_flags (UnitFlags) rewrite units on output, emit_version leads the document with #!bovnar <major>.<minor>
Writer::to_writer(out, pretty) Write to any std::io::Write: the document streams into out in 64 KiB blocks as it is written, the rest on flush() or finish(), which flush out; no cap on its size
Writer::to_writer_with(out, cap, pretty, unit_flags, emit_version) to_writer for a document of at most cap bytes (sink_buffer_exhausted past it, from the call that pushes the output over it), plus unit_flags and emit_version as in to_mem_with
Writer::to_mem_with_options(cap, options: WriteOptions) to_mem with every write option, below
Writer::to_writer_with_options(out, cap, options: WriteOptions) to_writer_with with every write option; cap usize::MAX is no cap

See §4.7 for the I/O writer. By default every writer accepts structs and arrays nested up to 255 deep, the same limit Reader and parse() read at. unit_flags takes UnitFlags — REDUCE rewrites each unit and rescales its value, ASCII_EXP writes ^N exponents, NUM_CANONICAL keeps a rescaled float in float shape. emit_version: true writes a leading #!bovnar M.N naming the spec version this build implements, as write_version() would.

WriteOptions is the whole of bvnr_write_flags_t that the C writer consults, with a Default: pretty, unit_flags, emit_version, the nesting caps max_struct_nesting and max_array_nesting (0 is 255, as for ReadOptions; a struct or an array one level deeper fails the write with struct_nesting_too_high or array_nesting_too_high), and on_event, the observer of §11.9. It holds a boxed closure, so it is passed by value and is not Clone.

use bovnar::{ErrorCode, WriteOptions, Writer};

fn main() -> Result<(), bovnar::Error> {
    let options = WriteOptions { max_struct_nesting: 2, ..WriteOptions::default() };
    let mut w = Writer::to_mem_with_options(4096, options)?;
    w.begin_struct("a")?;
    w.begin_struct("b")?;
    let err = w.begin_struct("c").unwrap_err();
    assert_eq!(err.code(), Some(ErrorCode::StructNestingTooHigh));
    Ok(())
}

The high-level to_string needs no buffer size: it retries on a writer twice the size until the output fits (up to 256 MiB).

11.2 Output retrieval

Method Description
w.get_output() -> &[u8] The document written so far; empty for a to_writer writer, whose output has gone to its std::io::Write
Writer::bytes_written -> u64 Number of bytes written
w.flush() Hand over what is written so far without ending the document (bvnr_write_flush): a to_writer writer pushes what it holds to its std::io::Write and flushes it once; a to_mem writer does nothing. See §11.5
w.finish() Flush and seal the output. Fails with Error::Argument if any struct is still open. For a to_writer writer it also pushes what the writer still holds to its std::io::Write and flushes it

There is no destroy(): dropping the Writer releases the C object.

11.3 Scalar write methods

Every scalar writer takes the key first and returns Result<()>. They take no unit parameters: a value that carries a unit is written with write_quantity (or inside write_array), which take a Unit.

write_uint(key, value: u64, width: u32, base: Base)

Write an unsigned integer. base selects the numeral system (Base::DECIMAL or Base::HEX for inline values; any Bovnar-supported base for write_bvni).

write_sint(key, value: i64, width: u32, base: Base)

Write a signed integer. base 64 and 85 are unsigned — + and - are digits in those alphabets — and fail with Error::Argument here and for a signed write_bvni.

write_float(key, value: f64, width: u32)

Write an IEEE 754 binary float.

write_float_fix(key, value: f64, width: u32, q: u32)

Write a Q-format fixed-point value. q is the number of fractional bits (0 ≤ q < width).

write_float_dec(key, value: f64, width: u32)

Write an IEEE 754-2008 decimal float.

write_string(key, value: &str)

Write a bare quoted UTF-8 string with no type annotation:

.host = "localhost";

To emit an explicit <utf8> annotation, use the low-level emit API with Event::TypeAnnotationStart / TypeAnnotationEnd before Event::Data.

write_bool(key, value: bool)

Write a bool value — serialized as the bare keyword true or false. On read-back it decodes to Value::Bool.

write_null(key)

Write a null value (empty slot).

write_octets(key, value: &[u8])

Write an octet stream, chunked. It reads back as Value::Bytes.

write_quantity(key, q: &Quantity)

Write a typed scalar as parse_typed read it: type, unit, unit spelling and datetime fraction are kept. The annotation is left out where it says nothing the literal does not.

use bovnar::{Quantity, Unit, ValueTypeFamily, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let float64 = ValueTypeSpec { family: ValueTypeFamily::Float as i32, width: 64, base: 0 };
    let mut w = Writer::to_mem(4096, false)?;
    w.write_quantity("velocity", &Quantity::new("9.81", float64, Unit::parse("m/s")?))?;
    w.finish()?;
    assert_eq!(w.get_output(), b".velocity=<float:64,m/s>9.81;");
    Ok(())
}

11.4 Extended integer writers

write_bvni(key, value: i128, width: u32, base: Base, signed: Signedness)

Arbitrary-width integer writer that supports all Bovnar numeral bases (2–62, 64, and 85), for an integer of any width up to 128 bits. Non-decimal values are formatted with bovnar's own big-integer arithmetic and emitted as quoted strings. signed is a Signedness: Signed, Unsigned, or Auto — sint for a negative value, uint otherwise (the default). format_bigint(value, base) renders an integer in any base on its own.

write_big_int(key, value: &BigInt, width, base, signed)

write_bvni for an integer of any size: value is a num_bigint::BigInt, as Value::BigInt carries one past i128, and width, base (Base) and signed (Signedness, Auto writing a negative value as sint) are those of write_bvni. writer::format_big_int(value, base) renders it on its own. A signed value in base 64 or 85 fails with Error::Argument, as for write_bvni.

write_bvnf_base(key, literal: &str, width: u32, base: Base)

Write a float from a pre-formatted string in base 10 or 16. base must be Base::DECIMAL or Base::HEX; any other value fails with Error::Argument.

Base

Base is a checked newtype over the numeral bases bovnar has an alphabet for — 2 to 62, 64 and 85 — rather than an enum of 63 variants. The common ones are named: BINARY, OCTAL, DECIMAL, HEX, BASE36, BASE62, BASE64, BASE85. Base::new(n) is Some for a base bovnar has an alphabet for and None otherwise; get() returns the number.

use bovnar::{Base, Signedness, Writer};

fn main() -> Result<(), bovnar::Error> {
    assert_eq!(Base::new(16), Some(Base::HEX));
    assert_eq!(Base::new(63), None);

    let mut w = Writer::to_mem(4096, false)?;
    w.write_bvni("big", 1i128 << 100, 128, Base::DECIMAL, Signedness::Auto)?;
    w.write_bvni("neg", -255, 16, Base::HEX, Signedness::Auto)?;
    w.finish()?;
    assert_eq!(bovnar::parse(w.get_output())?, bovnar::parse(
        b".big = <uint:128> 1267650600228229401496703205376; .neg = <sint:16,_16> \"-ff\";",
    )?);
    Ok(())
}

11.5 Struct helpers

use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.begin_struct("server")?;        // emit AssignmentStart + StructStart, increment depth
    w.write_string("host", "example.com")?;
    w.end_struct()?;                  // emit StructEnd, decrement depth
    w.finish()?;
    Ok(())
}

finish() verifies that the struct depth is zero; an unclosed struct fails with Error::Argument before the C library is asked to finish.

Two more kinds of value go into an open writer, whatever it writes to:

Method Writes
w.embed_document(key, doc) .key = <octets>; holding the document doc (bvnr_embed_document), for stream::parse_embedded or live::EmbeddedFeed to read back
w.mux_begin(key), w.mux_send(channel, payload), w.mux_end() a multiplexed octet stream under key (bvnr_mux_begin / _send / _end), the bytes of stream::mux_dump; values written before it are read back before its first message, and the document may go on after it

On a to_writer writer a message reaches out when the writer's 64 KiB block fills, when the document is finished, or at w.flush() (bvnr_write_flush), which writes out what the block holds and then calls out.flush(), once per call. A consumer that needs each message as it is sent — a live::Demux at the other end of a socket — gets it by a flush() after each mux_send. It is legal between any two writer calls, inside an open struct, array or mux stream too, and it changes only when the bytes leave, never which: a document flushed after every call is byte for byte the one written without a flush. It allocates nothing. A failing out is an Error::Io with writing_to_sink, from the flush() and from every call after it; on a to_mem writer flush() does nothing.

use std::io::Read;
use bovnar::live::{Demux, DemuxItem, DemuxOptions};
use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    let (mut rx, tx) = std::io::pipe().expect("pipe");
    let mut w = Writer::to_writer(tx, false)?;
    w.begin_struct("meta")?;
    w.write_string("codec", "av1")?;
    w.end_struct()?;
    w.mux_begin("media")?;
    w.mux_send(1, b"key frame")?;
    w.flush()?; // the metadata and the first message leave now

    // The other end has the message while the document is still open
    let options = DemuxOptions { key: Some("media".into()), ..DemuxOptions::default() };
    let mut demux = Demux::new(&options)?;
    let mut buf = [0u8; 4096];
    let mut first = None;
    while first.is_none() {
        let n = rx.read(&mut buf).expect("read");
        let mut input = &buf[..n];
        while let Some(item) = demux.next(&mut input)? {
            if let DemuxItem::Message { channel, payload } = item {
                first = Some((channel, payload.to_vec()));
            }
        }
    }
    assert_eq!(first, Some((1, b"key frame".to_vec())));
    w.mux_end()?;
    w.finish()
}

Two writers produce a multiplexed stream message by message, byte for byte the same for the same messages:

Writer with mux_send and flush codec::MuxWriter (§4.8)
Implementation the C writer: a C call per send and per flush, each message copied into the writer's 64 KiB buffer pure Rust, no C call; the chunk headers can be handed out next to the borrowed payload for a vectored write
Document shape any: the stream is one value, with values of every kind before and after it, each one checked as the writer checks every value a text preamble, the stream, and nothing after it
Destination a to_writer writer's std::io::Write, flushed by flush() a Vec or any std::io::Write, which the caller flushes
Use it for a stream inside a larger document, or when the writer already writes the rest a dedicated stream, a router or a relay

11.6 Version directive

use bovnar::Writer;

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem(4096, true)?;
    w.write_version(1, 1)?;           // emit a leading "#!bovnar 1.1" directive
    w.write_uint("n", 1, 8, bovnar::Base::DECIMAL)?;
    w.finish()?;
    assert!(w.get_output().starts_with(b"#!bovnar 1.1"));
    Ok(())
}

Emits a leading #!bovnar <major>.<minor> directive. Must be called before any value is written; calling it after output has begun fails with Error::Write (InvalidArgument). Use it to self-declare the spec version a document targets (spec-1.1 constructs such as datetime literals need it to re-read).

11.7 Array helpers

use bovnar::{EmitSpec, Event, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let plain = ValueTypeSpec::default();
    let mut w = Writer::to_mem(4096, false)?;
    w.emit(Event::AssignmentStart, EmitSpec::key("m"))?;
    for (i, row) in [["1", "2"], ["3", "4"]].iter().enumerate() {
        if i > 0 {
            w.new_array_dim()?;     // emit ArrayDimStart (the / separator between rows)
        }
        w.begin_array_row()?;       // emit ArrayRowStart
        for n in row {
            w.emit(Event::Data, EmitSpec::value(n, plain))?;
        }
        w.end_array_row()?;         // emit ArrayRowEnd
    }
    w.finish()?;
    assert_eq!(bovnar::parse(w.get_output())?, bovnar::parse(b".m = [1, 2] / [3, 4];")?);
    Ok(())
}

These are the raw events; write_array is the helper that drives them for you.

11.8 Low-level emit

w.emit(event: Event, spec: EmitSpec<'_>) -> Result<()>

Send an arbitrary event to the C writer. What goes with it — a key, a value and a type annotation — is an EmitSpec; it carries no unit. When both a vt and a value are supplied the token type is inferred: a string token for ValueTypeFamily::Utf8, a number otherwise.

Purpose Call
Start an assignment with a key w.emit(Event::AssignmentStart, EmitSpec::key("x"))
Write a typed value w.emit(Event::Data, EmitSpec::value("42", spec))
End a struct (nothing attached) w.emit(Event::StructEnd, EmitSpec::NONE)
use bovnar::{EmitSpec, Event, ValueTypeFamily, ValueTypeSpec, Writer};

fn main() -> Result<(), bovnar::Error> {
    let spec = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 16, base: 0 };
    let mut w = Writer::to_mem(4096, false)?;
    w.emit(Event::AssignmentStart, EmitSpec::key("x"))?;
    w.emit(Event::Data, EmitSpec::value("42", spec))?;
    w.finish()?;
    assert_eq!(bovnar::parse(w.get_output())?, bovnar::parse(b".x = <uint:16> 42;")?);
    Ok(())
}

Two calls go a level lower, for what an EmitSpec cannot say:

w.event(event: Event, token: TokenType, vt: ValueTypeSpec, data: &[u8]) -> Result<()>
w.annotation(vt: ValueTypeSpec, unit: &Unit, unit_source: Option<&str>) -> Result<()>

event sends one event with the token type given explicitly (bvnr_write_event): a plain symbol is Event::Data with TokenType::Symbol, an array item TokenType::ArrayNumber or ArrayString. data longer than 4 GiB fails with Error::Argument. annotation writes a whole type annotation, <family:width[,_base|,qN|,epoch][,unit]>, as the events from TypeAnnotationStart to TypeAnnotationEnd: the width of a sized family when it is not 0, the base of an integer or float that is not 10, the q of a fixed-point value, the epoch of a datetime and the unit when it has components (left out for datetime and utf8). unit_source is the unit's spelling to write instead of its canonical one, checked by the C writer as for bvnr_writer_set_unit_source; a unit the library cannot serialise fails with Error::Argument. It goes after the key and before the value, or before an array's first row (§11.10 uses both).

11.9 Observing the output: on_event

WriteOptions::on_event is a Box<dyn FnMut(&EventData<'_>) -> bool + Send> (WriteObserver), called with each event once it has been serialised (bvnr_write_flags_t.on_event), with what reached the output: under UnitFlags::REDUCE a rescaled value's new digits and unit, not the ones it was written with. Every event the writer serialises reaches it — those of the write_* methods, emit, the struct and array helpers, the mux and embedding calls. It is owned by the writer, so it shares state with the code around it through an Arc (or a channel); it cannot reach the writer itself.

use bovnar::units::UnitFlags;
use bovnar::{Event, Quantity, Unit, ValueTypeFamily, ValueTypeSpec, WriteOptions, Writer};
use std::sync::{Arc, Mutex};

fn main() -> Result<(), bovnar::Error> {
    let seen = Arc::new(Mutex::new(Vec::new()));
    let log = Arc::clone(&seen);
    let options = WriteOptions {
        unit_flags: UnitFlags::REDUCE,
        on_event: Some(Box::new(move |d| {
            if d.event == Event::Data {
                log.lock().unwrap().push((d.text(), d.value_unit.to_string()));
            }
            true
        })),
        ..WriteOptions::default()
    };
    let mut w = Writer::to_mem_with_options(4096, options)?;
    let uint = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 64, base: 0 };
    w.write_quantity("d", &Quantity::new("5", uint, Unit::parse("k~m")?))?;
    w.finish()?;
    assert_eq!(w.get_output(), b".d=<uint:64,m>5000;");
    assert_eq!(*seen.lock().unwrap(), [("5000".to_string(), "m".to_string())]);
    Ok(())
}

Returning false fails the write, and the writer, with scanner_callback_failed: a veto, say of a key that must not leave the process. A panic in the observer fails it the same way and reaches the caller of the write call that ran it; a later call returns the Error::Write.

11.10 The C per-type helpers

The bindings write every value through bvnr_write_event; each of the C per-type helpers has its counterpart, so none is bound itself:

C helper Rust
bvnr_write_string, _null, _bool write_string, write_null, write_bool
bvnr_write_uint, _sint, _float, _float_fix, _float_dec the methods of §11.3
their _unit forms, bvnr_writer_set_unit_source write_quantity, whose Quantity carries the unit and its spelling (unit_source)
bvnr_write_datetime write_quantity with a datetime type (the epoch in its base, datetime_epoch_index)
bvnr_write_plain event(Event::Data, TokenType::Symbol, …) after the key, or write_quantity of a symbol
bvnr_write_type_annotation annotation(vt, &unit, unit_source), before a value or an array's first row
bvnr_write_bvnf*, bvnr_write_bvni* write_bvnf_base, write_bvni, write_big_int (§11.4)
bvnr_write_struct_start, _end begin_struct, end_struct (§11.5)
use bovnar::{
    EmitSpec, Event, Quantity, TokenType, Unit, ValueTypeFamily, ValueTypeSpec, Writer,
    datetime_epoch_index,
};

fn main() -> Result<(), bovnar::Error> {
    let mut w = Writer::to_mem_with(4096, false, 0, true)?; // datetime is spec 1.1
    w.emit(Event::AssignmentStart, EmitSpec::key("mode"))?;
    w.event(Event::Data, TokenType::Symbol, ValueTypeSpec::default(), b"auto")?;
    let tai = datetime_epoch_index("tai").unwrap();
    let datetime = ValueTypeSpec { family: ValueTypeFamily::Datetime as i32, width: 64, base: tai };
    w.write_quantity("t", &Quantity::new("1750000000", datetime, Unit::default()))?;
    let u8_ = ValueTypeSpec { family: ValueTypeFamily::Uint as i32, width: 8, base: 0 };
    w.emit(Event::AssignmentStart, EmitSpec::key("v"))?;
    w.annotation(u8_, &Unit::parse("m")?, None)?;
    w.begin_array_row()?;
    w.event(Event::Data, TokenType::ArrayNumber, u8_, b"1")?;
    w.event(Event::Data, TokenType::ArrayNumber, u8_, b"2")?;
    w.end_array_row()?;
    w.finish()?;
    let out = w.get_output();
    let body = &out[out.iter().position(|&b| b == b'\n').unwrap() + 1..];
    assert_eq!(body, b".mode=auto;.t=<datetime:64,tai>1750000000;.v=<uint:8,m>[1,2];");
    Ok(())
}

12. DOM API

The DOM API parses a complete BVNR document into an in-memory tree for random-access queries without writing a SAX callback.

12.1 DomDoc::parse

use bovnar::DomDoc;

fn main() -> Result<(), bovnar::Error> {
    let doc = DomDoc::parse(b".a = 1;")?;
    assert_eq!(doc.len(), 1);
    Ok(())
}

A malformed document is an Error::Parse carrying the C error code. It has no position: the C DOM reports only the code (bvn_dom_doc_get_parse_error), so Error::position() is None. To locate the fault, read the same document with a Reader, which does report where.

12.2 DomDoc

Owning wrapper around bvn_dom_doc_t. Dropping it frees the entire tree; the DomNodes derived from it borrow it, so none can outlive it.

Method Description
DomDoc::parse(data) Parse a byte slice
DomDoc::parse_reader(input) parse over any std::io::Read; see the note below
DomDoc::parse_reader_with_policy(input, &policy) parse_reader under a policy
DomDoc::parse_reader_capped(input, max_bytes) / parse_reader_capped_with_policy(input, max_bytes, &policy) parse_reader with a lower cap, as bvn_dom_parse_fd_ex takes one; see the note below
DomDoc::parse_with_policy(data, &policy) Parse under a UnitPolicy: values arrive converted, and a document breaking the policy fails with unit_mismatch
doc.get(key) The top-level value assigned to key, as Option<DomNode>
doc.lookup(path) Dot-separated path lookup, e.g. "server.tls.cert", with indexed elements ("matrix[1][0]"). None when absent
doc.len() / doc.is_empty() Number of top-level entries
doc.entries() All top-level (String, DomNode) pairs, in document order
doc.to_value() The whole document as a Value::Struct, dropping type and unit info. Fails where a node cannot be read as its kind — see node.to_value()
DomDoc::create() An empty document to build in (§12.5)
doc.add(key, node) Add a NewNode as a top-level entry (§12.5)

File-size limits: DomDoc::parse_reader and parse_reader_with_policy take any std::io::Read and apply the same ceiling, DOM_READ_MAX_BYTES (256 MiB, BVN_DOM_FD_MAX_BYTES). A longer input is an Error::Parse with file_too_long, where the C function returns no document. Input over 4 GiB is refused as well (the C API's length is a uint32_t). parse_reader_capped lowers the ceiling to max_bytes; 0, or anything above 256 MiB, keeps it — there is no unlimited mode, as in C.

The DOM takes no read options. The C DOM parse functions read with a fixed set (the 255-level nesting, the reader's defaults for everything else), so ReadOptions cannot reach them; read with a Reader or parse_with_options for those limits.

Building a tree. DomDoc::create() and doc.add(key, node) are the C DOM's builder, for a tree made by hand from NewNodes (§12.5). Such a tree cannot be written out; build a document as a Value or with the Writer.

Membership is get(key).is_some(), and iteration is entries().

12.3 DomNode

A Copy view into a bvn_dom_node_t that borrows its DomDoc.

Method Description
dom_type() Option<DomType> — None for a kind this binding does not know
node.value_type() ValueTypeSpec
node.unit() Unit; dimensionless when the value has none
unit_str() Unit as a canonical string via bvn_dom_get_unit_string, or "" when the value carries no unit — the same contract as unit_str() (the C formatter's no_unit spelling is never returned)
node.is_null() true for null values
node.value_in_base_units() Numeric value scaled to SI base units (f64); an affine unit gets its offset applied (25 °C → 298.15). Returns 0.0 for any failure — non-numeric node, currency, affine-in-compound — which is indistinguishable from a genuine zero, and it rounds to double. Use units::unit_to_si_factor() (which reports the failure) or the want_unit hook when either matters
node.as_i64() / as_u64() Signed / unsigned 64-bit integer, as an Option
node.as_i32() / as_u32() Signed / unsigned 32-bit integer
node.as_i16() / as_u16() Signed / unsigned 16-bit integer
node.as_i8() / as_u8() Signed / unsigned 8-bit integer
node.as_float() Option<f64>
node.as_bool() Option<bool> for Bool nodes
node.as_str() Option<&str> for String, Symbol, or Reference nodes
node.as_bytes() Option<&[u8]> for OctetStream nodes
node.as_int_str(base) Any integer node, however wide, as digits in the given base
datetime_fraction() Option<&str> of the verbatim ISO sub-second digits for a datetime written as a literal with a fraction (spec 1.1)
node.unit_source() Option<&str> of the unit exactly as the document spelled it (library 2.0), where that differs from the canonical form — ucum:mL{total} for a unit of m~L; None for a node with no unit, one already written canonically, or one a unit policy converted
node.get(key) Child DomNode by string key (struct)
node.at(index) Child DomNode by flat index (array)
node.len() / is_empty() Element count for struct or array nodes, 0 otherwise
node.entries() (String, DomNode) pairs (struct nodes)
node.array_dims() Number of /-separated dimensions (array nodes only)
node.to_value() Recursively convert to a plain Value (drops type/unit info). A multi-row array comes back as an array of rows, empty rows included ([]/[] → [[], []]), as parse() gives it

The as_* accessors return Option. A node that is not that kind gives None rather than a zero or a panic, and as_i64 on a float node is None rather than a truncated number.

to_value fails rather than inventing a value. Text or a key that is not UTF-8, a float or bool node without its value, or an unknown node kind is an Error, rather than an invented value: two distinct keys must not collapse into one.

12.4 DomType

Null=0  Int=1  Float=2  String=3  Symbol=4
Reference=5  Struct=6  Array=7  OctetStream=8  Bool=9

DomType::from_raw(i32) converts a raw C value.

12.5 Building a tree (NewNode)

DomDoc::create() makes an empty document and NewNode, NewStruct and NewArray the nodes to put in it: the C DOM's builder (bvn_dom_doc_create, bvn_dom_node_alloc, bvn_dom_node_from_*, bvn_dom_struct_add, bvn_dom_array_append, bvn_dom_doc_add, doc/08 §4.5). It is there for code that consumes a DOM, a test fixture say, and it has the C builder's limits: it makes structs, arrays, nulls and integers only, and a built tree cannot be written out, since the C library has no DOM serialiser. To produce a document, use a Value or the Writer.

use bovnar::{DomDoc, NewArray, NewNode, NewStruct, Signedness, Unit, Value};
use num_bigint::BigInt;

fn main() -> Result<(), bovnar::Error> {
    let mut server = NewStruct::new()?;
    server.add("port", NewNode::from_u16(8080)?)?;
    server.add("tls", NewNode::null()?)?;
    let mut ports = NewArray::new()?;
    ports.push(NewNode::from_u16(80)?)?;
    ports.push(NewNode::from_u16(443)?)?;
    server.add("ports", ports)?;

    let mut doc = DomDoc::create()?;
    doc.add("server", server)?;
    let m = Unit::parse("m")?;
    let big = BigInt::from(1) << 100;
    doc.add("big", NewNode::from_bigint(&big, 128, Signedness::Auto, Some(&m))?)?;

    assert_eq!(doc.lookup("server.port").and_then(|n| n.as_u16()), Some(8080));
    assert_eq!(doc.get("big").map(|n| n.unit_str()), Some("m".to_string()));
    assert_eq!(
        doc.lookup("server.ports").map(|n| n.to_value()).transpose()?,
        Some(Value::Array(vec![Value::Int(80), Value::Int(443)]))
    );
    Ok(())
}
Constructor / method Description
DomDoc::create() An empty document
doc.add(key, node) Add node (anything Into<NewNode>) as a top-level entry. A parsed document takes one too
NewNode::null() A null node (bvn_dom_node_alloc)
NewNode::from_i8(v) … from_u64(v) An integer node of type sint:8 … uint:64, with no unit (bvn_dom_node_from_i8 … _u64)
NewNode::from_bigint(&value, width, signed, unit) An integer node of 65 to 32768 bits, with an Option<&Unit> (bvn_dom_node_from_bigint); Signedness::Auto is sint for a negative value and uint otherwise, as for write_bvni. A value that does not fit the type, another width, or a unit that is not structurally valid is Error::Argument
NewStruct::new() / s.add(key, node) A struct node, and a member added to it (bvn_dom_struct_add)
NewArray::new() / a.push(node) An array node, and an element appended to it (bvn_dom_array_append)
len() / is_empty() / dom_type() Members or elements of a NewStruct / NewArray; a NewNode's kind

Every constructor returns a Result, Error::OutOfMemory when the C library cannot allocate, as Reader::new and the Writer constructors do.

Ownership is a move. The C attach functions take the node they are given on every path: on success the container owns it, and on failure they destroy it. add and push take their node by value, so the type system makes that hand-over the last use: a node cannot be added twice, nor a struct filled after it was attached (a tree is built from its leaves up), and neither compiles. A node dropped before it is attached frees its subtree. All node types are Send.

use bovnar::{DomDoc, NewNode};

fn main() -> Result<(), bovnar::Error> {
    let mut doc = DomDoc::create()?;
    let n = NewNode::from_i32(1)?;
    doc.add("a", n)?;
    doc.add("b", n)?; // error[E0382]: use of moved value: `n`
    Ok(())
}

add fails with Error::Argument for a key that is not an identifier (§5.8; the C functions refuse only an empty one, but the path lookups could not reach another), and with the code DuplicateStructKey for a key the struct or document already has; the node is dropped then. The kinds are types, so there is no appending to a struct or adding to an array. Only an allocation failure inside the C call is Error::OutOfMemory.

Reading it back. A built tree reads through DomDoc and DomNode like a parsed one, with two differences that come from the C builder: an integer node of up to 64 bits carries no unit, and a built array records no row shape, so its array_dims() is 0 and to_value() gives one flat array. doc.add takes &mut self, so no DomNode is borrowed from the document while it grows.

Not bound: bvn_dom_node_alloc of the other kinds (a float, string, symbol, reference, bool or octet-stream node it makes holds no value and cannot be given one), bvn_dom_strdup (a C allocation helper; the attach functions copy the key themselves), and bvn_dom_get_bigint (a borrowed bvn_int_t; as_int_str() and to_value() already read an integer of any width).


13. Error handling

Every fallible call returns bovnar::Result<T>, whose error is bovnar::Error. Error has one variant per failure class:

Variant When raised
Error::Parse { code, position } Parse error in Reader or DomDoc (carries the code and, where the library reported one, the Position)
Error::Write { code, offset } Write error in Writer
Error::Argument { code, message } Invalid argument passed to a helper (e.g. bad unit string, refused unit policy)
Error::OutOfMemory(call) The library could not allocate; names the call that failed
Error::Io { code, io_kind, message } The std::io::Read or Write behind read_reader, parse_reader, write_to, to_writer or a stream twin, a reader's mirror (§10.2) or the input or out of canonicalize_reader (§4.10) failed. code is what the C library reports for an unreadable fd source or unwritable fd sink (reading_from_source_fd, writing_to_sink); io_kind and message keep what the I/O error said

The library is part of the crate, so there is no library-not-found error. Error implements std::error::Error and Display, so it works with ? into Box<dyn Error> and anyhow.

kind() gives the library's error name as an ErrorKind (an alias of ErrorCode) wherever the library reported one — an unparsable unit or a refused policy included — name() its C spelling, code_raw() the raw integer, and position() the line, column and byte offset where the input was refused:

use bovnar::{Error, ErrorKind, Unit};

fn main() {
    let err = bovnar::parse(b".a = 1;\n.b = @;").unwrap_err();
    assert_eq!(err.kind(), Some(ErrorKind::UnexpectedInputByte));
    assert_eq!(err.name(), Some("unexpected_input_byte"));
    let pos = err.position().expect("a parse error from the reader has a position");
    assert_eq!(pos.line, 2);
    assert!(pos.column > 0 && pos.offset > 0);
    assert_eq!(pos.byte, b'@' as u32);              // the byte the error was raised on

    match Unit::parse("furlong per fortnight") {
        Err(Error::Argument { code, message }) => {
            assert!(code.is_some(), "the library said why: {message}");
        }
        other => panic!("expected an argument error, got {other:?}"),
    }
}

position() is None for errors the library locates no better than by code: the DOM parser, and the stream helpers' framing errors. kind() is None for an argument error caught before the library and for an allocation failure. ErrorCode::name() is the library's text for a code on its own (bvn_error_to_string): ErrorCode::StringTooLong.name() is "string_too_long".

unit_convert_factor error semantics. The C function uses the (ok, requires_affine) pair to signal four distinct outcomes:

ok requires_affine Meaning
true false Multiplicative conversion; factor is ready to use
true true Both sides are affine and share their zero (e.g. °C ↔ °Re, Réaumur), so factor alone is correct
false true Affine conversion the offsets do not cancel (e.g. °C ↔ K). factor is 0.0 — not a usable multiplier
false false Units are dimensionally incompatible → Error::Argument

unit_convert_factor fails with Error::Argument only when both ok=false and requires_affine=false; for the other three the result's ok() method gives ok, so check it before multiplying by factor. It is a method rather than a field because a new public field would break code that builds or destructures UnitConversion; it is exact all the same, since the C function never succeeds with a factor of 0.0. When it is false, call convert_value, which handles the two-step affine path, or call unit_to_si_factor on both units and convert by hand. The Unit method convert_factor() returns the bare number and therefore fails for the 0.0 row instead of returning it.

Callbacks returning false. When an on_verified or on_unverified callback returns false, the C parser stops and read_mem returns Error::Parse with whatever error code the reader recorded.

Callbacks that panic. If the callback panics, the panic is caught at the FFI boundary, the C callback returns false to stop the parse, and the original panic is resumed from read_mem after the C call returns — a Rust panic never unwinds through C frames.


14. Threads

Reader, Writer and DomDoc are Send but not Sync. As bovnar.h (THREADING) allows, each can be moved to another thread, and distinct ones can be used on distinct threads, but one object must not be shared: a single reader or writer holds the whole parse or serialise state of one stream.

use bovnar::{DomDoc, Writer};

fn main() -> Result<(), bovnar::Error> {
    let doc = DomDoc::parse(b".n = 7;")?;
    // A DomDoc moves to another thread; a DomNode borrows it, so it stays with its document.
    let n = std::thread::spawn(move || doc.get("n").and_then(|n| n.as_i64())).join().unwrap();
    assert_eq!(n, Some(7));

    // Distinct writers on distinct threads need no locking
    let outputs: Vec<Vec<u8>> = (0..4u64)
        .map(|i| {
            std::thread::spawn(move || {
                let mut w = Writer::to_mem(4096, false).unwrap();
                w.write_uint("i", i, 8, bovnar::Base::DECIMAL).unwrap();
                w.finish().unwrap();
                w.get_output().to_vec()
            })
        })
        .collect::<Vec<_>>()
        .into_iter()
        .map(|h| h.join().unwrap())
        .collect();
    assert_eq!(outputs.len(), 4);
    Ok(())
}

The live readers (§4.9) are Send and not Sync too: each owns one C object for its stream.

parse, parse_typed, to_string, their _reader / write_to twins and the units, currency and json functions are free functions that build their own reader, writer or DOM for each call, so no two calls share an object and they can run on several threads at once.


15. Running the test suite

cd rust

# Run everything
cargo test

# One test file
cargo test --test test_units

# The documentation examples of rust/README.md
cargo test --doc

cargo test runs the unit tests in src/, the integration tests in tests/ (one file per topic — test_units.rs, test_dom.rs, test_stream.rs and so on — plus smoke.rs) and the documentation examples. The ABI check is three unit tests in src/ffi.rs, because the C structs it compares against are private. test_codec_alloc.rs installs a counting global allocator to show that the codec allocates nothing per message, so it is a test binary of its own; test_live_alloc.rs does the same for the live readers and the std::io twins.

test_writers_agree compares to_string with the C CLI's pretty-print, so it is #[ignore]d unless the CLI is built at <repo>/build/bovnar:

cmake -B build . && cmake --build build --target bovnar
cargo test --test test_writers_agree -- --ignored

The C library's own suite runs unchanged through CMake: cmake -B build . && cmake --build build && ctest --test-dir build. That suite also parses every bovnar code block of this document (ctest -R bvnr_doc).

rust/fuzz/ holds cargo-fuzz targets (nightly toolchain): parse (the streaming parsers and a write-back round trip), dom (parse and walk every node), json_roundtrip (from_json and to_json until the JSON is stable), codec (MuxReader and FrameReader fed in pieces, against themselves and against the C library's mux_load and load_documents) and live (the fed readers of bovnar::live in pieces, against Reader::read_mem, mux_load, codec::FrameReader and load_documents). Instrument the C library too by building it with clang:

cd rust/fuzz
CC=clang CFLAGS="-fsanitize=fuzzer-no-link,address" \
    cargo +nightly fuzz run -O parse -- -max_total_time=600

16. Crate layout

rust/
├── Cargo.toml        # version = the C library's version; publish = false (see package.py)
├── build.rs          # compiles the bundled c/, or in the repository ../src after running the generators
├── package.py        # stages the crate with the C sources bundled, for crates.io
├── src/
│   ├── lib.rs        # parse() / parse_typed() / to_string() / write_array() — public API
│   ├── ffi.rs        # the C declarations; the only `unsafe` code
│   ├── bvnfloat.rs   # BvnFloat: arbitrary-precision float + IEEE binary/decimal/fixed encoders
│   ├── codec.rs      # MuxWriter, MuxReader, FrameReader — the pure-Rust framing codec
│   ├── currency.rs   # ISO 4217 / crypto currency metadata (read from the C catalogue)
│   ├── decimal.rs    # Decimal: an exact decimal number (sign, coefficient, exponent)
│   ├── dom.rs        # DomDoc, DomNode, DomType, NewNode — random-access DOM and its builder
│   ├── enums.rs      # Rust enums mirroring the C enums
│   ├── error.rs      # Error, ErrorKind, Position
│   ├── io.rs         # a caller's std::io::Read as a callback source for read_reader / parse_reader
│   ├── json.rs       # from_json / to_json / detect (the CLI's `bovnar convert`)
│   ├── literals.rs   # the C validators, literal parsers/formatters, parse_type_annotation
│   ├── live.rs       # DocumentFeed, ValueFeed, EmbeddedFeed, FrameParser, FramePull, Demux — fed reading through C
│   ├── quantity.rs   # Quantity — typed, unit-annotated scalar for lossless round-trips
│   ├── reader.rs     # Reader, ReadOptions, ReadHandlers, EventData, EventPayload, UnitPolicy, UnitRule, canonicalize
│   ├── stream.rs     # framing, octet multiplexing, document-in-document
│   ├── timekeeping.rs # civil time and calendars: GregorianDate, CivilDateTime, Epoch
│   ├── unit.rs       # Unit, UnitComponent — a unit with equality, hashing and its operations
│   ├── units.rs      # unit_to_si_factor, unit_convert_factor, etc.
│   └── writer.rs     # Writer, WriteOptions, WriteObserver, Base, Signedness, EmitSpec
├── tests/            # one integration-test file per topic, plus smoke.rs
├── benches/          # codec.rs, live.rs: per-message figures of the codec and the fed readers (std only)
└── fuzz/             # cargo-fuzz targets
Module Contents
src/ffi.rs the C declarations and the mirrored C structs
src/reader.rs Reader, ReadOptions, ReadHandlers, EventData, EventPayload, UnitPolicy, UnitRule, canonicalize, canonicalize_reader
src/writer.rs Writer, WriteOptions, WriteObserver, Base, Signedness, EmitSpec
src/dom.rs DomDoc, DomNode, DomType, and the builder's NewNode, NewStruct, NewArray
src/literals.rs the C validators and literal helpers, parse_type_annotation, TypeAnnotation
src/enums.rs the Rust enums behind the C enums, and Exponent
src/error.rs Error, ErrorKind, Position
src/unit.rs Unit, UnitComponent
src/units.rs the unit functions: conversion, dimension vectors, profile notations
src/quantity.rs Quantity
src/bvnfloat.rs BvnFloat and the IEEE / fixed-point encoders
src/decimal.rs Decimal, the exact decimal value
src/currency.rs currency metadata
src/stream.rs framing, multiplexing and document-in-document, with their Read / Write twins
src/timekeeping.rs civil time and calendars: GregorianDate, CivilDateTime, Epoch and the functions of bvn_datetime.h / bvn_gregorian_date.h
src/codec.rs the incremental framing codec in pure Rust: MuxWriter, MuxReader, FrameReader, the frame writers
src/live.rs fed reading through the C library: DocumentFeed, ValueFeed, ValueBuilder, EmbeddedFeed, FrameParser, FramePull, Demux
src/io.rs the std::io::Read helpers behind the _reader entry points (the sinks of the _to ones are in ffi.rs and writer.rs)
src/json.rs JSON ⇄ BVNR, as the CLI's bovnar convert does it
src/lib.rs parse, parse_typed, to_string, the reader and writer twins, write_array and the crate-root re-exports

Out of scope. The crate has no bridges to third-party unit or array libraries. It takes no raw file descriptors or paths either: the entry points that read or write a whole document are std::io::Read / std::io::Write ones (§4.7), and a file is an opened std::fs::File passed to them.


17. Enums and BaseUnit

The C enums are Rust enums with CamelCase variants (the C member METER is BaseUnit::Meter): Event, TokenType, ValueTypeFamily, ErrorCode (also ErrorKind), BaseUnit, PrefixSystem, SIPrefix and IECPrefix. Raw C values convert with TryFrom<i32>, which refuses an unknown value with an UnknownValue error, and back with i32::from (or as i32). Each also has from_raw, which returns an Option.

Exponent and Base are open ranges, so they are checked newtypes with the common values named (Exponent::LINEAR, Exponent::NEG_SQUARE, ...). Exponent runs from EXPONENT_MIN (−100) to EXPONENT_MAX (100).

use bovnar::{BaseUnit, Exponent, SIPrefix, Unit};

fn main() -> Result<(), bovnar::Error> {
    let km = Unit::si(BaseUnit::Meter, SIPrefix::Kilo, Exponent::LINEAR);
    assert_eq!(km.to_string(), "k~m");
    assert_eq!(Unit::parse("k~m")?, km);
    let c = km.components()[0];
    assert_eq!((c.base, c.prefix), (BaseUnit::Meter, SIPrefix::Kilo.into()));
    assert_eq!(BaseUnit::try_from(i32::from(BaseUnit::Meter)), Ok(BaseUnit::Meter));
    assert_eq!(BaseUnit::Meter.name(), "METER");
    Ok(())
}

The BaseUnit enum mirrors the C value_base_unit_e. See doc/05 §11.1 for the block layout — an id's leading two digits name the vocabulary it comes from. The native units are 100000–100263 (UNIT_NATIVE_FIRST … UNIT_NATIVE_LAST) and the currencies 900000–900215 (CURRENCY_FIRST … CURRENCY_LAST), with the CamelCase spelling of each member (POUND_FORCE is PoundForce, GERMAN_MILE is GermanMile):

Range Members
0 None
100000–100001 Bit, Byte
100002–100008 SI base units (Second … Candela)
100009–100027 Named SI derived units (Hertz … Katal)
100028–100043 Non-SI accepted units (Liter … Year)
100044–100053 Imperial/US length (Inch … Fathom)
100054–100061 Imperial/US mass (Pound … Carat)
100062 Fahrenheit
100063–100066 Pressure (Atmosphere … Psi)
100067–100070 Energy (Calorie … Therm)
100071 Horsepower
100072–100074 Force (PoundForce, Dyne, Kip)
100075 Knot
100076–100084 US volume (Gallon … Barrel)
100085–100086 Area (Acre, Barn)
100087–100089 Angle (Arcminute, Arcsecond, Grad)
100090–100097 CGS units (Poise … Galileo)
100098–100100 Radiation (Curie, Roentgen, Rem)
100101–100102 Logarithmic (Neper, Decibel)
100103 Rankine
100104 Slug
100105 Thou
100106–100108 UK imperial volume (PintUk, FluidOunceUk, QuartUk)
100109–100110 Electrical power (Var, VoltAmpere)
100111 KilogramForce
100112 InchHg
100113 Rpm
100114 FootPound
100115–100116 Mass additional (Dram, Pennyweight)
100117–100118 Length additional (Chain, Rod)
100119–100120 Volume additional (Gill, GillUk)
100121 StandardGravity
100122 MetricHorsepower
100123 Revolution
100124–100125 Time additional (Month, Fortnight)
100126 AtmosphereTechnical
100127–100128 Textile linear density (Tex, Denier)
100129–100132 Apothecary/dry volume (FluidDram, Minim, Peck, Bushel)
100133–100145 Historical German units (Pfund, Zentner, Doppelzentner, Lot, Prussian line/zoll/fuss/elle/rute, Klafter, GermanMile, Morgen, Scheffel)
100146–100152 Additional physical units (SurveyFoot, League, Cable, Hand, Quintal, Scruple, Baud)
100153–100156 Temperature scales (Delisle, NewtonTemp, Reaumur, Romer)
100157–100162 Ratio/proportion units (Percent, PerMille, PerMyriad, PerCentMille, Ppm, Ppb)
100163 PhScale
100164–100165 Named speed units (MilePerHour, KilometerPerHour)
100166–100170 Water hardness (GermanHardness, EnglishHardness, FrenchHardness, RussianHardness, AmericanHardness)
100171–100172 Concentration (Val, GrainsPerGallon)
100173–100179 Turbidity, salinity and conductivity (TurbidityNtu, TurbidityFnu, PracticalSalinity, ConductivityFactor, TurbidityFtu, TurbidityFau, TurbidityJtu)
100180–100185 Temperature differences (DeltaKelvin, DeltaFahrenheit, DeltaDelisle, DeltaNewtonTemp, DeltaReaumur, DeltaRomer) — Δ°C shares DeltaKelvin and Δ°Ra shares DeltaFahrenheit
100186–100191 The six units the profiles needed (MeterWater, CalorieIt, BtuTh, TroyPound, ApothecaryDram, LongHundredweight)
100192–100200 US survey lengths (SurveyInch … SurveyAcre)
100201–100203 Typographic lengths (Point, Pica, Line)
100204–100209 US dry volumes and the trade measures (DryGallon, DryQuart, DryPint, BoardFoot, Cord, SurveyAcreFoot)
100210–100214 Darcy, ThermEc, RefrigerationTon, Dobson, Shake
100215–100216 The ratios below ppb (Ppt, Ppq) — spelled pptr and ppq in documents
100217–100225 Nine units two or three vocabularies define (PrintersPoint, PrintersPica, HorsepowerElectric, HorsepowerBoiler, Abvolt, AssayTon, BushelUk, Clo, Debye)
100226–100236 Calendar and sidereal time (YearTropical, YearGregorian, YearSidereal, YearCommon, MonthSynodal, MonthTropical, MonthSidereal, DaySidereal, HourSidereal, MinuteSidereal, SecondSidereal)
100237–100243 CGS electrostatic (Statvolt, Statampere, Statcoulomb, Statfarad, Statohm, Stathenry, Statsiemens)
100244–100246 Luminance (Lambert, Apostilb, Footlambert)
100247–100249 Water-vapour permeance (Perm0c, Perm23c, PermMetric)
100250–100255 Heat conventions (CalorieMean, Calorie15c, Calorie20c, Btu59f, Btu60f, BtuMean)
100256–100261 ElementaryCharge, Spere, CircularMil, UnitPole, HorsepowerWater, MonthGregorian
100262–100263 CGS electrostatic magnetics (Statweber, Stattesla)
900000–900165 ISO 4217 fiat currencies (Aed … Zwl), block 90 — starts at CURRENCY_FIRST
900166–900215 Cryptocurrencies (Btc … Rune), the rest of block 90 — ends at CURRENCY_LAST

There is no sentinel member one past the highest id: the id space is blocked and sparse, so such a number would only invite a bounds check it cannot support. Use UNIT_NATIVE_FIRST / UNIT_NATIVE_LAST / CURRENCY_FIRST / CURRENCY_LAST, or ask currency::is_currency.

The profiles' opaque units are in this enum. The C value_base_unit_t has a block per profile for the profile-only units, and as shipped two of them have members: UCUM's arbitrary atoms (200000–200039, UcumIu for ucum:[IU]) and UN/ECE's package and count codes (300000–300024, UneceOne … UneceVial; see doc/11 §7.1). They have no native spelling, so a unit carrying one renders in its profile notation: unit_to_str returns "unece:XBX". BaseUnit::try_from accepts the raw base of a profile-only unit.

Note on Cup_: the Cuban-Peso currency is exposed as BaseUnit::Cup_ (trailing underscore), not BaseUnit::Cup. The plain name Cup is the US-cup volume unit (enum value 100080); since enum variant names must be unique, the currency at value 900033 takes the suffixed name. This affects the variant name only — the .bvnr wire token is still the bare uppercase CUP, and case stays load-bearing (cup = volume, CUP = currency).


18. Spec 1.1 additions

These bindings target the Bovnar spec 1.2; spec 1.0 remains the frozen baseline, and the crate's own version (2.1.0) is a separate number (bovnar::version() against bovnar::spec_version()). Spec 1.2's one addition, the unit-profile notation, is covered in §5.1: it is gated on a #!bovnar 1.2 directive, which to_string() prepends when a kept spelling or a profile-only unit needs it. The 1.1 features (all gated on a #!bovnar 1.1 directive — an unversioned document is treated as 1.0) are exposed as:

use bovnar::{Value, parse_typed, peek_version, spec_version, to_string};

fn main() -> Result<(), bovnar::Error> {
    assert_eq!(spec_version(), (1, 2));
    let bvnr = b"#!bovnar 1.1\n.t = <datetime:64,unix> 2026-01-01T12:00:00.5Z;";
    assert_eq!(peek_version(bvnr), Some((1, 1)));

    let doc = parse_typed(bvnr)?;
    let Value::Struct(fields) = &doc else { unreachable!() };
    let Value::Quantity(q) = &fields[0].1 else { unreachable!() };
    assert_eq!(q.epoch_name().as_deref(), Some("unix"));
    assert_eq!(q.frac.as_deref(), Some("5"));
    // the fraction survives a typed round trip
    assert_eq!(parse_typed(&to_string(&doc, true)?)?, doc);
    Ok(())
}

ErrorCode adds InvalidSpecVersion (42), UnsupportedSpecVersion (43), InvalidCodepoint (44), InvalidDatetimeLiteral (45), and DatetimeLiteralUnsupportedEpoch (46).


19. License

MIT, like the C library — see LICENSE.

The crate compiles libbvnr into your binary, and that embeds Bovnar's unit-profile tables, which carry identifier strings from UCUM, QUDT, OM 2, UDUNITS-2, the CF standard name table and UN/ECE Recommendations 20 and 21. Those identifiers belong to their publishers and the MIT grant does not extend to them; the notices are at THIRD_PARTY_NOTICES.md.


See also


End of Bovnar — Rust Bindings (Bovnar spec 1.2).