BOVNAR.bvnr

Bovnar — Read & Write API

Bovnar (BVNR) v1.1 documentation · Also available as Markdown and PDF.

Spec version: 1.1 Status: Reference — the C reader, writer, and DOM as implemented Scope: Every function needed to read and write Bovnar streams, in the order you call them.

The writer uses the same event/data model as the reader — bvnr_event_t and bvnr_data_t — so the two APIs are deliberately symmetric. Learn one, the other follows.


Table of Contents

  1. Reader
  2. Writer
  3. Shared
  4. DOM API (bovnar_dom.h)
  5. Complete Read Example
  6. Inline Unit Suffix — Reading
  7. Complete Write Example

1. Reader


1.1 bvnr_reader_create / bvnr_reader_destroy

bvnr_reader_t *bvnr_reader_create(void);
void           bvnr_reader_destroy(bvnr_reader_t *r);

Allocate and free a reader on the heap. bvnr_reader_create returns NULL if allocation fails. Always pair with bvnr_reader_destroy when done.

bvnr_reader_t *r = bvnr_reader_create();
if (!r) { perror("alloc"); exit(1); }

/* ... open, read ... */

bvnr_reader_destroy(r);

1.2 bvnr_source_from_fd

void bvnr_source_from_fd(bvnr_source_t *s, int fd);

Initialise the source s to read from an open, readable POSIX file descriptor. The caller retains ownership of fd; the library will not close it.

bvnr_source_t src;
int fd = open("config.bvnr", O_RDONLY);
if (fd < 0) { perror("open"); exit(1); }

bvnr_source_from_fd(&src, fd);
/* pass &src to bvnr_open_read_source */

1.3 bvnr_source_from_mem

void bvnr_source_from_mem(bvnr_source_t *s, const void *buf, uint64_t len);

Initialise the source s to read from an in-memory buffer. The buffer must remain valid for the duration of bvnr_read. No copy is made.

static const char payload[] =
    ".host = \"localhost\";\n"
    ".port = <uint:16> 8080;\n";

bvnr_source_t src;
bvnr_source_from_mem(&src, payload, sizeof(payload) - 1);

1.4 bvnr_open_read_source

bool bvnr_open_read_source(bvnr_reader_t        *r,
                         const bvnr_source_t  *src,
                         const bvnr_sink_t    *src_mirror,
                         bvnr_read_flags_t    *options);

Attach the source to the reader and configure it. Must be called before bvnr_read. Returns false on invalid arguments (error_invalid_argument).

typedef struct bvnr_read_flags_s {
    void  *userdata;
    bool (*on_unverified)(void *userdata, bvnr_event_t, bvnr_data_t *);
    bool (*on_verified)  (void *userdata, bvnr_event_t, bvnr_data_t *);
    bool   continue_on_error;
    bvnr_on_error_fn on_error;
    uint64_t max_file_size;        /* 0 → unlimited / endless (default); set positive to cap */
    uint8_t  max_struct_nesting;   /* 0 → 64 internal default; hard cap 255 */
    uint8_t  max_array_nesting;    /* 0 → 64 internal default; hard cap 255 */
    /* ... the remaining size limits, plus strict_version, text_only (§1.13),
     * and the want_unit hook and its two knobs (§1.10) ... */
} bvnr_read_flags_t;

The full struct is in include/bovnar.h, and the specification lists it in §16.4.

on_verified is the callback you will implement almost always. on_unverified fires before semantic validation — use it only for diagnostics or partial inspection. The one exception is read-time unit conversion: want_unit (§1.10) runs ahead of both callbacks, so with it installed an on_unverified consumer also sees a populated converted/conv on ev_data. Both callbacks must return true to continue parsing, false to abort (sets error_scanner_callback_failed).

The options pointer is not stored; the struct is read during bvnr_open_read_source only, so it may live on the stack.

max_text_bytes is not a total-size cap. It counts the bytes the text scanner consumes; an octet stream's binary payload does not count towards it. A 212-byte document whose body is a 200-byte octet stream is accepted at max_text_bytes = 8. If you are bounding memory or guarding against a hostile input, max_file_size is the one that counts every byte. Both are exact at their boundary: a document needing N is accepted at N and refused at N-1.

Reader default limits. When a bvnr_read_flags_t field is set to 0, the reader substitutes an internal default. For the nesting fields, the default is 64 (not 255); the hard maximum is 255. For max_array_items and max_text_bytes, the default is 2 147 483 647 — permissive but finite. max_file_size differs: 0 means unlimited / endless (no byte-count cap accumulated), which is the default so endless streaming works out of the box. Setting max_file_size explicitly to 16777216 (16 MiB) is recommended for production.

static bool on_event(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    /* handle ev / d ... */
    return true;
}

bvnr_read_flags_t opts = {
    .on_verified   = on_event,
    .userdata      = &my_ctx,
    .max_file_size = 16777216,  /* 16 MiB cap */
    /* max_array_nesting: 0 → 64 internal default; hard cap 255 */
};

if (!bvnr_open_read_source(r, &src, NULL, &opts)) {
    fprintf(stderr, "open failed\n");
    return -1;
}

1.5 bvnr_open_read_mem

bool bvnr_open_read_mem(bvnr_reader_t     *r,
                         const void        *buf,
                         uint64_t           len,
                         void              *mirror_buf,
                         uint64_t           mirror_cap,
                         bvnr_read_flags_t *options);

Convenience wrapper that constructs a memory source (and optionally a memory mirror sink) internally. Equivalent to calling bvnr_source_from_mem followed by bvnr_open_read_source. Pass NULL / 0 for mirror_buf / mirror_cap to skip mirroring.

bvnr_read_flags_t opts = { .on_verified = on_event, .userdata = &ctx };

if (!bvnr_open_read_mem(r, payload, payload_len, NULL, 0, &opts))
    return -1;

1.6 bvnr_read

bool bvnr_read(bvnr_reader_t *r);

Drive the parser until EOF or a fatal error. Fires the registered callbacks for every event. Returns true on clean completion, false on any error.

This is the only call needed after bvnr_open_read_*. The reader does not allocate during this call; all buffering is internal to the reader struct.

if (!bvnr_read(r)) {
    fprintf(stderr, "parse error: %s at line %" PRIu64 " col %" PRIu64 "\n",
            bvn_error_to_string(bvnr_reader_get_error(r)),
            bvnr_reader_get_error_line(r),
            bvnr_reader_get_error_column(r));
    bvnr_reader_destroy(r);
    return -1;
}

1.7 bvnr_reader_get_error and friends

error_code_t bvnr_reader_get_error     (const bvnr_reader_t *r);
uint64_t     bvnr_reader_get_error_line   (const bvnr_reader_t *r);
uint64_t     bvnr_reader_get_error_column (const bvnr_reader_t *r);
uint64_t     bvnr_reader_get_error_offset (const bvnr_reader_t *r);
uint32_t     bvnr_reader_get_error_byte   (const bvnr_reader_t *r);
uint64_t     bvnr_reader_get_recovery_count(const bvnr_reader_t *r);

The three position counters measure different things, and a tool placing a caret needs the right one:

Getter Unit Base Note
..._line lines 1
..._column characters 1 a multi-byte UTF-8 sequence advances it by one; a tab advances to the next multiple of 4
..._offset bytes 0 use this to index the input buffer

In .x = "café"; .y = @; the @ is byte 20 but column 19. This matters here more than in most formats: unit symbols are routinely non-ASCII (µ~m, °C, Ω), so the two disagree often.

All five error/location getters above (everything except bvnr_reader_get_recovery_count) are only meaningful when bvnr_read returned false (or after a recoverable error when continue_on_error is set). bvnr_reader_get_error_byte returns the raw byte value that triggered the error. bvnr_reader_get_recovery_count is the exception: it returns how many errors triggered entry into resync mode in continue_on_error mode (and so is meaningful even when bvnr_read ultimately returned true). This count is incremented at error entry, not when resync completes at ";".bvnr_reader_get_skipped_bytes` is the same kind of thing and answers the question the count cannot: how much of the input recovery threw away. One skipped assignment and a whole discarded struct both report a single recovery, and the skipped bytes were never parsed, so nothing else in the API mentions them — a non-zero total means the document your callbacks saw is not the whole document.

if (!bvnr_read(r)) {
    error_code_t ec = bvnr_reader_get_error(r);
    fprintf(stderr,
            "%s at line %" PRIu64 ", col %" PRIu64
            ", offset %" PRIu64 ", byte 0x%02X\n",
            bvn_error_to_string(ec),
            bvnr_reader_get_error_line(r),
            bvnr_reader_get_error_column(r),
            bvnr_reader_get_error_offset(r),
            bvnr_reader_get_error_byte(r));
}

1.8 Version directive (spec 1.1)

bool        bvnr_reader_get_declared_version(
                const bvnr_reader_t *r, uint16_t *major, uint16_t *minor);
bool        bvnr_peek_version(
                const void *buf, uint64_t len, uint16_t *major, uint16_t *minor);
uint32_t    bvnr_version(void);
const char *bvnr_version_string(void);
void        bvnr_spec_version(uint16_t *major, uint16_t *minor);

A document may begin with a #!bovnar <major>.<minor> directive (see spec §3.4). After bvnr_read, bvnr_reader_get_declared_version returns true and fills major/minor when the document carried one (either out pointer may be NULL). Set bvnr_read_flags_t.strict_version to reject a version newer than this build supports with error_unsupported_spec_version; by default such a version is recorded but accepted. A malformed directive is always error_invalid_spec_version.

bvnr_peek_version scans a raw buffer for the directive without a full parse (handy before opening a writer to round-trip it). bvnr_version / bvnr_version_string return the library version; bvnr_spec_version returns the highest spec version this build understands (BVNR_SPEC_VERSION_*).

uint16_t maj, min;
if (bvnr_reader_get_declared_version(r, &maj, &min))
    printf("document declares bovnar %u.%u\n", maj, min);

To emit a directive, call bvnr_write_version right after opening the writer (see §2.5), or set bvnr_write_flags_t.emit_version to stamp the current spec version automatically.


1.9 Datetime family (spec 1.1)

const char *bvnr_datetime_epoch_name (value_type_spec_t vt);
int32_t     bvnr_datetime_epoch_mjd  (value_type_spec_t vt);
int32_t     bvnr_datetime_epoch_index(const char *name);
bool        bvnr_write_datetime(bvnr_writer_t *w, const char *key,
                                uint32_t width, const char *epoch, int64_t value);

A <datetime:width,epoch> value (family vt_datetime) is a signed integer count of seconds since a named epoch — a timestamp, distinct from a duration (a number with a time unit, e.g. <float:64,s>). The carrier is validated like sint; the epoch is stored as a small dense index in value_type_spec_t.base (not a numeric base — the carrier is always decimal). These two helpers recover the epoch from a spec: bvnr_datetime_epoch_name returns its lowercase name ("unix" — the default — "tai", "gps", "mjd", "ntp", "galileo", "glonass", "y2000", "beidou"), and bvnr_datetime_epoch_mjd returns its Modified Julian Day (the bvn_epoch_t value from bvn_datetime.h). Pass that to bvn_dt_epoch_seconds_to_datetime() to convert to a civil date/time.

/* on a datetime data event: */
int64_t secs;  bvn_parse_int64((const char *)d->data, d->value_type, &secs);
bvn_datetime_t civil;
bvn_dt_epoch_seconds_to_datetime(&civil,
    (bvn_epoch_t)bvnr_datetime_epoch_mjd(d->value_type), secs);

To write a datetime, use the typed helper bvnr_write_datetimeepoch is an epoch name (or NULL for unix; an unknown name is error_invalid_argument), value is signed seconds. bvnr_datetime_epoch_index maps a name to the index stored in value_type_spec_t.base (the inverse of bvnr_datetime_epoch_name), for building a spec by hand. The document must declare #!bovnar 1.1 to be re-read (emit the directive with bvnr_write_version).

bvnr_write_version(w, 1, 1);
bvnr_write_datetime(w, "created", 64, "gps", 1750000000);  /* <datetime:64,gps> */

The family is spec 1.1: it requires a #!bovnar 1.1 declaration, and in a 1.0/unversioned document a datetime annotation is error_illegal_value_type.

ISO-8601 literals and fractional seconds. A datetime may be written as an ISO-8601 literal (2026-06-15T12:00:00.5Z) instead of a raw integer; the reader converts it to the whole-second carrier you receive in d->data. When the literal carries a fractional second, the verbatim digits (no leading .) are delivered alongside the carrier in two bvnr_data_t fields, frac_data and frac_lengthNULL/0 for every other value. The carrier is unchanged (the value floors to the written second), so the fraction is informational, but it lets a consumer see sub-second precision the integer cannot hold, and the writer re-emits it: a datetime data event whose frac_data is set is serialised back as an ISO literal so the value round-trips. Like d->data, frac_data is not NUL-terminated — bound the read by frac_length.

/* on a datetime data event: */
if (d->frac_data && d->frac_length) {
    /* d->frac_data[0 .. frac_length) are the sub-second digits, e.g. "5" */
}

1.10 Read-time lossless unit / base conversion (want_unit)

/* bvnr_read_flags_t */
bool (*want_unit)(void *userdata, const bvnr_data_t *data,
                  value_unit_t *want, uint32_t *want_base);

/* bvnr_data_t */
bool             converted;   /* true when this value was converted */
bvnr_converted_t conv;        /* the exact converted value (see below) */

typedef struct bvnr_converted_s {
    value_unit_t     unit;    /* the target unit */
    const char*      text;    /* exact value in `base`, NUL-terminated;
                               * NULL if it does not terminate in `base` */
    uint32_t         length;  /* strlen(text), 0 when text is NULL */
    uint32_t         base;    /* base text is rendered in (2..62, 64, 85) */
    const struct bvn_int_s* num;   /* exact numerator (signed) */
    const struct bvn_int_s* den;   /* exact denominator (> 0) */
} bvnr_converted_t;

/* bvnr_read_flags_t */
bool     want_unit_allow_nonterminating;  /* opt in to rational-only results */
uint32_t max_conversion_length;           /* work limit; 0 = 1024 */

By default the reader hands you every numeric value exactly as written and you convert it yourself (§3.4, bvn_unit_convert_rational). Set want_unit to have the reader do it for you — losslessly, in exact arbitrary-precision arithmetic, for a value of any width and any base.

When want_unit is non-NULL, the reader calls it for every numeric value (with or without a unit) after validation and before either value callback — it can abort the parse, and the two views of one value must not disagree. An on_unverified consumer therefore also sees a populated converted/conv on ev_data, even though everything else about that event is still the pre-validation view. Inspect data->value_unit (native unit), data->value_type, or data->data and either:

*want_base accepts any base bvnr can write — 2..62 plus 64 (Base64) and 85 (Ascii85) — or 0 to keep the value's own. Anything else is an error, never a quiet substitution. Note that 64 and 85 have no sign character, so a negative result in those is rejected too.

Requesting *want equal to the native unit with a different *want_base performs a pure base conversion (e.g. a hex integer delivered in decimal). The choice is usually key-specific; track "the current key" (from the earlier ev_assignment_start event) in your userdata.

On a valid request the value arrives with data->converted == true and data->conv holding the exact result — the value in the requested unit and base as both a positional string (conv.text) and a reduced rational (conv.num/conv.den, bvn_int.h). Everything in conv is reader-owned and valid only for the callback — copy text (or the rational) to retain it. data->data / data->value_unit keep the original digits and unit.

The conversion is lossless. A 1056-bit binary float or a 512-bit integer converts with no loss beyond the library's own declared factor — the result widens as needed rather than rounding into a double. Every numeric family is eligible: uint/sint of any width and base (including a multiprecision integer or a non-decimal base written as a string literal like <uint:32,_16> "FF"), float, float_dec, float_fix. A datetime is never offered.

Once the hook has asked for a conversion, the value either arrives converted or the parse stops. Nothing approximate is delivered, and nothing is silently skipped:

Condition Error
*want dimensionally incompatible with the value's unit (seconds for a length; one currency for another) error_unit_mismatch
the true factor is irrational (a π-based angle, e.g. degree → radian) error_unit_inexact
the value's unit puts an affine scale inside a compound (°C/h, °C·m, °C²): the offset is a number of kelvin and the product has nowhere to add it error_unit_mismatch
the exact result has no terminating expansion in *want_base (e.g. 1 m → mile in base 10), and want_unit_allow_nonterminating is off error_unit_inexact
the literal is finite but too extreme to build an exact rational from (e.g. 1e1000000) error_value_out_of_range
*want_base is not a base bvnr writes, or the result is negative in base 64/85, or out of memory error_invalid_argument
the exact result would be longer than max_conversion_length characters error_value_out_of_range

Only nan/inf/ninf are handed over untouched (converted == false, no error): they carry no finite value, so no conversion was possible or promised.

Bounding the work

Rendering an exact expansion is quadratic in its digit count, and the count follows the magnitude of the value's exponent, not the literal's length: 1e-9800 is seven characters and expands to 9800 digits. Left unbounded, a one-kilobyte document of such values costs minutes of CPU while looking trivial.

max_conversion_length caps the characters a conversion may produce; 0 selects BVNR_DEFAULT_MAX_CONVERSION_LENGTH (1024), generous next to any real measurement. Anything longer is error_value_out_of_range, rejected before the digits are generated. Raise it if you genuinely need thousands of exact digits. Like max_number_length and max_array_items, it is here so a consumer of untrusted input is not at the mercy of the input's shape.

Exact-but-not-writable results

Plenty of everyday conversions are exact as a rational yet have no finite positional expansion in the output base — km/h → m/s is 5/18, °F → °C and m → km in base 2 are the same story. By default those abort with error_unit_inexact rather than round.

Set want_unit_allow_nonterminating when your consumer can take a rational. The value then arrives normally with conv.num/conv.den exact and conv.text == NULL (conv.length == 0) — always NULL-check conv.text before printing it. An irrational factor still aborts even with the flag set: there is no exact rational to hand over in the first place.

static bool want_unit(void *ud, const bvnr_data_t *d,
                      value_unit_t *want, uint32_t *want_base)
{
    /* deliver every length in metres, base 10; leave everything else */
    bool ok;
    value_unit_t metre = bvn_parse_unit((const uint8_t *)"m", &ok);
    if (bvn_units_compatible(d->value_unit, metre)) {
        *want = metre;
        *want_base = 10;
        return true;
    }
    return false;
}

static bool on_event(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    if (ev == ev_data && d->converted && d->conv.text)
        printf("= %s m\n", d->conv.text);   /* e.g. "5 k~m" -> "5000" */
    return true;
}

bvnr_read_flags_t opts = { .on_verified = on_event, .want_unit = want_unit };

1.11 bvn_parse_uint64 / bvn_parse_int64 / bvn_parse_double

bool bvn_parse_uint64(const char *s, value_type_spec_t vt, uint64_t *out);
bool bvn_parse_int64 (const char *s, value_type_spec_t vt, int64_t  *out);
bool bvn_parse_double(const char *s, value_type_spec_t vt, double   *out);

Convert the raw token string received in ev_data into a C numeric type. The vt argument — taken directly from d->value_type — supplies the base and bit-width for range checking.

The data pointer inside bvnr_data_t is not NUL-terminated, and for null/empty values d->length may be 0. Always guard the copy by d->length and NUL-terminate manually.

static bool on_event(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    if (ev != ev_data) return true;

    char buf[256];
    if (d->length >= sizeof(buf)) return false;
    if (d->length) memcpy(buf, d->data, d->length);
    buf[d->length] = '\0';

    switch (d->value_type.family) {
    case vt_uint: {
        uint64_t v;
        if (bvn_parse_uint64(buf, d->value_type, &v))
            printf("uint = %" PRIu64 "\n", v);
        break;
    }
    case vt_sint: {
        int64_t v;
        if (bvn_parse_int64(buf, d->value_type, &v))
            printf("sint = %" PRId64 "\n", v);
        break;
    }
    case vt_float: {
        double v;
        if (bvn_parse_double(buf, d->value_type, &v))
            printf("float = %g\n", v);
        break;
    }
    default:
        break;
    }
    return true;
}

1.12 Reader-side unit policy (bvnr_reader_set_unit_policy)

What want_unit does through a callback, stated as data: what the document must contain, and what unit the consumer wants values delivered in. Everything is expressed as unit text, so a binding can drive it without a function pointer.

#define BVNR_MAX_UNIT_TARGETS 8u
#define BVNR_MAX_UNIT_RULES   8u
#define BVNR_MAX_UNIT_PATH    96u

typedef struct bvnr_unit_target_s {
    const char *unit;   /* "m/s"; parsed when the policy is set */
    uint32_t    base;   /* output base, 0 = the value's own */
} bvnr_unit_target_t;

typedef struct bvnr_unit_rule_s {
    const char            *path;   /* ".inlet.temperature", or ".inlet.*" */
    const char            *unit;
    uint32_t               base;   /* output base for bvnr_rule_convert, 0 = own */
    bvnr_unit_rule_mode_t  mode;   /* bvnr_rule_convert | bvnr_rule_require */
} bvnr_unit_rule_t;

typedef struct bvnr_unit_policy_s {
    /* per-field rules — consulted before everything below */
    const bvnr_unit_rule_t     *rules;
    uint32_t                    num_rules;
    /* conversion */
    const bvnr_unit_target_t   *targets;
    uint32_t                    num_targets;
    uint32_t                    base;         /* output base for `normalise` */
    bvnr_unit_normalise_t       normalise;    /* none | bvnr_normalise_si */
    bvnr_unit_inexact_policy_t  on_inexact;   /* error | leave */
    /* validation — these reject a document and never change a value */
    bool                        require_unit;
    const char * const         *require_dimension_of;
    uint32_t                    num_require_dimension_of;
} bvnr_unit_policy_t;

bool bvnr_reader_set_unit_policy(bvnr_reader_t *r, const bvnr_unit_policy_t *p);

Every unit string is parsed by the setter, so a typo is a false return before the parse begins rather than an error inside somebody's document; a rejected policy leaves the previous one in force. The strings need not outlive the call. The policy may be installed before or after bvnr_open_read_*, and it survives re-opening the reader on another document — it describes the consumer, not the document. Passing NULL clears it.

Per-field rules

A rule names ONE field, by the dotted key path a value sits at — leading dot included, exactly as the document writes its keys. A path ending in .* names everything below that point at any depth. Rules are consulted before everything else in the policy: they are the most specific thing it can say.

static const bvnr_unit_rule_t rules[] = {
    { ".inlet.temperature", "°C",  0, bvnr_rule_convert },
    { ".inlet.*",           "m",   0, bvnr_rule_require },
};
bvnr_unit_policy_t p = {0};
p.rules = rules; p.num_rules = 2;

Unlike a whole-document target, a rule is an assertion: the caller named this field, so a value that cannot be applied to it is error_unit_mismatch rather than a value passed through quietly — silence would defeat the point of naming it. A bare number fails a rule too: ".speed is m/s" is not satisfied by a value with no unit. bvnr_rule_require asserts without converting.

First match wins, so order matters: put ".a.b" before ".a.*". A prefix only matches at a component boundary, so ".in.*" does not claim ".inlet.a". Array elements sit at the path of the assignment holding them, so one rule covers a whole array. Paths are capped at BVNR_MAX_UNIT_PATH characters and 32 levels of nesting; a value deeper than the tracker can describe is at an unknown path and matches no rule at all, rather than matching the wrong one.

Conversion

Each numeric value is converted to the first target it can validly convert to, so order is significant: a list of {"m", "k~m"} never selects k~m. A value that matches no target is delivered untouched — that is the normal outcome for a value no rule mentions, not a failure. A value already in the unit a target names is also left alone (unless that target asks for an output base, which makes it a pure base conversion), so converted means "the policy restated this value", not "the policy looked at it".

static const bvnr_unit_target_t targets[] = { { "°C", 0 }, { "m", 0 } };
bvnr_unit_policy_t p = {0};
p.targets = targets; p.num_targets = 2;
bvnr_reader_set_unit_policy(r, &p);
document delivered
<float:64,°F> 212.0 100 in °C
<float:64,in> 12.0 0.3048 in m
<float:64,%> 35.0 untouched — no target is a ratio
<float:64> 0.25 untouched — see the fence below

A value with no unit only ever matches a target that is itself no_unit. A bare number is dimensionally compatible with % and with ppm, so without that fence a policy naming "%" would deliver 0.25 as 25 — the silent rescale the format exists to prevent, arrived at through the machinery meant to prevent it. The fence works in both directions: a policy naming no_unit will not restate 35 % as 0.35.

Selection uses bvn_units_convertible, not bvn_units_compatible. That is what keeps k~$USD -> $USD available: a currency carries no dimension by design, so bvn_units_compatible reports it incompatible with itself although the conversion is exact and well defined.

Normalisation

normalise = bvnr_normalise_si catches whatever the targets did not, delivering it in coherent SI base units with prefixes folded out:

document delivered
<float:64,in> 12.0 0.3048 in m
<float:64,°F> 212.0 373.15 in K
<float:64,g> 5.0 0.005 in k~g — mass normalises to the kilogram
<float:64,%> 35.0 untouched
<float:64,$USD> 5.0 untouched

Every dimensionless unit is left exactly as written — %, ppm, dB, pH, rad, °, the turbidity scales — along with the currencies. Normalising a ratio would silently restate 35 % as 0.35, and normalising an angle would need the irrational factor between ° and rad. See bvn_unit_si_normal_form.

Exactness

The conversion is the same exact path want_unit uses, and nothing approximate is ever delivered. A blanket mode makes every value a conversion candidate, though, so a single ordinary factor — 42 km/h is 35/3 m/s, exact as a rational with no terminating base-10 expansion — would otherwise reject a document nobody had a complaint about. on_inexact = bvnr_inexact_leave hands that value over in its native unit instead, visible as converted == false and never as rounded digits. A genuinely irrational factor still aborts, since there is nothing exact to hand over either way.

Validation

require_unit and require_dimension_of reject a document; they never change a value. Both are evaluated on the unit the document wrote, before any conversion — validate what you were sent, convert for the consumer — so they mean the same thing whether or not a conversion was also requested. A failure is error_unit_mismatch.

static const char *lengths[] = { "m" };
bvnr_unit_policy_t p = {0};
p.require_unit             = true;   /* reject any bare number */
p.require_dimension_of     = lengths;/* every value must be a length */
p.num_require_dimension_of = 1;

require_dimension_of asks about the dimension, not the unit: 5 k~m, 12 in and 3 mi all satisfy {"m"}. A value must satisfy at least one entry. The same unitless fence applies — a bare number satisfies a requirement only if one of the listed units is itself no_unit.

Where this sits relative to want_unit

The hook is more specific and wins. For each value the reader asks the hook first, then the per-field rules, then targets, and falls back last to normalise. Setting several is a legitimate combination: normalise the document, name the two fields that need something else, and hand-handle one more in the hook.


1.13 Refusing the binary half (text_only, under implementation)

bvnr_read_flags_t f = {0};
f.text_only = true;              /* an octet stream is error_octet_stream_forbidden */

Bovnar is a text/binary hybrid: an octet stream carries arbitrary bytes inside an otherwise readable document, framed by 0x00 and length-prefixed so a reader can skip it without inspecting it (spec §9). That design is what makes binary payloads free of escaping and expansion — and it is also the one part of a document that a text-shaped pipeline destroys silently. Line-ending normalisation rewrites a 0x0D inside a chunk payload, the length prefix then points at the wrong byte, and what arrives looks like a malformed document rather than a mangled one. A git checkout without -text, a sed filter, or a log aggregator that assumes lines will each do it.

text_only lets a consumer state that this channel carries text, and have the parser enforce it:

$ bovnar validate --text-only telemetry.bvnr
Validation failed: octet_stream_forbidden at line 3, col 6

This is the same move as --require-unit: the format permits something, and a consumer that cannot accept it says so where it is checkable rather than hoping. A producer can use it the same way, to guarantee that a channel it publishes stays transport-safe.

The refusal fires at the stream's opening 0x00, before any payload is read — the point is that such a document should not have reached this reader at all, so the earliest refusal is the useful one, and a large binary region is never touched. The flag changes nothing else: a document without an octet stream parses identically with it set.

Available on the CLI for validate and events. query refuses the option rather than ignoring it, because that command parses through the DOM, which takes a unit policy but not the read flags — an assertion silently not in force is worse than none.


2. Writer


2.1 bvnr_writer_create / bvnr_writer_destroy

bvnr_writer_t *bvnr_writer_create(void);
void           bvnr_writer_destroy(bvnr_writer_t *w);

Allocate and free a writer on the heap. Mirrors the reader lifecycle exactly. Returns NULL on allocation failure.

bvnr_writer_t *w = bvnr_writer_create();
if (!w) { perror("alloc"); exit(1); }

/* ... open, write events, finish ... */

bvnr_writer_destroy(w);

2.2 bvnr_sink_to_fd

void bvnr_sink_to_fd(bvnr_sink_t *s, int fd);

Initialise sink s to write serialised bytes to an open, writable POSIX file descriptor. The caller retains ownership of fd.

bvnr_sink_t sink;
bvnr_sink_to_fd(&sink, STDOUT_FILENO);
/* or: int fd = open("out.bvnr", O_WRONLY|O_CREAT|O_TRUNC, 0644); */

2.3 bvnr_sink_to_mem

void bvnr_sink_to_mem(bvnr_sink_t *s, void *buf, uint64_t cap);

Initialise sink s to write into a caller-provided memory buffer of cap bytes. Writing beyond cap produces error_sink_buffer_exhausted. Use bvnr_sink_bytes_written to query how many bytes were actually written — but see the note there: for output produced by a writer, bvnr_writer_bytes_written is the one that works.

char out[4096];
bvnr_sink_t sink;
bvnr_sink_to_mem(&sink, out, sizeof(out));

2.4 bvnr_sink_bytes_written

uint64_t bvnr_sink_bytes_written(const bvnr_sink_t *s);

Return the number of bytes pushed into a memory sink so far. Only meaningful for sinks created with bvnr_sink_to_mem.

Not for writer output. bvnr_open_write_sink takes a copy of the sink, so everything a writer emits advances the copy and never the caller's struct — this counter stays 0 for the whole lifetime of the writer. Use it only when you push into the sink yourself. To size writer output, ask the writer:

bvnr_write_finish(w);
uint64_t n = bvnr_writer_bytes_written(w);   /* NOT bvnr_sink_bytes_written(&sink) */
fwrite(out, 1, n, stdout);
bvnr_writer_destroy(w);                       /* read the count before destroying */

2.5 bvnr_open_write_sink

bool bvnr_open_write_sink(bvnr_writer_t        *w,
                            const bvnr_sink_t    *sink,
                            bool                  pretty,
                            bvnr_write_flags_t   *options);

Attach sink to the writer and configure it. Must be called before any bvnr_write_event. Returns false on invalid arguments.

typedef struct bvnr_write_flags_s {
    /* Regrouped for exposition — see include/bovnar.h for the declaration
       order, and zero-initialise rather than relying on this one. ... */

    /* ── Writer enforces these ─────────────────────────────────── */
    uint8_t  max_struct_nesting;      /* 0 → 64 internal default; hard cap 255 */
    uint8_t  max_array_nesting;       /* 0 → 64 internal default; hard cap 255 */
    void    *userdata;
    bool   (*on_event)(void *userdata, bvnr_event_t, bvnr_data_t *);
    bvn_unit_flags_t unit_flags;      /* controls unit annotation format */
    bool     emit_version;            /* emit a leading "#!bovnar M.N" on open */

    /* ── Present for API symmetry with bvnr_read_flags_t;          ─
       the writer does not read or enforce these fields. Set to 0. */
    uint16_t max_identifier_length;
    uint16_t max_string_length;
    uint16_t max_number_length;
    uint16_t max_symbol_length;
    uint16_t max_reference_length;
    uint64_t max_array_items;
    uint64_t max_text_bytes;
    uint64_t max_file_size;
    bool     continue_on_error;       /* no-op in the writer */
    bvnr_on_error_fn on_error;        /* no-op in the writer */

    uint64_t _reserved[4];            /* padding for future fields; leave zero */
} bvnr_write_flags_t;

Writer limits. Only max_struct_nesting, max_array_nesting, on_event, userdata, and unit_flags have any effect on the writer. All other fields are present solely to keep bvnr_write_flags_t structurally parallel to bvnr_read_flags_t; they are silently ignored. In particular, continue_on_error, on_error, and all per-token-length fields have no effect. The writer never internally limits array items, text bytes, or file size.

on_event in the write flags fires for each event as it is serialised — useful for logging or auditing. Pass NULL if not needed.

unit_flags controls how unit annotations are serialised by the writer. The valid flags are:

Flag Value Effect
BVN_UNIT_FLAGS_NONE 0 Default: Unicode superscript exponents, no reduction
BVN_UNIT_REDUCE 1 << 0 Reduce compound units to canonical form before serialising
BVN_UNIT_ASCII_EXP 1 << 1 Use ^N ASCII caret notation instead of Unicode superscripts

BVN_UNIT_REDUCE folds every prefix out of the unit, and the writer scales the value to match: 5 with unit k~m is written as 5000 m, not 5 m. The rescale runs in exact rational arithmetic, so a value far wider than a double keeps every digit; where the scaled value cannot be written exactly in the value's own base the write fails with error_value_out_of_range rather than rounding. The rescaled value must also still satisfy the type it was declared with: an uint/sint that is no longer integral or no longer fits its width, and a float_fix that no longer fits its Q format (<float_fix:32,q16> leaves 15 integer bits, so 30000 km in metres does not fit), are refused the same way — the writer must not emit a document this library's own reader rejects. (A direct caller of bvn_unit_to_string_ex gets no such help — see the note on that function in §3.3.)

These flags can be OR-combined: BVN_UNIT_REDUCE | BVN_UNIT_ASCII_EXP. The flags are fixed at open time. To change serialisation behaviour, destroy the writer and open a new one with the updated unit_flags. The getter bvnr_writer_unit_flags(w) is used internally by the Python FFI layer to retrieve the live flags before each unit serialisation call; there is no public setter.

bvnr_sink_t sink;
bvnr_sink_to_fd(&sink, fd);

bvnr_write_flags_t opts = { 0 };  /* all defaults */
if (!bvnr_open_write_sink(w, &sink, /*pretty=*/true, &opts))
    return -1;

2.6 bvnr_open_write_mem

bool bvnr_open_write_mem(bvnr_writer_t      *w,
                           void               *buf,
                           uint64_t            cap,
                           bool                pretty,
                           bvnr_write_flags_t *options);

Convenience wrapper that constructs a memory sink internally and calls bvnr_open_write_sink. Equivalent to bvnr_sink_to_mem + bvnr_open_write_sink. To retrieve the written byte count after finishing, call bvnr_writer_bytes_written.

char out[4096];
bvnr_write_flags_t opts = { 0 };
if (!bvnr_open_write_mem(w, out, sizeof(out), false, &opts))
    return -1;

2.7 bvnr_write_event

bool bvnr_write_event(bvnr_writer_t *w, bvnr_event_t ev, bvnr_data_t *data);

Emit one event to the writer. This is the only function used to produce output. It serialises the event and the data it describes directly into the configured sink.

Returns true on success, false on any serialisation error.

The event sequence you must emit for every assignment mirrors exactly what the reader delivers to on_verified. At minimum, for a typed scalar value:

ev_assignment_start          (data->data = key, data->length = key length)
ev_type_annotation_start     (data->data = annotation text, or NULL for no annotation)
ev_type_annotation_type_family
ev_type_annotation_type_family_parameter   (for each parameter)
ev_type_annotation_end
ev_data                      (data->data = value string, data->value_type/value_unit set)

The BVN_TYPE_* macros build value_type_spec_t literals conveniently:

#define BVN_TYPE_PLAIN          ((value_type_spec_t){ .family = vt_plain })
#define BVN_TYPE_UTF8           ((value_type_spec_t){ .family = vt_utf8  })
#define BVN_TYPE_BOOL           ((value_type_spec_t){ .family = vt_bool  })
#define BVN_TYPE_UINT(w)        ((value_type_spec_t){ .family = vt_uint,      .width = (w) })
#define BVN_TYPE_SINT(w)        ((value_type_spec_t){ .family = vt_sint,      .width = (w) })
#define BVN_TYPE_FLOAT(w)       ((value_type_spec_t){ .family = vt_float,     .width = (w) })
/* float_fix: .base is repurposed to store Q (fractional bits). */
#define BVN_TYPE_FLOAT_FIX(w,q) ((value_type_spec_t){ .family = vt_float_fix, .width = (w), .base = (q) })
/* float_dec: base field is unused (always 0).                   */
#define BVN_TYPE_FLOAT_DEC(w)   ((value_type_spec_t){ .family = vt_float_dec, .width = (w) })
/* float with explicit numeral base (for base-16 output):        */
#define BVN_TYPE_FLOAT_BASE(w,b) ((value_type_spec_t){ .family = vt_float, .width = (w), .base = (b) })
/* uint/sint with explicit numeral base:                         */
#define BVN_TYPE_UINT_BASE(w,b) ((value_type_spec_t){ .family = vt_uint, .width = (w), .base = (b) })
#define BVN_TYPE_SINT_BASE(w,b) ((value_type_spec_t){ .family = vt_sint, .width = (w), .base = (b) })

The maximum bit-width accepted for uint and sint is BVN_MAX_INT_WIDTH (defined as 32768u in bovnar.h). The validator and writer reject any declared uint/sint width exceeding this limit with error_illegal_value_type.

Critical: The writer dispatches ev_type_annotation_type_family_parameter events on d->type, not on d->value_type. For each parameter event, d.type must be set to the appropriate token_type_t value: token_is_type_width for the width parameter, token_is_type_base for the base parameter, token_is_type_q for the Q (fractional bits) parameter of float_fix, and token_is_unit for the unit parameter. An unrecognised d.type causes the writer to emit nothing for that event — the annotation will be silently incomplete. Use bvnr_write_type_annotation (see §3.1) to avoid this complexity entirely.

Example: write .port = <uint:16> 8080;

/* Helper: emit a fully-typed uint16 assignment */
static bool write_uint16(bvnr_writer_t *w,
                          const char *key, uint16_t value)
{
    char valbuf[16];
    snprintf(valbuf, sizeof(valbuf), "%" PRIu16, value);

    value_type_spec_t vt = BVN_TYPE_UINT(16);
    value_unit_t      vu = BVN_UNIT_NONE;

    bvnr_data_t d;

    /* 1. Assignment start — key */
    d = (bvnr_data_t){ .data = (void*)key, .length = (uint32_t)strlen(key) };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;

    /* 2. Type annotation — use bvnr_write_type_annotation for the full sequence */
    if (!bvnr_write_type_annotation(w, vt, vu)) return false;

    /* 3. Value */
    d = (bvnr_data_t){
        .type       = token_is_number,
        .value_type = vt,
        .value_unit = vu,
        .data       = valbuf,
        .length     = (uint32_t)strlen(valbuf),
    };
    return bvnr_write_event(w, ev_data, &d);
}

Example: write a string assignment .host = "localhost";

static bool write_string(bvnr_writer_t *w,
                           const char *key, const char *value)
{
    value_type_spec_t vt = BVN_TYPE_UTF8;
    value_unit_t      vu = BVN_UNIT_NONE;
    bvnr_data_t d;

    d = (bvnr_data_t){ .data = (void*)key, .length = (uint32_t)strlen(key) };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;

    d = (bvnr_data_t){ .value_type = vt };
    if (!bvnr_write_event(w, ev_type_annotation_start, &d)) return false;
    if (!bvnr_write_event(w, ev_type_annotation_type_family, &d)) return false;
    if (!bvnr_write_event(w, ev_type_annotation_end, &d)) return false;

    d = (bvnr_data_t){
        .value_type = vt,
        .data       = (void*)value,
        .length     = (uint32_t)strlen(value),
    };
    return bvnr_write_event(w, ev_data, &d);
}

Example: open and close a struct

static bool write_struct_start(bvnr_writer_t *w, const char *key)
{
    bvnr_data_t d = { .data = (void*)key, .length = (uint32_t)strlen(key) };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;
    return bvnr_write_event(w, ev_struct_start, &(bvnr_data_t){0});
}

static bool write_struct_end(bvnr_writer_t *w)
{
    return bvnr_write_event(w, ev_struct_end, &(bvnr_data_t){0});
}

2.8 bvnr_write_version

bool bvnr_write_version(bvnr_writer_t *w, uint16_t major, uint16_t minor);

Emit a leading #!bovnar <major>.<minor> version directive (spec §3.4). Must be called immediately after bvnr_open_write_* and before any value; calling it once output has begun is error_invalid_argument. Use it to round-trip a directive read from a source document, or pass BVNR_SPEC_VERSION_MAJOR / BVNR_SPEC_VERSION_MINOR to stamp the current spec version. Setting bvnr_write_flags_t.emit_version is equivalent to calling it with the current spec version right after open.

bvnr_open_write_sink(w, &sink, true, NULL);
bvnr_write_version(w, 1, 1);          /* "#!bovnar 1.1\n" */
bvnr_write_uint(w, "port", 16, 443);
bvnr_write_finish(w);

2.9 bvnr_write_finish

bool bvnr_write_finish(bvnr_writer_t *w);

Flush any buffered output and finalise the stream. Must be called after all bvnr_write_event calls and before bvnr_writer_destroy. Returns false if any struct is still open (error_got_incomplete_bvnr_stream), if writing the trailing semicolon fails, or if flushing the write buffer to the sink fails.

64 KiB write buffer. The writer accumulates output into an internal 64 KiB buffer. Individual bvnr_write_event calls do not push bytes to the sink immediately; instead bytes accumulate in the buffer and are forwarded to the sink only when the buffer is full or when bvnr_write_finish is called. This means the sink receives large contiguous writes rather than one tiny push per token, which is important for fd-based sinks. bvnr_writer_bytes_written always reflects the true total of bytes handed off to the sink plus bytes still in the buffer, so the count is accurate at any point during writing.

if (!bvnr_write_finish(w)) {
    fprintf(stderr, "write finish failed: %s\n",
            bvn_error_to_string(bvnr_writer_get_error(w)));
}
bvnr_writer_destroy(w);

2.10 bvnr_writer_get_error and friends

error_code_t     bvnr_writer_get_error       (const bvnr_writer_t *w);
uint64_t         bvnr_writer_get_error_offset(const bvnr_writer_t *w);
uint64_t         bvnr_writer_bytes_written   (const bvnr_writer_t *w);
bvn_unit_flags_t bvnr_writer_unit_flags      (const bvnr_writer_t *w);

The writer error API is smaller than the reader's: there are no bvnr_writer_get_error_line or bvnr_writer_get_error_column functions. The writer has no lexer and therefore cannot track source positions. Use bvnr_writer_get_error_offset (byte count into the output stream) and bvnr_writer_get_error (error code) for diagnostics. bvnr_writer_bytes_written returns the total bytes emitted to the sink so far — available at any point, not only after errors.

bvnr_writer_unit_flags returns the bvn_unit_flags_t bitmask currently stored in the writer object (set via bvnr_write_flags_t.unit_flags at open time). The writer uses these flags whenever it serialises a unit annotation string (via bvn_unit_to_string_ex). This function is primarily used by the Python bindings FFI layer to retrieve the live flags before each unit serialisation call.

if (!bvnr_write_event(w, ev_data, &d)) {
    fprintf(stderr, "write error: %s at offset %" PRIu64 "\n",
            bvn_error_to_string(bvnr_writer_get_error(w)),
            bvnr_writer_get_error_offset(w));
    return -1;
}

2.11 bvn_format_uint64 / bvn_format_int64 / bvn_format_double

int32_t bvn_format_uint64(char *buf, size_t bufsize,
                            uint64_t value, uint32_t base, uint32_t min_digits);

int32_t bvn_format_int64(char *buf, size_t bufsize,
                           int64_t value, uint32_t base, uint32_t min_digits);

int32_t bvn_format_double(char *buf, size_t bufsize,
                            double value, value_type_spec_t vt);

Produce the value string that goes into bvnr_data_t.data when writing numeric values. All three return the number of bytes written (excluding NUL terminator), or -1 on buffer overflow.

char buf[32];

/* Format 255 as a base-16 value, minimum 2 digits → "ff" */
int32_t n = bvn_format_uint64(buf, sizeof(buf), 255, 16, 2);
/* buf = "ff", n = 2 */

/* Format 9.81 as a 64-bit float */
value_type_spec_t vt = BVN_TYPE_FLOAT(64);
n = bvn_format_double(buf, sizeof(buf), 9.81, vt);
/* buf = "9.81e+0", n = 7 — the writer emits an exponent for every non-zero
   value (zero is written as "0.0" / "-0.0"), so a float always re-reads as a
   float and never as an integer carrier */

These strings are then placed into bvnr_data_t.data / .length before calling bvnr_write_event(w, ev_data, &d).


2.12 bvnr_write_bvnf_base / bvnr_write_bvnf_base_unit

bool bvnr_write_bvnf_base(bvnr_writer_t *w, const char *key,
                            const bvn_float_t *f,
                            uint32_t width, uint32_t base);

bool bvnr_write_bvnf_base_unit(bvnr_writer_t *w, const char *key,
                                 const bvn_float_t *f,
                                 uint32_t width, uint32_t base,
                                 value_unit_t unit);

Write an arbitrary-precision bvn_float_t value at any valid float width (0, 16, or any multiple of 32 up to 32768) and in either base 10 or base 16. The float string is generated internally by bvn_float_to_str and then validated by the writer before being sent to the sink — the call fails and sets an error if the generated string does not satisfy the writer's type constraints.

bvnr_write_bvnf_base is the no-unit variant; bvnr_write_bvnf_base_unit attaches a physical unit to the type annotation.

Base 10 — the value string uses the same decimal format as bvn_format_double and is emitted as a bare number token (token_is_number). The type annotation is <float:W> or <float:W,_10>.

Base 16 — the value string uses a binary-exponent hexadecimal format: [−]D.DDDDp[+|−]EEE where D.DDDD are lowercase hex mantissa digits and EEE is the decimal binary exponent. The string is emitted as a quoted string token (token_is_string) because non-decimal literals must be quoted in BVNR. The type annotation is <float:W,_16>.

#include "bvn_float.h"

bvn_float_t *f = bvn_float_alloc(1024u);
bvn_float_from_str(f, "3.14159265358979323846", 10);

/* .pi_dec = <float:1024> 3.14... ; */
bvnr_write_bvnf_base(w, "pi_dec", f, 1024u, 10u);

/* .pi_hex = <float:256,_16> "1.921fb54442d18p+1"; */
bvn_float_t *g = bvn_float_alloc(256u);
bvn_float_from_double(g, 3.14159265358979323846);
bvnr_write_bvnf_base(w, "pi_hex", g, 256u, 16u);

bvn_float_free(f);
bvn_float_free(g);

width 0 uses the precision stored in the bvn_float_t itself. An invalid width (not 0, not 16, not a positive multiple of 32, or greater than BVN_FLOAT_MAX_PREC) causes the call to return false with error_illegal_value_type.

These functions supersede calling bvnr_write_bvnf / bvnr_write_bvnf_unit when base 16 output or widths greater than 128 are needed. bvnr_write_bvnf and bvnr_write_bvnf_unit remain available as convenience wrappers that always use base 10.


2.13 bvnr_write_bvni / bvnr_write_bvni_unit

bool bvnr_write_bvni(bvnr_writer_t *w, const char *key,
                      const bvn_int_t *n,
                      uint32_t width, uint32_t base);

bool bvnr_write_bvni_unit(bvnr_writer_t *w, const char *key,
                            const bvn_int_t *n,
                            uint32_t width, uint32_t base,
                            value_unit_t unit);

Write an arbitrary bit-width integer. The type family (uint or sint) is determined by the negative flag of the bvn_int_t: negative values produce <sint:W,...>, non-negative values produce <uint:W,...>. Any numeric base supported by the writer (2–62, 64, 85) may be specified.

The integer string is generated by bvn_int_to_str and then validated before reaching the sink. For non-decimal bases the string is emitted as a quoted token (token_is_string); for base 10 it is emitted as a bare number token (token_is_number).

#include "bvn_int.h"

bvn_int_t *n = bvn_int_alloc();

/* 128-bit unsigned max in decimal */
bvn_int_from_str(n, "340282366920938463463374607431768211455", 10);
bvnr_write_bvni(w, "u128max", n, 128u, 10u);

/* 256-bit value in hex — emitted as <uint:256,_16> "deadbeef..."; */
bvn_int_from_str(n, "deadbeefcafebabe0011223344556677"
                    "8899aabbccddeeff0011223344556677", 16);
bvnr_write_bvni(w, "wide_hex", n, 256u, 16u);

/* signed negative in hex */
bvn_int_from_str(n, "-7fffffff", 16);
bvnr_write_bvni(w, "neg_hex", n, 32u, 16u);

bvn_int_free(n);

width 0 defaults to 64 bits for range validation. A width that cannot hold the value causes the call to return false with error_value_out_of_range.


2.14 BVN_TYPE_FLOAT_BASE

#define BVN_TYPE_FLOAT_BASE(w, b)  /* value_type_spec_t */

Convenience macro that constructs a value_type_spec_t for a float with explicit width w and base b. The existing BVN_TYPE_FLOAT(w) macro always yields base 0 (equivalent to base 10); BVN_TYPE_FLOAT_BASE is needed when base 16 must be encoded in the type spec before being passed to bvnr_write_type_annotation or bvnr_write_event directly.

value_type_spec_t vt16 = BVN_TYPE_FLOAT_BASE(256u, 16u);
/* → { .family = vt_float, .width = 256, .base = 16 } */

value_type_spec_t vt10 = BVN_TYPE_FLOAT_BASE(64u, 10u);
/* equivalent to BVN_TYPE_FLOAT(64) */

2.15 Writer-side unit policy (bvnr_writer_set_unit_policy)

bool bvnr_writer_set_unit_policy(bvnr_writer_t *w, const bvnr_unit_policy_t *p);

The producing half of the contract in 1.12: refuse to emit a value a reader under the same policy would refuse to read.

This is the half the format's promise rests on. A reader can only reject a document somebody already wrote; only the writer can stop a bare number reaching a file in the first place, which is what "hand the file to anyone and they have everything required to interpret it" actually depends on.

static const char *lengths[] = { "m" };
bvnr_unit_policy_t p = {0};
p.require_unit             = true;    /* no bare numeric values */
p.require_dimension_of     = lengths; /* and every one of them a length */
p.num_require_dimension_of = 1;
bvnr_writer_set_unit_policy(w, &p);

bvnr_write_event (and every bvnr_write_* helper above it) then fails with error_unit_mismatch, latched into the writer like any other write error and readable with bvnr_writer_get_error. The unit checked is the one the value will carry in the output — whether it was given inline on the value or as a parameter of its type annotation, and a single annotation covers every element of the array under it.

Validation only. Only require_unit and require_dimension_of are accepted; a policy carrying targets, normalise, base or on_inexact is rejected (false) rather than half-honoured. The writer already has a value-rewriting mode — BVN_UNIT_REDUCE in bvnr_write_flags_t.unit_flags, which folds prefixes out and rescales the value exactly — and a second one arriving through a different door, with different rules about exactness, is how two features end up disagreeing about what a document says.

Like the reader's, the policy may be set before or after bvnr_open_write_*, survives re-opening the writer on another document, and is cleared with NULL.


3. Shared


3.1 bvnr_write_type_annotation

bool bvnr_write_type_annotation(bvnr_writer_t *w,
                                 value_type_spec_t vt,
                                 value_unit_t vu);

Emit a complete type-annotation event sequence (ev_type_annotation_start, ev_type_annotation_type_family, zero or more ev_type_annotation_type_family_parameter events, ev_type_annotation_end) in a single call. Returns false on any serialisation error.

This is the preferred way to write type annotations. Using bvnr_write_event directly for parameter events requires setting d.type to the appropriate token_type_t constant for each parameter; bvnr_write_type_annotation handles this correctly and automatically.

The function emits parameters as follows:

value_type_spec_t vt = BVN_TYPE_FLOAT(64);
value_unit_t      vu = BVN_UNIT_COMPOUND2(
                           bu_meter,  si_none, exp_linear,
                           bu_second, si_none, exp_neg_square);

/* Emits: <float:64,m/s²> */
if (!bvnr_write_type_annotation(w, vt, vu)) return false;

3.2 bvn_parse_unit / bvn_parse_unit_n

value_unit_t bvn_parse_unit  (const uint8_t *unit, bool *ok);
value_unit_t bvn_parse_unit_n(const uint8_t *unit, uint32_t len, bool *ok);

Parse a compound unit string (e.g. "k~g·m/s²") into a value_unit_t. Both set *ok to false and return a zeroed unit on any error.

bvn_parse_unit requires a NUL-terminated string. bvn_parse_unit_n accepts a length len and does not require a NUL terminator — use this variant when the unit string is a substring of a larger buffer (as is the case inside the parser itself).

This is useful when reading: after ev_type_annotation_type_family_parameter, the unit is already parsed for you in d->value_unit. You only need bvn_parse_unit / bvn_parse_unit_n if you are constructing a unit from a string yourself (e.g. from a config or CLI argument).

The validator also calls bvn_parse_unit_n internally when processing an inline unit suffix (the optional unit token that may follow a scalar value before its terminating ;). You do not need to call either function yourself to consume inline units; the parsed result is automatically placed in d->value_unit of the ev_data event, exactly as for annotation-specified units.

bool ok;
value_unit_t u = bvn_parse_unit((const uint8_t *)"k~g·m/s²", &ok);
if (!ok) {
    fprintf(stderr, "bad unit\n");
    return;
}
/* u now holds { num_components=3, [{bu_gram,exp_linear,si_kilo},
                                    {bu_meter,exp_linear,si_none},
                                    {bu_second,exp_neg_square,si_none}] } */

/* Length-bounded variant — no NUL needed */
const uint8_t *annotation = (const uint8_t *)"float:64,m/s";
value_unit_t u2 = bvn_parse_unit_n(annotation + 9, 3, &ok); /* "m/s" */

3.3 bvn_unit_to_string / bvn_unit_to_string_ex

int32_t bvn_unit_to_string(value_unit_t u, char *buf, size_t bufsize);

int32_t bvn_unit_to_string_ex(value_unit_t u, char *buf, size_t bufsize,
                               bvn_unit_flags_t flags);

Serialise a value_unit_t back into its canonical string form. Numerator components are joined by ·, followed by / and denominator components joined by ·. Returns bytes written (excluding NUL), or -1 on buffer overflow.

bvn_unit_to_string is equivalent to calling bvn_unit_to_string_ex with flags = BVN_UNIT_FLAGS_NONE.

bvn_unit_to_string_ex accepts a bvn_unit_flags_t bitmask that controls output format:

Flag Effect
BVN_UNIT_FLAGS_NONE Default: Unicode superscript exponents, no reduction
BVN_UNIT_REDUCE Reduce compound unit to canonical named SI unit before serialising
BVN_UNIT_ASCII_EXP Use ^N ASCII caret notation instead of Unicode superscripts

These flags can be OR-combined. The writer uses bvn_unit_to_string_ex internally, passing the flags from bvnr_writer_unit_flags(w).

BVN_UNIT_REDUCE here returns only the reduced UNIT. bvn_unit_to_string_ex discards the scale bvn_unit_reduce folded out, so k~g serialises as "g" — a string denoting a quantity 1000× smaller than the unit passed in. The writer above moves the value with it; nothing else does.

A direct caller must not reach for bvn_unit_reduce's scale to do it. That scale is 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, so k~N comes back "k~N" with nothing to rescale while bvn_unit_reduce still reports 1000 — and k~g comes back "g", where the 1000 must be applied. The two are indistinguishable from outside, both being a lone unit with a kilo prefix, so the scale has to come from the unit that is actually emitted: format with this function, parse the result back, and convert from the original to that. The unit-system reference §12.2 gives the eight lines, which are the ones bvn_ser_reduced_number runs. The collapse never substitutes one named unit for another: Sv stays Sv, Bq stays Bq. A lone base at an exponent other than 1 still collapses, though — s⁻¹ becomes Hz and m~s⁻¹ becomes k~Hz. And a reduction that overflows (a summed exponent outside ±100, too many surviving bases, or a scale out of float range) returns -1: it drops a component, so what it produces is a different unit rather than a shorter spelling of the same one.

Note on writer usage: When driving the writer manually via bvnr_write_event, do not pass a unit string in bvnr_data_t.data for the ev_type_annotation_start event — the serialiser ignores that field and derives the annotation from data->value_type and the subsequent parameter events. Use bvnr_write_type_annotation (§3.1) to emit a complete, correct type annotation in one call.

value_unit_t u = BVN_UNIT_COMPOUND2(
    bu_gram,   si_kilo,  exp_linear,
    bu_second, si_none,  exp_neg_square);

char buf[64];
int32_t n = bvn_unit_to_string(u, buf, sizeof(buf));
/* buf = "k~g/s²", n = 7 */

n = bvn_unit_to_string_ex(u, buf, sizeof(buf), BVN_UNIT_ASCII_EXP);
/* buf = "k~g/s^2", n = 7 */

3.4 bvn_unit_convert_value (bovnar_si_units.h)

bool bvn_unit_convert_value(double value, value_unit_t from,
                            value_unit_t to, double *out);

Convert one numeric quantity from unit from into unit to, writing the result to *out. Handles both the simple multiplicative case (5 k~m → 5000 m) and the affine case (25 °C → 298.15 K), routing the latter through SI base units. This is the same routine the reader's want_unit hook (§1.10) uses, and the C equivalent of the Python convert_value.

Returns false — leaving *out untouched — when the two units are dimensionally incompatible or have no SI mapping. That boolean is the "validly convert only" guard: the reader turns a false here into error_unit_mismatch.

double m;
if (bvn_unit_convert_value(5.0,
        bvn_parse_unit((const uint8_t *)"k~m", &ok),
        bvn_parse_unit((const uint8_t *)"m",   &ok), &m))
    printf("%.0f m\n", m);          /* 5000 m */

For the standalone factor (without applying it) or to detect the affine case, see bvn_unit_convert_factor in bovnar_si_units.h.

bvn_unit_convert_value works in double, so it is lossy for wide values. For a lossless conversion — the engine behind the reader's want_unit hook (§1.10) — use the exact-rational pair, also in bovnar_si_units.h:

bool    bvn_unit_convert_rational(const bvn_int_t *vnum, const bvn_int_t *vden,
                                  value_unit_t from, value_unit_t to,
                                  bvn_int_t *out_num, bvn_int_t *out_den,
                                  bool *exact);
int32_t bvn_rational_to_str(const bvn_int_t *num, const bvn_int_t *den,
                            uint32_t base, char *buf, size_t bufsize, bool *exact);
size_t  bvn_rational_str_bufsize(const bvn_int_t *num, const bvn_int_t *den,
                                 uint32_t base);
bool    bvn_rational_base_valid(uint32_t base);   /* static inline */

bvn_unit_convert_rational converts the exact rational vnum/vden (parse a wire value into one with bvn_float_parse_rational for floats, or bvn_int_from_str for integers) from from into to, writing the exact reduced result to out_num/out_den. It returns false for dimensionally incompatible units, and sets *exact = false when the true factor is irrational (π-based angles) — the result is then only an approximation and a lossless consumer must reject it. bvn_rational_to_str renders an exact rational in any base bvnr can write — 2..62 plus 64 and 85 (bvn_rational_base_valid) — setting *exact = true and writing the full expansion when it terminates. It distinguishes two failures:

Return Meaning
BVN_RATIONAL_NONTERMINATING (-2) the expansion is infinite in this base. The rational itself is still exact, so use num/den.
-1 bad arguments, an unsupported base, a negative value in the sign-less bases 64/85, out of memory, or a bufsize too small.

The buffer is never truncated — half an exact expansion is simply a different number — so size it with bvn_rational_str_bufsize, which upper-bounds the result from the operands' bit lengths.

Both handle any value width — a 1056-bit float, a 512-bit integer — with no precision loss. The exactness they promise is bounded by the unit table's own declared factors: every non-irrational unit carries an exact rational to_si factor (see src/gendata/units.bvnr), and gen_units.py refuses to generate a table in which a rounded decimal is passed off as exact.


3.5 bvn_error_to_string

const char *bvn_error_to_string(error_code_t code);

Return a short, static, human-readable description of an error code. The returned pointer is valid for the lifetime of the program; do not free it.

fprintf(stderr, "error: %s\n", bvn_error_to_string(bvnr_reader_get_error(r)));
/* e.g. "error: value_out_of_range" */

Unit-related error codes (for reference):

Code Value String Trigger
error_unit_illegal 32 "unit_illegal" Unparseable unit string (unknown base, bad prefix, empty component, >8 components)
error_unit_too_long 22 "unit_too_long" An inline unit suffix exceeds its 255-byte lexer buffer; a unit in an annotation raises error_type_too_long instead
error_unit_mismatch 38 "unit_mismatch" Inline unit suffix present, type-annotation unit also present, and the two differ; or a want_unit target dimensionally incompatible with the value's unit (§1.10)
error_unit_inexact 47 "unit_inexact" A want_unit conversion could not be delivered exactly: irrational factor, or a non-terminating expansion in the output base without want_unit_allow_nonterminating (§1.10)
error_unit_profile_unknown 49 "unit_profile_unknown" A unit written in the name: profile notation names a profile this build does not have (profile under implementation)
error_unit_profile_unsupported 50 "unit_profile_unsupported" A valid profile expression over known atoms with no representation in the unit model — a special unit carrying a reference level, a scale factor outside the SI prefix decades, or more components than a unit may hold (profile under implementation)
error_octet_stream_forbidden 51 "octet_stream_forbidden" Under implementation — not in a released version. The document contains an octet stream and the reader was opened with text_only. Reported at the stream's opening 0x00, before its payload is read
error_type_param_whitespace 52 "type_param_whitespace" Whitespace split a type-annotation parameter in two (spec §5.3). Legal beside a separator — the family :, a , between parameters, before the closing > — and an error inside a parameter, reported at the first byte after it. <float:64,k g> used to be accepted as k~g and <uint:6 4> as a 64-bit width

3.6 Canonicalising observer (bvnr_canon_observer_*)

An adapter that lets a reader's event stream drive the serializer directly, without constructing a bvnr_writer_t. Installed as a reader callback it re-emits every event to a sink, producing a canonical — or pretty-printed — copy of the input. This is what the command-line pretty-printer and canonicaliser are built on.

typedef struct bvnr_canon_observer_s bvnr_canon_observer_t;

bvnr_canon_observer_t *bvnr_canon_observer_create(const bvnr_sink_t *sink, bool pretty);
void bvnr_canon_observer_set_version(bvnr_canon_observer_t *obs,
                                     uint16_t major, uint16_t minor);
bool bvnr_canon_observer_on_event(void *obs, bvnr_event_t ev, bvnr_data_t *data);
bool bvnr_canon_observer_finish(bvnr_canon_observer_t *obs);
void bvnr_canon_observer_destroy(bvnr_canon_observer_t *obs);

pretty selects the indented form; false emits the compact canonical form. bvnr_canon_observer_create returns NULL if the sink is missing or has no push function.

bvnr_canon_observer_on_event has the signature of a reader callback — void * first — so the observer handle can be passed as userdata and the function used as on_verified directly. Call bvnr_canon_observer_finish after bvnr_read returns to flush the trailing state, then bvnr_canon_observer_destroy.

It does not re-validate. The events already came from a validating reader, so the observer reproduces faithfully what it is fed rather than checking it a second time. For the same reason its array-nesting cap is set to the maximum: the reader has already enforced its own limit, and a second, stricter cap here would reject a document the reader accepted.

Version directives. A canonical copy of a spec-1.1 document needs the directive its constructs require in order to re-read. bvnr_canon_observer_set_version records one to prepend; it is emitted lazily, just before the first event. Call it before any event is fed — typically from the reader's callback once bvnr_reader_get_declared_version resolves (§1.8). A version of 0.0, or a call made after output has begun, is ignored.

bvnr_sink_t sink;
bvnr_sink_to_fd(&sink, STDOUT_FILENO);

bvnr_canon_observer_t *canon = bvnr_canon_observer_create(&sink, /*pretty=*/true);

bvnr_read_flags_t flags = {0};
flags.userdata    = canon;
flags.on_verified = bvnr_canon_observer_on_event;

bvnr_open_read_source(r, &src, NULL, &flags);
bvnr_read(r);
bvnr_canon_observer_finish(canon);
bvnr_canon_observer_destroy(canon);

3.7 Unit inspection helpers

Three more entry points from bovnar_si_units.h. The Python bindings expose all of them — see Extended unit functions — and they are listed here so the C side is not the poor relation.

bool    bvn_unit_dimension_vector(value_unit_t u, int32_t dims[bvn_si_dim_count]);
bool    bvn_prefix_unit_valid(value_unit_prefix_t prefix, value_base_unit_t base);
int32_t bvn_exponent_to_int(unit_exponent_t e);
unit_exponent_t bvn_int_to_exponent(int32_t n);

bvn_unit_dimension_vector fills the seven SI exponents, indexed by bvn_si_dim_idx_tbvn_si_dim_meter, _kilogram, _second, _ampere, _kelvin, _mol, _candela, with bvn_si_dim_count (7) as the array length. Two units with the same vector are the same physical quantity however they were spelled:

m/s          ok=1  [ 1, 0, -1, 0, 0, 0, 0]
k~g·m/s²     ok=1  [ 1, 1, -2, 0, 0, 0, 0]
N            ok=1  [ 1, 1, -2, 0, 0, 0, 0]      same vector as the compound above
%            ok=1  [ 0, 0,  0, 0, 0, 0, 0]      dimensionless, but it HAS a vector
$USD         ok=0                               currencies carry no dimension

The false return for a currency is the same fact §4.1 of the unit policy reference records: a currency has no dimension vector, so a screen built on dimensions alone declines conversions the engine can actually perform.

bvn_prefix_unit_valid answers whether a prefix may sit on a base unit — the IEC prefixes belong to bit and byte only:

value_unit_prefix_t p = { .system = prefix_iec, .id.iec = iec_gibi };
bvn_prefix_unit_valid(p, bu_byte);    /* true  */
bvn_prefix_unit_valid(p, bu_meter);   /* false */

bvn_exponent_to_int / bvn_int_to_exponent convert between the unit_exponent_t enum and a plain integer: bvn_exponent_to_int(exp_neg_square) is -2, and bvn_int_to_exponent(-2) is exp_neg_square.


4. DOM API (bovnar_dom.h)

The SAX reader above streams events. When you instead want the whole document in memory for random-access queries — without writing a callback — use the DOM API in include/bovnar_dom.h. It parses a document into a tree of bvn_dom_node_t owned by a bvn_dom_doc_t, which you navigate and read with typed accessors.

4.1 Parsing and lifetime

bvn_dom_doc_t *bvn_dom_doc_create(void);
void           bvn_dom_doc_destroy(bvn_dom_doc_t *doc);   /* frees the whole tree; NULL-safe */
bvn_dom_doc_t *bvn_dom_parse(const void *data, uint32_t len);
bvn_dom_doc_t *bvn_dom_parse_fd(int fd);
bvn_dom_doc_t *bvn_dom_parse_fd_ex(int fd, uint64_t max_bytes);
error_code_t   bvn_dom_doc_get_parse_error(const bvn_dom_doc_t *doc);

bvn_dom_parse returns NULL only on allocation failure — a malformed document still returns a non-NULL doc, so check bvn_dom_doc_get_parse_error() (error_none means it parsed cleanly), not the pointer, to detect a parse error. The bvn_dom_parse_fd* variants instead return NULL on any failure (allocation, I/O, or exceeding the size cap). bvn_dom_parse_fd_ex caps the accumulated input at max_bytes, but only downward: 0 or any value above the built-in hard cap BVN_DOM_FD_MAX_BYTES (256 MiB) is clamped to that cap — there is no unlimited mode. Free every returned doc with bvn_dom_doc_destroy.

4.2 Navigation

bvn_dom_node_t *bvn_dom_lookup(const bvn_dom_doc_t *doc, const char *path); /* dot path, e.g. "server.tls.cert" */
bvn_dom_node_t *bvn_dom_struct_get(const bvn_dom_node_t *node, const char *key);
bvn_dom_node_t *bvn_dom_array_at(const bvn_dom_node_t *node, uint32_t index);
uint32_t        bvn_dom_struct_count(const bvn_dom_node_t *node);
uint32_t        bvn_dom_array_count(const bvn_dom_node_t *node);
uint32_t        bvn_dom_array_dims(const bvn_dom_node_t *node);   /* number of `/`-separated dimensions */
const bvn_dom_entry_t *bvn_dom_struct_entries(const bvn_dom_node_t *node);
const bvn_dom_entry_t *bvn_dom_doc_entries(const bvn_dom_doc_t *doc);
uint32_t               bvn_dom_doc_count(const bvn_dom_doc_t *doc);

Each bvn_dom_entry_t is { char *key; bvn_dom_node_t *value; }. A missing key or out-of-range index returns NULL.

4.3 Type inspection

typedef enum bvn_dom_type_e {
    BVN_DOM_NULL, BVN_DOM_INT, BVN_DOM_FLOAT, BVN_DOM_STRING, BVN_DOM_SYMBOL,
    BVN_DOM_REFERENCE, BVN_DOM_STRUCT, BVN_DOM_ARRAY, BVN_DOM_OCTET_STREAM, BVN_DOM_BOOL
} bvn_dom_type_t;

bvn_dom_type_t    bvn_dom_node_type(const bvn_dom_node_t *node);
bool              bvn_dom_is_null(const bvn_dom_node_t *node);
value_type_spec_t bvn_dom_get_value_type(const bvn_dom_node_t *node);
value_unit_t      bvn_dom_get_unit(const bvn_dom_node_t *node);
int32_t           bvn_dom_get_unit_string(const bvn_dom_node_t *node, char *buf, size_t bufsize);
double            bvn_dom_get_value_in_base_units(const bvn_dom_node_t *node);

bvn_dom_get_value_in_base_units scales a numeric node into coherent SI — 1.5 k~m1500.0, and for an affine scale it applies the offset too, so 25 °C298.15. Two caveats it cannot signal:

4.4 Typed value accessors

Each accessor returns false (leaving the out-param unchanged — no clamping or truncation) when the node is NULL, not of the requested kind, or does not fit the target type. Pointer results are borrowed — valid only until the owning document is destroyed; do not free them.

bool bvn_dom_get_bool  (const bvn_dom_node_t *node, bool   *out);
bool bvn_dom_get_float (const bvn_dom_node_t *node, double *out);
bool bvn_dom_get_i64/u64/i32/u32/i16/u16/i8/u8(const bvn_dom_node_t *node, /* int type */ *out);
bool bvn_dom_get_string   (const bvn_dom_node_t *node, const char    **out, uint32_t *len); /* NUL-terminated; *len excludes NUL */
bool bvn_dom_get_symbol   (const bvn_dom_node_t *node, const char    **out, uint32_t *len);
bool bvn_dom_get_reference(const bvn_dom_node_t *node, const char    **out, uint32_t *len);
bool bvn_dom_get_octets   (const bvn_dom_node_t *node, const uint8_t **out, uint32_t *len); /* raw bytes, NOT NUL-terminated */

Wide integers (> 64 bits) are not stored inline; read them as a borrowed bigint or render them to a string:

const bvn_int_t *bvn_dom_get_bigint(const bvn_dom_node_t *node); /* NULL unless the int is wider than 64 bits */
char            *bvn_dom_int_to_str(const bvn_dom_node_t *node, uint32_t base); /* caller owns; free with bvn_dom_free_string */
void             bvn_dom_free_string(char *s);

For a datetime node written as a literal with a fractional second (spec 1.1), the verbatim sub-second digits are available separately; the node's integer value is still the whole-second epoch count read via bvn_dom_get_i64:

const char *bvn_dom_get_datetime_fraction(const bvn_dom_node_t *node, uint32_t *len_out);

4.5 Building a tree

The DOM is also writable, e.g. to construct a document programmatically and hand it to a serialiser. bvn_dom_node_alloc / the bvn_dom_node_from_* constructors make nodes; the *_add/*_append functions attach them.

bvn_dom_node_t *bvn_dom_node_alloc(bvn_dom_type_t t);
void            bvn_dom_node_destroy(bvn_dom_node_t *n);
bvn_dom_node_t *bvn_dom_node_from_i64/u64/i32/u32/i16/u16/i8/u8(/* value */);
bvn_dom_node_t *bvn_dom_node_from_bigint(bvn_int_t *bigint, value_type_spec_t vt, value_unit_t vu);
bool bvn_dom_struct_add   (bvn_dom_node_t *s, const char *key, uint32_t klen, bvn_dom_node_t *val);
bool bvn_dom_doc_add      (bvn_dom_doc_t  *d, const char *key, uint32_t klen, bvn_dom_node_t *val);
bool bvn_dom_array_append (bvn_dom_node_t *a, bvn_dom_node_t *elem);
char *bvn_dom_strdup(const char *s, uint32_t len);

Ownership. bvn_dom_struct_add / bvn_dom_doc_add / bvn_dom_array_append always take ownership of the value node: on success the container owns it, and on every failure path the node is destroyed internally — so never destroy it yourself after the call (doing so double-frees). bvn_dom_node_from_bigint is the one exception with asymmetric ownership: on success it takes ownership of the bvn_int_t; on failure (NULL return) it does not, and you still own it.

4.6 Minimal example

bvn_dom_doc_t *doc = bvn_dom_parse(buf, (uint32_t)len);
if (!doc) { /* out of memory */ }
if (bvn_dom_doc_get_parse_error(doc) != error_none) {
    /* malformed input — inspect the code */
} else {
    bvn_dom_node_t *port = bvn_dom_lookup(doc, "server.port");
    uint16_t p;
    if (port && bvn_dom_get_u16(port, &p)) { /* use p */ }
}
bvn_dom_doc_destroy(doc);

4.7 Parsing under a unit policy

bvn_dom_doc_t *bvn_dom_parse_policy(const void *data, uint32_t len,
                                    const bvnr_unit_policy_t *p);
bvn_dom_doc_t *bvn_dom_parse_fd_policy(int fd, uint64_t max_bytes,
                                       const bvnr_unit_policy_t *p);

The DOM takes the same policy the streaming reader does (1.12), so a random-access consumer can assert what it expects to find and store the unit it wants back. NULL behaves exactly like the plain forms.

A validation failure lands in bvn_dom_doc_get_parse_error() as error_unit_mismatch, like any other parse error. A policy the library refuses — a malformed unit, a rule path naming nothing — is error_invalid_argument, so a mistake in the policy is never mistaken for a fault in the document.

A value the policy converted is stored converted: its digits, its unit and its base are the conversion's, because a caller who asked for metres and got the document's feet back would have no way to notice. An integer that converts to a fraction — 5 g in kilograms is 0.005 — is stored as a float, since that is what it now is.

static const bvnr_unit_rule_t rules[] = {
    { ".inlet.temperature", "°C", 0, bvnr_rule_convert },
};
bvnr_unit_policy_t p = {0};
p.rules = rules; p.num_rules = 1;

bvn_dom_doc_t *doc = bvn_dom_parse_policy(buf, len, &p);
bvn_dom_node_t *n  = bvn_dom_lookup(doc, ".inlet.temperature");
double v; bvn_dom_get_float(n, &v);      /* 100.0, from a document in °F */

This is also what bovnar query uses, so the command line has the same reach:

bovnar query --field .inlet.temperature=°C .inlet.temperature sensors.bvnr
bovnar query --require-unit .inlet.flow sensors.bvnr

5. Complete Read Example

#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "bovnar.h"

typedef struct { char key[256]; } ctx_t;

static bool on_verified(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    ctx_t *ctx = ud;

    if (ev == ev_assignment_start) {
        size_t n = d->length < 255 ? d->length : 255;
        memcpy(ctx->key, d->data, n);
        ctx->key[n] = '\0';
        return true;
    }

    if (ev != ev_data || d->length == 0) return true;

    char vbuf[256];
    size_t n = d->length < 255 ? d->length : 255;
    memcpy(vbuf, d->data, n);
    vbuf[n] = '\0';

    switch (d->value_type.family) {
    case vt_uint: {
        uint64_t v;
        bvn_parse_uint64(vbuf, d->value_type, &v);
        printf(".%-20s = %" PRIu64 "  (uint%u)\n",
               ctx->key, v, bvn_effective_width(d->value_type));
        break;
    }
    case vt_float: {
        double v;
        bvn_parse_double(vbuf, d->value_type, &v);
        printf(".%-20s = %g  (float%u)\n",
               ctx->key, v, bvn_effective_width(d->value_type));
        break;
    }
    case vt_utf8:
        printf(".%-20s = \"%s\"\n", ctx->key, vbuf);
        break;
    default:
        printf(".%-20s = %s\n", ctx->key, vbuf);
        break;
    }
    return true;
}

int main(int argc, char **argv)
{
    if (argc < 2) { fprintf(stderr, "usage: %s <file>\n", argv[0]); return 1; }

    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) { perror(argv[1]); return 1; }

    bvnr_reader_t *r = bvnr_reader_create();
    ctx_t ctx = {0};

    bvnr_source_t src;
    bvnr_source_from_fd(&src, fd);

    bvnr_read_flags_t opts = {
        .on_verified   = on_verified,
        .userdata      = &ctx,
        .max_file_size = 16777216,
    };

    if (!bvnr_open_read_source(r, &src, NULL, &opts)) {
        fputs("failed to open reader\n", stderr);
        bvnr_reader_destroy(r);
        close(fd);
        return 1;
    }

    int ret = 0;
    if (!bvnr_read(r)) {
        fprintf(stderr, "%s at line %" PRIu64 " col %" PRIu64 "\n",
                bvn_error_to_string(bvnr_reader_get_error(r)),
                bvnr_reader_get_error_line(r),
                bvnr_reader_get_error_column(r));
        ret = 1;
    }

    bvnr_reader_destroy(r);
    close(fd);
    return ret;
}

6. Inline Unit Suffix — Reading

A scalar value may carry an inline unit suffix directly after the literal, before its terminating ;:

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

From the application's perspective, inline units and annotation units are transparent: both end up in d->value_unit of the ev_data event. No special handling is needed.

The only behavioral difference occurs when both are present and disagree: the validator raises error_unit_mismatch (38) and parsing fails:

.bad = <float:64,m> 1.0 s;    /* annotation says m, inline says s → error */

Inline unit suffixes are illegal inside array elements. The lexer rejects them with error_unexpected_input_byte.

6.1 Reading inline unit values

/* The callback below works identically whether the unit came from a
 * type annotation or from an inline suffix — no change needed.        */
static bool on_verified(void *ud, bvnr_event_t ev, bvnr_data_t *d)
{
    if (ev != ev_data) return true;

    char unit_str[128] = "no_unit";
    /* dimensionless: num_components==0 (BVN_UNIT_NONE) or
       num_components==1 with base==bu_none (BVN_UNIT_NO_PREFIX(bu_none)) */
    bool is_dim = (d->value_unit.num_components == 0) ||
                  (d->value_unit.num_components == 1 &&
                   d->value_unit.components[0].base == bu_none);
    if (!is_dim)
        bvn_unit_to_string(d->value_unit, unit_str, sizeof(unit_str));

    char val[256];
    size_t n = d->length < 255 ? d->length : 255;
    memcpy(val, d->data, n);
    val[n] = '\0';

    printf("value=%s  unit=%s\n", val, unit_str);
    return true;
}

7. Complete Write Example

#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "bovnar.h"

/* Emit: .velocity = <float:64,m/s²> 9.81; */
static bool write_velocity(bvnr_writer_t *w)
{
    value_type_spec_t vt = BVN_TYPE_FLOAT(64);
    value_unit_t      vu = BVN_UNIT_COMPOUND2(
                               bu_meter,  si_none, exp_linear,
                               bu_second, si_none, exp_neg_square);

    char valbuf[32];
    bvn_format_double(valbuf, sizeof(valbuf), 9.81, vt);

    bvnr_data_t d;

    d = (bvnr_data_t){ .data = "velocity", .length = 8 };
    if (!bvnr_write_event(w, ev_assignment_start, &d)) return false;

    if (!bvnr_write_type_annotation(w, vt, vu)) return false;

    d = (bvnr_data_t){
        .type       = token_is_number,
        .value_type = vt,
        .value_unit = vu,
        .data       = valbuf,
        .length     = (uint32_t)strlen(valbuf),
    };
    return bvnr_write_event(w, ev_data, &d);
}

int main(void)
{
    bvnr_writer_t *w = bvnr_writer_create();

    bvnr_sink_t sink;
    bvnr_sink_to_fd(&sink, STDOUT_FILENO);

    bvnr_write_flags_t opts = { 0 };
    if (!bvnr_open_write_sink(w, &sink, /*pretty=*/true, &opts)) {
        fputs("failed to open writer\n", stderr);
        bvnr_writer_destroy(w);
        return 1;
    }

    int ret = 0;
    if (!write_velocity(w) || !bvnr_write_finish(w)) {
        fprintf(stderr, "write error: %s\n",
                bvn_error_to_string(bvnr_writer_get_error(w)));
        ret = 1;
    }

    bvnr_writer_destroy(w);
    return ret;
}
/* Output: .velocity = <float:64,m/s²> 9.81; */

See also


End of Bovnar — Read & Write API (Bovnar spec 1.1).