(* ================================================================== *)
(* BOVNAR – EBNF derived from parser/lexer/validator implementation *)
(* Version 1.1 Notation: ISO/IEC 14977:1996 *)
(* { } = zero or more [ ] = optional *)
(* ( ) = grouping | = alternation *)
(* , = concatenation ; = rule terminator *)
(* "…" = literal terminal (*…*) = comment *)
(* Updated: 2026-06-17 — drop the residual "1.1 (draft)" label (spec *)
(* 1.1 is released); note datetime also accepts a width parameter; *)
(* reframe base-unit as a lookup into the full 163-unit / 491-spelling *)
(* table (NOT the closed SI subset it previously claimed). *)
(* 2026-06-16 — consistency pass vs the implementation: *)
(* allow ws/comments before the assignment ";" and the "/" array *)
(* separator; move the inline unit to a value-only scalar-with-unit *)
(* rule so array elements are correctly excluded; bound \u{…} to 1–6 *)
(* hex digits and version-int to no-leading-zero; inline type-spec; *)
(* split the array-homogeneity / duplicate-key constraints into the *)
(* LEXER/VALIDATOR tier (the streaming reader) vs the DOM-BUILDER *)
(* tier (only a DOM parse rejects ragged siblings, mixed kinds, and *)
(* duplicate keys; the streaming reader accepts them). *)
(* 2026-06-15 — spec 1.1 (additive over the frozen 1.0): *)
(* version directive (#!bovnar M.N), datetime type family, *)
(* \xHH / \u{…} string escapes, reference array indexing. *)
(* Earlier (2026-05-29): null/true/false/on/off keywords, bool *)
(* type family, special numbers respelled nan / inf / ninf *)
(* ================================================================== *)
(* ───────────────────────────────────────────────────────────────── *)
(* 1. Top-level stream *)
(* ───────────────────────────────────────────────────────────────── *)
(* Valid end-of-stream states (lexer states where EOF is accepted): *)
(* undefined – nothing has been read yet (empty stream) *)
(* first_comment_intro – inside a leading comment that runs to EOF *)
(* with no trailing newline (comment-only stream) *)
(* first_bom – BOM or first-line comment (its newline seen), *)
(* no assignment yet *)
(* value_outro – the semicolon of the last assignment consumed *)
(* comment_intro / ignore_comment_byte – a trailing comment *)
(* opened after the last ";", ended by EOF *)
(* In all these cases struct_nesting_level must be zero. *)
(* Events emitted to the validator callbacks (bvnr_event_t): *)
(* *)
(* ev_stream_start – once at the very start of bvn_lex_run *)
(* ev_stream_end – once at the very end of bvn_lex_run, *)
(* after the last value's terminator is consumed *)
(* ev_assignment_start – on the identifier token of an assignment *)
(* ev_struct_start – on "{" *)
(* ev_struct_end – on "}" *)
(* ev_array_row_start – on "[" opening a bracket row *)
(* ev_array_row_end – on "]" closing a bracket row *)
(* ev_array_dim_start – on "/" between rows; fired immediately *)
(* when "/" is consumed, before the next *)
(* "[" opens the next row *)
(* ev_octet_stream_start – on the NUL byte that enters binary mode *)
(* ev_octet_stream_end – on the 0x00 tag ending an octet stream *)
(* *)
(* For every typed value (including values whose type annotation is *)
(* synthesised by the validator – see §4) the following sequence *)
(* is emitted before ev_data: *)
(* *)
(* ev_type_annotation_start – once, before params *)
(* ev_type_annotation_type_family – the family name *)
(* ev_type_annotation_type_family_parameter – zero to three *)
(* times, once per present parameter (width, *)
(* base or q, unit), in that order *)
(* ev_type_annotation_end – once, after params *)
(* ev_data – the value token *)
(* *)
(* ev_type_annotation_start is emitted to on_unverified first *)
(* (raw bytes only), then to on_verified (with value_type and *)
(* value_unit filled in). All other type-annotation events and *)
(* ev_data are emitted to both callbacks simultaneously. *)
(* For bool keywords (true/false/on/off) the same default type- *)
(* annotation sequence is emitted, synthesising <bool> when no *)
(* explicit annotation was given (no width/base/unit params). *)
(* *)
(* ev_data is also emitted for null values, symbols, references, *)
(* and octet-stream chunks without a preceding type-annotation *)
(* sequence. Struct values do NOT produce ev_data; they are *)
(* signaled by ev_struct_start and ev_struct_end. *)
(* Only blank characters — NOT comments — may precede the version *)
(* directive: it is recognised solely as the very first comment, so an *)
(* earlier comment would demote it to an ordinary one (see below). *)
stream = [utf8-bom] , {ws-char} , [version-directive] , ws ,
{assignment , ws} ;
(* UTF-8 BOM: legal only as the very first three bytes of the stream. *)
utf8-bom = "\xEF\xBB\xBF" ;
(* Version directive (spec 1.1): an OPTIONAL leading comment of the *)
(* form "#!bovnar <major>.<minor>" declaring the spec version the *)
(* document targets. It is recognised only as the very first comment *)
(* (after the optional BOM and whitespace); a "#!bovnar …" on any *)
(* later line is an ordinary comment. Lexically it IS a comment, so a *)
(* spec-1.0 reader skips it transparently — the declaration is purely *)
(* additive. A 1.1+ reader parses it, exposes the version, and (in *)
(* strict mode) rejects a version it does not support. Each component *)
(* is a decimal integer with no leading zero (a bare "0" excepted). *)
(* A malformed version after the "!bovnar" prefix is *)
(* error_invalid_spec_version. *)
(* The directive accepts ONLY horizontal whitespace (HT or SP) around *)
(* its components — not the LF/CR/VT/FF that "ws-char" also covers. *)
version-directive = "#" , "!bovnar" , hspace , {hspace} ,
version-int , "." , version-int ,
{hspace} , ("\x0D" | "\x0A" | (* eof *)) ;
hspace = "\x09" (* HT *) | "\x20" (* SP *) ;
(* version-int: a decimal integer with no leading zero (a bare "0" is the *)
(* only form starting with "0"); each component must fit in a uint16. *)
version-int = "0"
| ( ("1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9") , {DIGIT} ) ;
(* Whitespace: blank characters and comments, freely intermixed. *)
ws = {ws-char | comment} ;
(* At least one whitespace character, followed by optional further *)
(* whitespace and comments. Used as the mandatory separator before *)
(* an inline unit suffix. *)
ws-mandatory = ws-char , {ws-char | comment} ;
ws-char = "\x09" (* HT *)
| "\x0A" (* LF *)
| "\x0B" (* VT *)
| "\x0C" (* FF *)
| "\x0D" (* CR *)
| "\x20" ; (* SP *)
(* Comment: "#" through the next LF, CR, or end of input. *)
(* Control bytes 0x00–0x08, 0x0E–0x1F, and 0x7F are hard errors *)
(* inside a comment body. *)
comment = "#" , {comment-char} , ("\x0D" | "\x0A" | (* eof *)) ;
comment-char = "\x09" (* HT *)
| "\x0B" | "\x0C" (* VT, FF *)
| printable-byte (* 0x20–0x7E *)
| high-byte ; (* 0x80–0xFF *)
(* ───────────────────────────────────────────────────────────────── *)
(* 2. Assignments *)
(* ───────────────────────────────────────────────────────────────── *)
(* The leading "." is a syntactic sigil; it is not stored in the key. *)
(* At least one id-start character after "." is required; *)
(* ".=" is error_empty_identifier. *)
(* Comments are permitted between the last key character and "=". *)
assignment = "." , key , ws , "=" , ws , value , ws , ";" ;
key = id-start , {id-body-char} ;
(* First character of an identifier, symbol, or reference segment: *)
(* A–Z, a–z, "_", and UTF-8 leader bytes 0xC3–0xF4. *)
(* 0xC2 is explicitly rejected (ACT_NONE) at all token-start *)
(* positions, so code points U+0080–U+00BF cannot begin these tokens.*)
id-start = ALPHA | "_" | utf8-leader ;
(* Body characters of an identifier: *)
(* Accepted: "+", "-", digits, alpha, "_", *)
(* UTF-8 leader bytes 0xC3–0xF4, UTF-8 continuation bytes 0x80–0xBF.*)
(* Hard-error bytes: 0x00–0x08, 0x0E–0x1F, 0x7F, 0xC2, punct: *)
(* " # , . / ; < > [ ] { } = ! $ % & ' ( ) * : ? @ \ ^ ` | ~ *)
(* "=" terminates the body (ACT_value_intro). *)
(* Whitespace terminates the body (ACT_to_identifier_outro), after *)
(* which only "=" is accepted (with comments permitted between). *)
id-body-char = "+" | "-"
| DIGIT
| ALPHA
| "_"
| utf8-leader (* 0xC3–0xF4 *)
| utf8-continuation ; (* 0x80–0xBF *)
(* ───────────────────────────────────────────────────────────────── *)
(* 3. Values *)
(* ───────────────────────────────────────────────────────────────── *)
(* A value is the right-hand side of an assignment or an element *)
(* inside an array row; the optional type annotation applies in both *)
(* contexts. *)
(* *)
(* Typed null elements: in array context, a type annotation not *)
(* followed by any raw value token (i.e. the next byte is "," "]" *)
(* or ";") is a null value carrying the annotated type. This is *)
(* handled by ACT_type_null_then_new_array_value, *)
(* ACT_type_null_then_array_outro, and ACT_type_null_then_value_outro *)
(* from the type_outro state. *)
value = [type-annotation , ws] , ( scalar-with-unit | raw-value ) ;
(* A bare number or string scalar may carry an inline unit suffix — but ONLY *)
(* at value position, never inside an array element (array-elem uses raw- *)
(* value, which has no unit option). The lexer reaches the inline-unit state *)
(* only from number_outro / string_outro, so no other value form takes one. *)
scalar-with-unit = ( number | string ) , ws-mandatory , inline-unit ;
raw-value = null-value
| bool-value
| special-number
| datetime-literal (* spec 1.1 *)
| number
| string
| symbol
| reference
| array
| struct
| octet-stream ;
(* datetime-literal (spec 1.1): an ISO-8601 UTC timestamp accepted in *)
(* place of the integer epoch-seconds carrier. The lexer accumulates the *)
(* shape (dtlit_* states, entered from a digit run followed by "-"); the *)
(* validator strictly range-checks the fields and converts the UTC civil *)
(* instant to the integer carrier on the declared epoch (unix when a bare *)
(* literal infers <datetime:64,unix>). Whole-second carrier: a frac- *)
(* tional part (any digit count) does not change the value but is kept *)
(* verbatim and re-emitted so the literal round-trips. A "+HH:MM" / *)
(* "-HH:MM" offset is folded to true UTC before conversion. *)
(* The atomic GNSS epochs (gps/galileo/glonass/beidou) reject a literal. *)
datetime-literal = year , "-" , month , "-" , day ,
[ "T" , hour , ":" , minute , ":" , second ,
[ "." , dt-fraction ] , [ dt-zone ] ] ;
dt-fraction = digit , { digit } ; (* kept verbatim; carrier is whole seconds *)
dt-zone = "Z" | ( "+" | "-" ) , hour , ":" , minute ; (* tz offset *)
year = digit , digit , digit , digit ;
month = digit , digit ; (* 01..12 *)
day = digit , digit ; (* 01..28/29/30/31 per month *)
hour = digit , digit ; (* 00..23 *)
minute = digit , digit ; (* 00..59 *)
second = digit , digit ; (* 00..60 (60 = UTC leap second; on the *)
(* tai epoch it is its own instant, on *)
(* the civil ones it folds onto the *)
(* following second -- see the spec) *)
(* null-value: either the reserved keyword "null" or the empty *)
(* production. *)
(* At assignment level: ".foo = ;" and ".foo = null;" are equivalent. *)
(* In array context: a leading or trailing comma, two consecutive *)
(* commas, a bare "null", or a type annotation immediately followed *)
(* by "," / "]" all denote a null element. *)
null-value = "null" | (* empty *) ;
(* bool-value: a reserved keyword in value position. "on" is an *)
(* alias for "true" and "off" for "false"; the validator collapses *)
(* all four spellings to a vt_bool value (canonical text "true" / *)
(* "false") and synthesises a <bool> type annotation when none is *)
(* given (see §4). An explicit <bool> annotation accepts only these *)
(* keywords as its value. *)
(* *)
(* Lexically these keywords — together with "null" — are ordinary *)
(* symbol tokens (§7); the validator reserves the exact words and *)
(* reclassifies them. A longer word that merely starts with one *)
(* (e.g. "truthy", "nullable") remains a plain symbol. *)
bool-value = "true" | "false" | "on" | "off" ;
(* ───────────────────────────────────────────────────────────────── *)
(* 4. Type annotations *)
(* ───────────────────────────────────────────────────────────────── *)
(* Lexer: a keyword state machine (tf_* states) recognises the eight *)
(* family names via six keyword paths (float_fix / float_dec share *)
(* the "float" prefix; see §4 below). After the family keyword the *)
(* raw bytes of the ":…" *)
(* parameter string are accumulated into type_data by copy_type_byte. *)
(* *)
(* Validator: bvn_parse_type_annotation parses the accumulated string *)
(* and the validator emits the structured type-annotation event *)
(* sequence described in §1. *)
(* *)
(* Default synthesis: when a number or string value carries no *)
(* explicit type annotation (value_type is still vt_plain), the *)
(* validator synthesises a default before emitting ev_data: *)
(* strings / special-numbers → <utf8> / <float:64,_10,no_unit> *)
(* floats (has "." or "e"/"E") → <float:64,_10,no_unit> *)
(* negative integers → <sint:64,_10,no_unit> *)
(* plain integers → <uint:64,_10,no_unit> *)
(* bool keywords (true/false/on/off) → <bool> *)
(* The full type-annotation event sequence is always emitted for *)
(* number and string values, whether annotated explicitly or not. *)
type-annotation = "<" , ws , param-type , ws , ">" ;
(* Types with an optional parameter list after ":". *)
(* Parameters are identified by class and may appear in any order; *)
(* at most one of each class (width, base, q, unit) is permitted. *)
(* *)
(* "utf8" goes to type_body_outro after the keyword; the lexer *)
(* accepts ":" and the parameter bytes, but utf8 is a parameterless *)
(* family — any width, base, q, or unit parameter is rejected by *)
(* bvn_parse_type_annotation as error_illegal_value_type (see bool). *)
(* *)
(* "float_fix" and "float_dec" are not separate keyword paths in the *)
(* lexer state table. The lexer fires ACT_tf_float_done after the *)
(* shared "float" prefix (states tf_f → tf_fl → tf_flo → tf_floa *)
(* → ACT_tf_float_done), which stores "float" in type_data and *)
(* transitions to type_body_outro. The subsequent bytes "_fix" or *)
(* "_dec" are accumulated via copy_type_byte. The final string *)
(* ("float", "float_fix", or "float_dec") is dispatched in *)
(* bvn_parse_type_annotation. *)
(* "bool" and "utf8" are parameterless families: any width, base, q, *)
(* or unit parameter is error_illegal_value_type. bool's only valid *)
(* values are the bool keywords (see bool-value, §3). *)
(* "datetime" (spec 1.1) is a timestamp: a signed integer seconds count *)
(* validated like sint. It accepts an optional width parameter (as sint) *)
(* plus one epoch-name parameter (unix|tai|gps|mjd|ntp|galileo|glonass| *)
(* y2000|beidou; default unix), e.g. <datetime:64,unix>. A numeric base, *)
(* q, or unit parameter is error_illegal_value_type. Available only when *)
(* the document declares "#!bovnar 1.1" or newer. *)
param-type = ("uint" | "sint" | "float" | "float_fix" | "float_dec" | "utf8"
| "bool" | "datetime")
, [ws , ":" , ws , type-param-list] ;
(* The parameter list is accumulated byte-by-byte into type_data by *)
(* the copy_type_byte / type_body_outro states, and later re-parsed *)
(* by bvn_parse_type_annotation. *)
(* *)
(* Bytes accepted in the accumulated string: *)
(* A–Z, a–z, 0–9, "$", "%", "(", ")", "*", "+", ",", ".", "/", *)
(* "-", ":", "^", "_", "~" *)
(* ("~" joins a prefix to its unit, "$" starts a currency, "%" is *)
(* percent, "(" and ")" group a unit expression -- all required by *)
(* inline-unit-char below) *)
(* UTF-8 continuation bytes 0x80–0xBF *)
(* UTF-8 leader bytes 0xC2–0xF4 (0xC2 IS accepted here, unlike *)
(* identifier / symbol / reference token starts) *)
(* Whitespace is accepted but not accumulated; it transitions to *)
(* type_body_outro, which accepts the same bytes as copy_type_byte *)
(* plus "#" (0x23, entering comment_intro), while treating whitespace *)
(* as ignored rather than re-triggering the outro transition. *)
(* ">" always terminates the annotation. *)
type-param-list = type-param , {ws , "," , ws , type-param} ;
type-param = width-param (* e.g. 32, 64 *)
| base-param (* e.g. _16, _64, _85 *)
| q-param (* e.g. q8, q16 – float_fix only *)
| unit-param ; (* e.g. m~s, Gi~B, m^2 *)
(* Width: one or more decimal digits. 0 means "no width restriction".*)
(* float: 0, 16, or any multiple of 32 up to BVN_FLOAT_MAX_PREC.*)
(* float_fix, *)
(* float_dec: 0, 16, 32, 64, 128, 256. *)
width-param = DIGIT , {DIGIT} ;
(* Base: underscore followed by one or more decimal digits. *)
(* Valid values: 2–62, 64 (standard Base64), 85 (Ascii85). *)
(* Bases 64 and 85 are uint-only: their alphabets use '+'/'-' as *)
(* digits, leaving no sign character, so sint+64/85 is illegal. *)
(* "float" accepts only base 10 (or absent, → 10) or base 16; *)
(* other bases → error_illegal_value_type. *)
(* "float_fix" and "float_dec" reject the base parameter entirely: *)
(* any base-param → error_illegal_value_type. *)
(* A non-decimal base with uint/sint should use a quoted string value; *)
(* a bare literal is not rejected by the validator but will be parsed *)
(* as a symbol token, causing a type/value mismatch instead. *)
base-param = "_" , DIGIT , {DIGIT} ;
(* Q~parameter: lowercase "q" followed by one or more decimal digits. *)
(* Specifies the number of fractional bits for the float_fix Q-format.*)
(* Value = raw_signed_integer × 2^(-Q). *)
(* Valid only for "float_fix"; rejected for all other families. *)
(* Valid range: 0 ≤ Q < effective_width (width 0 → effective 64). *)
(* Q is stored in value_type_spec_t.base and retrieved via *)
(* bvn_effective_q; bvn_effective_base always returns 10 for *)
(* float_fix and float_dec. *)
q-param = "q" , DIGIT , {DIGIT} ;
(* Unit parameter: everything up to the next "," or ">". *)
(* Parsed against the unit table by bvn_parse_unit. *)
(* "no_unit" is a reserved keyword meaning explicitly dimensionless. *)
(* Valid for: uint, sint, float, float_fix, float_dec. *)
unit-param = unit-char , {unit-char} ;
unit-char = (* any byte accepted by copy_type_byte that is not *)
(* "," (0x2C) or ">" (0x3E): *)
(* A–Z, a–z, 0–9, "*", "+", ".", "/", "-", ":", "^", *)
(* "_", "~", "$", "%", "(", ")", or a UTF-8 multi- *)
(* byte byte (0x80–0xBF or 0xC2–0xF4) *) ;
(* ── Unit sub-grammar (semantic, enforced by bvn_parse_unit) ─── *)
(* *)
(* resolved-unit = "no_unit" | unit-expr ; *)
(* unit-expr = unit-factor , {unit-sep , unit-factor} ; *)
(* unit-factor = unit-component | "(" , unit-expr , ")" ; *)
(* *)
(* unit-sep = "*" (* product – U+002A *) *)
(* | "·" (* product – U+00B7 *) *)
(* | "/" ; (* division *) *)
(* "·" is the middle dot, encoded as 0xC2 0xB7 (UTF-8 for U+00B7). *)
(* "*" and "·" are semantically equivalent (multiplication). *)
(* *)
(* Semantics of "/": the first "/" switches every subsequent factor *)
(* into the denominator (its stored exponents are negated). A "(...)" *)
(* group is a sub-expression evaluated independently; when it falls in *)
(* the denominator its net exponents are negated as a whole, so *)
(* "k~g/(m·s²)" = kg·m⁻¹·s⁻² and "(k~g/m)·s²" = kg·m⁻¹·s². Additional *)
(* "/" separators at one level never toggle back to the numerator. *)
(* An explicit separator is required before a group ("m·(s)", not *)
(* "m(s)"); a group is not (yet) followed by its own exponent; paren *)
(* nesting is bounded at 16. *)
(* *)
(* Empty components or groups (e.g. "m//s", "()", "m*·s"), unmatched *)
(* parentheses, and more than BVNR_MAX_UNIT_COMPONENTS = 8 total *)
(* components per unit string all produce error_unit_illegal. *)
(* *)
(* unit-component = [prefix "~"] base-unit [unit-exponent] ; *)
(* *)
(* When a prefix is present the separator "~" is mandatory. *)
(* A bare base-unit with no prefix requires no separator. *)
(* *)
(* si-prefix = "Q"|"R"|"Y"|"Z"|"E"|"P"|"T"|"G"|"M"|"k"|"h"|"da" *)
(* | "d"|"c"|"m"|"µ"|"n"|"p"|"f"|"a"|"z"|"y"|"r"|"q" ; *)
(* (µ = U+00B5 0xC2 0xB5; Greek μ = U+03BC; ASCII "u" alias) *)
(* *)
(* iec-prefix = "Ki"|"Mi"|"Gi"|"Ti"|"Pi"|"Ei"|"Zi"|"Yi"|"Ri"|"Qi" ; *)
(* Ri (2^90) and Qi (2^100) were added in IEC 80000-13:2022 ed. 2. *)
(* *)
(* base-unit = a symbol looked up in the fixed unit table by *)
(* bvn_parse_unit (src/utils/bovnar_utils.c): 163 named units with *)
(* 491 accepted spellings (canonical symbols plus long-form and plural *)
(* aliases), spanning SI, imperial / US customary, CGS, radiation, *)
(* surveying, culinary, Old German and digital-storage quantities. *)
(* Doc 2 (the unit-system spec) is the authoritative registry; the SI *)
(* coherent / common subset below is REPRESENTATIVE only, NOT a closed *)
(* alternation: *)
(* b B s m g A K mol cd Hz N Pa J W V Ω F C S Wb T H lm lx Bq Gy Sv *)
(* kat rad sr L l min h d wk yr °C ° degC degrC degrees degree degr *)
(* deg t bar eV Da au ha *)
(* Symbols are case-sensitive and a few spellings collide across roles, *)
(* resolved by context: "P" = poise (base unit) vs peta (SI prefix), *)
(* "G" = gauss vs giga. (Ω = U+2126: 0xE2 0x84 0xA6; ° = U+00B0: 0xC2 *)
(* 0xB0) *)
(* *)
(* unit-exponent = [exp-sign] exp-digit *)
(* | "^" ["-"|"+"] ASCII-digit ; *)
(* *)
(* exp-sign = "⁺" (* U+207A: 0xE2 0x81 0xBA, positive / no-op *) *)
(* | "⁻" ; (* U+207B: 0xE2 0x81 0xBB, negate exponent *) *)
(* *)
(* exp-digit = "¹"|"²"|"³"|"⁴"|"⁵"|"⁶"|"⁷"|"⁸"|"⁹" ; *)
(* (U+00B9 / 00B2 / 00B3 / 2074–2079) *)
(* *)
(* "⁺" is accepted but has no effect (same as absent). *)
(* The ASCII form "^[+-]?[1-9]" maps to the same internal constants *)
(* as the Unicode superscript form. "^0" is not a valid exponent. *)
(* ───────────────────────────────────────────────────────────────── *)
(* 5. Numbers *)
(* ───────────────────────────────────────────────────────────────── *)
(* The lexer is digit-agnostic: any ASCII digit 0–9 is accepted in a *)
(* bare number literal without base validation. Digit validity against*)
(* a non-decimal base is checked later by bvn_acc_parse_number. *)
(* Leading zeros are not rejected by the lexer ("007" is valid). *)
(* Only "e"/"E" are accepted as exponent markers in bare literals. *)
(* "p"/"P" (binary exponent) is recognised by the validator inside a *)
(* quoted-string number value of a base-16 float, e.g. *)
(* <float:64,_16> "1.8p+2"; there "e"/"E" are hex mantissa digits, so *)
(* the binary exponent must use "p"/"P" (see spec §6.3). It is the *)
(* only context in which "p"/"P" is reachable. *)
number = ["-"] , (int-led | dot-led) , [dec-exponent] ;
(* Integer-led: one or more digits with an optional fractional part. *)
(* A trailing dot ("123.") without fractional digits is valid. *)
int-led = DIGIT , {DIGIT} , ["." , {DIGIT}] ;
(* Dot-led: at least one digit after the dot is required. *)
(* ".5" and "-.5" are valid; a lone "." is a hard error. *)
dot-led = "." , DIGIT , {DIGIT} ;
dec-exponent = ("e" | "E") , ["+" | "-"] , DIGIT , {DIGIT} ;
(* Special IEEE-754 values are bare reserved keywords: nan, inf, and *)
(* ninf (negative infinity). Like null / true / false / on / off they *)
(* are lexed as ordinary symbols and reclassified to numeric special *)
(* values by the validator; a bare word that is not one of these exact *)
(* spellings stays an ordinary symbol (e.g. "infinity", "nans"). *)
(* There is no sigil and no inline-unit suffix — a unit is supplied *)
(* via the type annotation, e.g. <float:64,m/s> inf. *)
special-number = "nan" | "inf" | "ninf" ;
(* ───────────────────────────────────────────────────────────────── *)
(* 6. Strings *)
(* ───────────────────────────────────────────────────────────────── *)
(* Two or more adjacent quoted literals with only whitespace/comments *)
(* between them are concatenated into a single string token by the *)
(* lexer (string_outro re-enters string_intro on '"'). The combined *)
(* byte length is bounded by max_string_length (default UINT16_MAX). *)
string = string-literal , {ws , string-literal} ;
string-literal = '"' , {string-char} , '"' ;
string-char = safe-string-byte
| escape-seq ;
(* safe-string-byte: whitespace 0x09–0x0D, 0x20–0x7E except '"' (0x22)*)
(* and '\' (0x5C), plus high bytes 0x80–0xFF. *)
(* Control bytes 0x00–0x08, 0x0E–0x1F, and DEL (0x7F) are hard *)
(* errors even inside strings. *)
safe-string-byte = (* byte in [0x09,0x0D] | [0x20,0x21] | [0x23,0x5B] *)
(* | [0x5D,0x7E] | [0x80,0xFF] *) ;
(* Recognised escape sequences (from bvn_escape_lut): *)
escape-seq = "\" , ( "t" (* \t = 0x09 HT *)
| "n" (* \n = 0x0A LF *)
| "v" (* \v = 0x0B VT *)
| "f" (* \f = 0x0C FF *)
| "r" (* \r = 0x0D CR *)
| '"' (* \" *)
| "\" (* \\ *)
| byte-escape (* spec 1.1, see below *)
| unicode-escape ) ; (* spec 1.1, see below *)
(* All other bytes after "\" are error_illegal_escape_sequence. *)
(* spec 1.1 only — available when the document declares "#!bovnar 1.1" *)
(* or newer; in a 1.0/unversioned document "x"/"u" after "\" stay *)
(* error_illegal_escape_sequence. *)
byte-escape = "x" , hex-digit , hex-digit ; (* one byte 0xHH *)
unicode-escape = "u" , "{" , hex-digit ,
[hex-digit] , [hex-digit] , [hex-digit] ,
[hex-digit] , [hex-digit] , "}" ;
(* 1 to 6 hex digits naming a Unicode scalar. A 7th hex digit before "}" *)
(* (or an empty "{}") is error_illegal_escape_sequence — a structural *)
(* check made before the value is examined. A value that is a surrogate *)
(* (D800..DFFF) or above 10FFFF is error_invalid_codepoint; otherwise the *)
(* scalar is UTF-8 encoded. A \x byte must leave the whole string valid *)
(* UTF-8 (else error_invalid_utf8_byte). *)
hex-digit = DIGIT | "a".."f" | "A".."F" ;
(* ───────────────────────────────────────────────────────────────── *)
(* 6.5 Inline unit suffix *)
(* ───────────────────────────────────────────────────────────────── *)
(* An inline-unit may follow a scalar number or string value (but not *)
(* a special-number keyword — give its unit via the type annotation). *)
(* At least one whitespace character (ws-mandatory) is the sole *)
(* required separator between the value literal and the unit. *)
(* Inline units attach only at value position: the value rule has a *)
(* scalar-with-unit alternative, while an array element (array-elem) *)
(* uses raw-value, which has no unit option. At the action level the *)
(* lexer additionally rejects ACT_inline_unit_intro when the *)
(* in_array_element flag is set (a redundant guard). *)
(* *)
(* INVALID (no whitespace before the unit): *)
(* .bad = 9.81m/s; (* no separator at all — hard error *) *)
(* *)
(* Examples: *)
(* .a = 9.81 m/s; (* space separator *) *)
(* .b = <float:64> 1.5 k~m; (* with type annotation *) *)
(* .c = <float:64,s> inf; (* special-number unit via annotation *) *)
(* .d = 1.5e3 k~J; (* number with exponent *) *)
(* .e = "FF" m; (* string literal with unit *) *)
(* *)
(* State-machine flow: *)
(* 1. Whitespace from a number-body state fires ACT_to_number_outro *)
(* → number_outro. Strings enter string_outro_nosp after the *)
(* closing ""; whitespace transitions to string_outro. *)
(* Special numbers (nan, inf, ninf) are lexed as symbols and *)
(* take no inline unit (give the unit via the type annotation). *)
(* 2. From number_outro / string_outro (whitespace already seen): *)
(* alpha / "_" / "$" / "%" / "(" / UTF-8 leader (0xC2–0xF4) *)
(* → ACT_inline_unit_intro *)
(* 3. ACT_inline_unit_intro pushes the first byte and enters *)
(* inline_unit_body directly. *)
(* 4. inline_unit_body accumulates unit characters (including ":" *)
(* as a valid mid-unit byte) until whitespace (→ *)
(* inline_unit_outro) or ";" (→ value_outro via finalize). *)
(* *)
(* Inside an array element (in_array_element flag set), *)
(* ACT_inline_unit_intro immediately rejects with *)
(* error_unexpected_input_byte. *)
(* *)
(* Semantic rule (enforced by validator): *)
(* If a type-annotation unit was also given, the inline unit must *)
(* parse to the identical value_unit_t; mismatch → error_unit_mismatch. *)
inline-unit = inline-unit-start , { inline-unit-char } ;
(* A currency component carries a mandatory "$" sigil (spec 1.0): "$USD", *)
(* "$BTC", or prefixed "<prefix>~$EUR". Bare codes are no longer currencies, *)
(* so a currency code can never collide with a physical-unit symbol. *)
(* "(" lets an inline unit begin with a parenthesised group, e.g. "(m/s)/s", *)
(* so the inline form accepts the same unit grammar as a type-annotation unit. *)
inline-unit-start = ALPHA | "_" | "$" | "%" | "(" | unit-lead-byte ;
inline-unit-char = ALPHA | DIGIT | "_" | "$" | "%" | "+" | "-" | "." | "/" | ":" | "^" | "*" | "~"
| "(" | ")" | unit-lead-byte | utf8-continuation ;
(* unit-lead-byte: the UTF-8 lead-byte range accepted inside a unit — the *)
(* full 0xC2-0xF4, BROADER than utf8-leader (0xC3-0xF4). Unlike identifier, *)
(* symbol, and reference token starts (where 0xC2 is rejected, see §12), a *)
(* unit may begin with or contain a 0xC2-led code point: the µ prefix *)
(* (U+00B5 = 0xC2 0xB5), the ° / °C units (U+00B0 = 0xC2 0xB0), and the *)
(* "·" product separator (U+00B7 = 0xC2 0xB7). The lexer maps the whole *)
(* 0xC2-0xF4 range to ACT_inline_unit_intro / ACT_copy_inline_unit_byte, *)
(* matching copy_type_byte in a type-annotation unit body. *)
unit-lead-byte = (* byte in [0xC2, 0xF4] *) ;
(* ───────────────────────────────────────────────────────────────── *)
(* 7. Symbols (unquoted bare-word values in value position) *)
(* ───────────────────────────────────────────────────────────────── *)
(* A symbol starts with id-start and continues with sym-body-char. *)
(* Differences from the identifier body action table: *)
(* "=" is a hard error (not a state transition) *)
(* "," / "]" / ";" terminate the symbol cleanly (not errors) *)
(* "." is a hard error (same as in identifier body) *)
(* Reserved keywords: a symbol token whose text is exactly "null", *)
(* "true", "false", "on", or "off" is reclassified by the validator *)
(* (see null-value / bool-value, §3) and is NOT delivered as a symbol.*)
(* Any other bare word — including one that merely begins with a *)
(* keyword, like "ontology" or "nullish" — remains a symbol. *)
symbol = id-start , {sym-body-char} ;
(* sym-body-char spans the same byte range as id-body-char; the *)
(* difference is purely in the termination actions fired for *)
(* "," (new_array_value), "]" (array_outro), and ";" (value_outro). *)
sym-body-char = id-body-char ;
(* ───────────────────────────────────────────────────────────────── *)
(* 8. References *)
(* ───────────────────────────────────────────────────────────────── *)
(* The "&" sigil introduces a dotted path to another key. *)
(* The stored string starts with "." and includes all dots: *)
(* "&.foo.bar" stores ".foo.bar". *)
(* At least one segment is required; "&" alone or "&." without a *)
(* following id-start character is an error. *)
(* A reference is stored UNRESOLVED — the path is a string token; the *)
(* library never dereferences it, so the target need not exist and *)
(* cycles are not detected. Resolution is the application's job. *)
reference = "&" , ref-segment , {ref-segment | ref-index} ;
ref-segment = "." , id-start , {ref-body-char} ;
(* ref-index (spec 1.1): array indexing in a reference path, e.g. *)
(* &.matrix[0][1]. The bytes are captured verbatim into the path *)
(* string (references are stored unresolved); bvn_dom_lookup *)
(* interprets them at the DOM layer. Gated on a "#!bovnar 1.1" *)
(* declaration — in a 1.0/unversioned document "[" in a reference is *)
(* error_unexpected_input_byte. The reference_index sub-state *)
(* distinguishes a "]" that closes an index from one that closes an *)
(* enclosing array (which ends the reference). *)
ref-index = "[" , DIGIT , {DIGIT} , "]" ;
(* ref-body-char: same byte set as sym-body-char. A "." (0x2E) *)
(* does NOT extend the body: it is ACT_copy_reference_dot, which *)
(* starts a NEW ref-segment (advancing to reference_segment_intro, *)
(* which REQUIRES an id-start next). A "." with no following *)
(* id-start — a trailing or doubled dot — is *)
(* error_unexpected_input_byte. *)
ref-body-char = sym-body-char ;
(* ───────────────────────────────────────────────────────────────── *)
(* 9. Arrays *)
(* ───────────────────────────────────────────────────────────────── *)
(* An array is one or more bracket-enclosed rows separated by "/": *)
(* .a = [1, 2, 3]/[4, 5, 6]; *)
(* "/" emits ev_array_dim_start; the next "[" opens the next row. *)
(* Whitespace/comments are permitted on both sides of "/" (between the *)
(* closing "]" and "/", and between "/" and the next "["). *)
(* *)
(* The former adjacent-row syntax "[1,2][3,4]" and the comma-between- *)
(* rows form "[1,2],[3,4]" are hard errors at the assignment level. *)
(* array_outro no longer accepts "[" directly; the only continuation *)
(* for a further row is "/". *)
(* *)
(* Nested arrays are valid element values inside a row: *)
(* [[1,2],[3,4]] is a one-row outer array whose two elements are *)
(* the inner arrays [1,2] and [3,4]. *)
(* *)
(* Leading and trailing commas produce null elements: *)
(* [,1,2,] → four elements: null, 1, 2, null. *)
(* *)
(* Semantic constraints — two distinct tiers: *)
(* *)
(* a) Enforced by the LEXER/VALIDATOR (the streaming reader): the *)
(* "/"-separated dimension rows of a SINGLE array must all have *)
(* the same element count; a mismatch is *)
(* error_array_row_size_mismatch (code 2). The lexer checks each *)
(* row as it closes, so the error fires at the earliest byte (the *)
(* overshooting element, or the closing "]" of a short row). *)
(* The per-bracket-pair context save/restore mechanism keeps this *)
(* check scoped to one array instance: a "/"-row width never *)
(* leaks across a "," boundary or into a sibling branch. *)
(* [1,2,3]/[4,5] → error (one array's /-rows: 3 vs 2) *)
(* [[1,2]/[3,4,5]] → error (inner /-array's rows: 2 vs 3) *)
(* *)
(* b) Enforced by the DOM BUILDER ONLY (bvn_dom_parse), NOT by the *)
(* streaming reader — `loads`/the SAX callbacks accept these and *)
(* deliver every element; only a DOM parse rejects them. Array *)
(* elements must be HOMOGENEOUS (spec 1.0): the same kind *)
(* throughout, `,`-separated sibling sub-arrays must be *)
(* rectangular (match in length), and sibling structs must share *)
(* the same shape. A ragged sibling set is *)
(* error_array_row_size_mismatch (code 2); a mix of element kinds *)
(* (e.g. a scalar beside a sub-array) is *)
(* error_array_element_type_mismatch (code 39); sibling structs *)
(* with differing shapes is error_struct_shape_mismatch (code 40).*)
(* [[1,2],[3,4,5]] → DOM error (ragged sibling sub-arrays) *)
(* [1,[2,3]] → DOM error (scalar mixed with a sub-array) *)
(* [{.x=1;},{.y=2;}] → DOM error (sibling structs, differing *)
(* shapes) *)
(* [[1,2]/[3,4], [5,6]/[7,8]] → accepted (same-shape blocks) *)
(* *)
(* c) Array nesting depth ≤ max_array_nesting (default 64, cap 255).*)
array = array-row , {ws , "/" , ws , array-row} ;
array-row = "[" , ws , [row-content] , ws , "]" ;
(* row-content is OPTIONAL: "[]" (nothing between the brackets) is an *)
(* EMPTY row — zero elements, no ev_data. It is distinct from "[null]" *)
(* (one null element) and "[,]" (two null elements). When present, *)
(* row-content has at least one element position; leading / trailing / *)
(* consecutive commas each expand to a null-value element. An empty row *)
(* has width 0, a valid row width for /-dimension consistency. *)
row-content = array-elem , {ws , "," , ws , array-elem} ;
(* array-elem has the same structure as a top-level value, except that an *)
(* inline unit suffix is not permitted: it uses raw-value (no unit option), *)
(* not the value rule's scalar-with-unit alternative (see §6.5). *)
array-elem = [type-annotation , ws] , raw-value ;
(* ───────────────────────────────────────────────────────────────── *)
(* 10. Structs (nested key-value scopes) *)
(* ───────────────────────────────────────────────────────────────── *)
(* A struct "{" … "}" contains zero or more assignments. "{}" is an *)
(* empty struct (zero members). After "}" the struct behaves like any *)
(* completed value: followed by ";" at the top level, or "," / "]" in *)
(* array context. *)
(* *)
(* Key uniqueness is a DOM-BUILDER constraint, NOT a grammar one: the *)
(* lexer/validator (the streaming reader) accepts a repeated key and *)
(* delivers BOTH assignments. A DOM parse (bvn_dom_parse) is stricter — *)
(* a duplicate key within one scope (a struct, or the top-level *)
(* document) is error_duplicate_struct_key. The same key in different *)
(* scopes is always unrelated. *)
(* Nesting depth: struct_nesting_level ≤ max_struct_nesting *)
(* (default 64 when the flag is 0; hard cap 255). *)
(* An unmatched "}" is error_illegal_struct_close. *)
struct = "{" , ws , {assignment , ws} , "}" ;
(* ───────────────────────────────────────────────────────────────── *)
(* 11. Octet streams (binary-framed escape from the text layer) *)
(* ───────────────────────────────────────────────────────────────── *)
(* A NUL byte (0x00) appearing where a value is expected switches from*)
(* text mode to binary chunk mode. The UTF-8 stream validator is *)
(* suspended for the duration. *)
(* *)
(* Binary protocol (bvn_read_octet_stream): *)
(* Tag 0x01 → chunk follows (2-byte LE length + data bytes) *)
(* Tag 0x00 → end of stream *)
(* Any other tag → error_octet_stream_out_of_sync *)
(* *)
(* After ev_octet_stream_end the lexer enters octet_stream_outro, *)
(* which accepts the same terminators as any other completed value. *)
octet-stream = "\x00" , {os-chunk} , os-end ;
os-chunk = "\x01" , os-length , os-data ;
os-end = "\x00" ;
(* 16-bit little-endian length; 0x0000 encodes 65536 bytes. *)
os-length = byte , byte ; (* little-endian uint16; 0x0000 → 65536 *)
os-data = {byte} ; (* exactly os-length bytes; not CF *)
(* ───────────────────────────────────────────────────────────────── *)
(* 12. Lexical primitives *)
(* ───────────────────────────────────────────────────────────────── *)
DIGIT = "0" | "1" | "2" | "3" | "4"
| "5" | "6" | "7" | "8" | "9" ;
ALPHA = "A"|"B"|"C"|"D"|"E"|"F"|"G"|"H"|"I"|"J"|"K"|"L"|"M"
| "N"|"O"|"P"|"Q"|"R"|"S"|"T"|"U"|"V"|"W"|"X"|"Y"|"Z"
| "a"|"b"|"c"|"d"|"e"|"f"|"g"|"h"|"i"|"j"|"k"|"l"|"m"
| "n"|"o"|"p"|"q"|"r"|"s"|"t"|"u"|"v"|"w"|"x"|"y"|"z" ;
(* utf8-leader here denotes the range used at identifier, symbol, and *)
(* reference token-start and -body positions: 0xC3–0xF4. *)
(* 0xC2 is the leader for U+0080–U+00BF. It is explicitly overridden *)
(* to ACT_NONE at every identifier, symbol, and reference boundary, *)
(* so code points U+0080–U+00BF cannot start or appear in those *)
(* tokens. 0xC2 IS accepted inside quoted strings and type parameter *)
(* bodies (copy_type_byte), where the full range 0xC2–0xF4 applies. *)
utf8-leader = (* byte in [0xC3, 0xDF] | [0xE0, 0xEF] | [0xF0, 0xF4] *) ;
utf8-continuation = (* byte in [0x80, 0xBF] *) ;
(* Printable ASCII: 0x20–0x7E *)
printable-byte = (* byte in [0x20, 0x7E] *) ;
(* High byte: any byte 0x80–0xFF. 0xC0, 0xC1, 0xF5–0xFF are *)
(* structurally in this range but rejected by the parallel UTF-8 *)
(* validator (error_invalid_utf8_byte). *)
high-byte = utf8-continuation | utf8-leader
| (* 0xC0 | 0xC1 | 0xF5–0xFF – rejected by UTF-8 check *) ;
byte = (* any single byte 0x00–0xFF *) ;
(* ───────────────────────────────────────────────────────────────── *)
(* 13. Constraints not expressible in context-free EBNF *)
(* ───────────────────────────────────────────────────────────────── *)
(*
a) UTF-8 validity
Every byte in the text layer (outside octet-stream regions) must
form valid UTF-8 when fed through bvn_utf8_feed. The UTF-8
validator runs in parallel with the action state machine; a
non-UTF-8 byte causes error_invalid_utf8_byte regardless of which
lexer state the parser is in. Overlong encodings and surrogate
halves are rejected.
b) BOM uniqueness
The UTF-8 BOM (EF BB BF) is legal only at byte offset 0.
If the stream begins with "#" instead (a first-line comment), the
special first_comment_* state machine watches the comment body for
the exact byte sequence 0xEF 0xBB 0xBF and rejects it as
error_invalid_byte_order_mark. This detection applies only while
the first_comment_* states are active (i.e. until the first
newline); subsequent regular comments (comment_intro state) have
no BOM detection, so U+FEFF encoded as 0xEF 0xBB 0xBF appearing
in any later comment body or string literal is valid UTF-8 and is
accepted.
c) 0xC2 at token boundaries
The byte 0xC2 (UTF-8 leader for U+0080–U+00BF) is mapped to
ACT_NONE at identifier, symbol, and reference token-start and
token-body positions, forbidding code points U+0080–U+00BF in
those tokens. 0xC2 is accepted inside quoted strings and in type
parameter bodies (copy_type_byte / type_body_outro).
d) Special-number keywords
The special floats nan / inf / ninf are bare reserved keywords (no
sigil). They are lexed as symbols and reclassified to numeric
special values by the validator, like null / true / false / on /
off; any other bare word (e.g. "infinity", "nans") stays a symbol.
They take no inline unit — a unit is supplied via the annotation.
e) String concatenation
Adjacent string literals with only whitespace/comments between
them are concatenated into a single token by the lexer. The
combined byte length is bounded by max_string_length (default
UINT16_MAX = 65535 bytes).
f) Number exponent in bare literals vs. quoted strings
Bare number literals only accept "e"/"E" as exponent markers
(exp_intro state).
g) Array row-size consistency (LEXER/VALIDATOR — the streaming reader)
The "/"-separated dimension rows of a single array must all have
the same element count; the lexer checks each row as it closes, so
the violation is reported at the earliest offending byte. The
per-bracket-pair context save/restore mechanism keeps the /-row
check scoped to one array instance.
Violation: error_array_row_size_mismatch
(e.g. [1,2,3]/[4,5], or [[1,2]/[3,4,5]]).
Sibling homogeneity (DOM BUILDER ONLY — bvn_dom_parse; the streaming
reader accepts these). Array elements must be the same kind throughout,
`,`-separated sibling sub-arrays must be rectangular (match in length,
so a bare array/matrix is rectangular), and sibling structs must share
the same shape. A ragged sibling set is error_array_row_size_mismatch
(e.g. [[1,2],[3,4,5]]); a mix of element kinds is
error_array_element_type_mismatch (code 39, e.g. [1,[2,3]]); sibling
structs with differing shapes is error_struct_shape_mismatch (code 40,
e.g. [{.x=1;},{.y=2;}]). loads()/the SAX callbacks deliver such arrays
unchanged; only a DOM parse rejects them.
h) Array and struct nesting limits
Array nesting (bracket depth): max_array_nesting (default 64 when
the flag is 0; hard cap 255).
Struct nesting: max_struct_nesting (default 64 when the flag is 0;
hard cap 255).
i) Type-annotation / value compatibility (enforced by validator)
"utf8" requires a string value.
"uint", "sint", "float", "float_fix", "float_dec" accept a number or
string value; a quoted string allows special float notation.
"utf8" and "bool" are parameterless: the lexer accepts the ":"
and parameter bytes, but bvn_parse_type_annotation rejects any
width, base, q, or unit parameter as error_illegal_value_type
(e.g. "<utf8:8>" and "<utf8:,m>" are both errors).
"uint" rejects negative number literals (error_value_out_of_range).
Digit characters in the value must be valid for the declared base
(error_digit_not_in_base).
The numeric value must fit in the declared bit-width
(error_value_out_of_range).
For "float": accepted widths are 0, 16, or any multiple of 32 up to
BVN_FLOAT_MAX_PREC (32768); accepted bases are 10 (or absent) and
16; any other base → error_illegal_value_type.
For "float_fix": accepted widths are 0, 16, 32, 64, 128, 256;
the base parameter (_N) is forbidden (error_illegal_value_type);
the Q parameter (qN) must satisfy 0 ≤ Q < effective_width
(error_illegal_value_type if violated);
Q stored in value_type_spec_t.base; retrieved via bvn_effective_q.
For "float_dec": accepted widths are 0, 16, 32, 64, 128, 256;
the base parameter (_N) is forbidden (error_illegal_value_type);
the Q parameter is not valid for float_dec.
Neither "float_fix" nor "float_dec" can be synthesised by the
validator's default type synthesis; both require explicit annotation.
j) Identifier (key) non-empty
The grammar requires at least one id-start character after ".".
An empty key (".=") triggers error_empty_identifier.
k) Struct closing brace without matching open
A "}" seen when struct_nesting_level == 0 is
error_illegal_struct_close.
l) Comma in non-array context
"," triggers ACT_new_array_value, which checks
array_nesting_level > 0 and returns error_unexpected_input_byte
if the condition is not met. A stray "," at the top level is
therefore an error.
m) Octet stream out-of-sync
Any byte other than 0x01 (chunk) or 0x00 (end) as the tag byte
inside an octet stream causes error_octet_stream_out_of_sync.
n) Error recovery (continue_on_error mode)
When bvnr_read_flags_t.continue_on_error is set, any parse error
invokes the on_error callback and then enters the resync state
machine (states: resync, resync_string, resync_string_escape,
resync_comment). The resync machine skips bytes while tracking
bracket and brace nesting depths, and attempts to re-synchronise
at the next ";" at the current nesting depth. recovery_count
(bvnr_reader_get_recovery_count) is incremented immediately when
an error triggers entry into resync mode, not when resync
completes at ";". EOF inside any resync state raises a final
error_got_incomplete_bvnr_stream (delivered to on_error and
returned by bvnr_reader_get_error); the original error that
triggered resync was already delivered to on_error earlier, at
the point of fault.
o) Inline unit suffix
An inline-unit follows a scalar number or string in non-array
context only. A special-number keyword (nan, inf, ninf) takes NO
inline unit — it is lexed as a symbol, and symbols accept no inline
suffix; "inf m/s" is error_unexpected_input_byte. Supply a unit
for a special value through the type annotation (<float:64,m/s> inf).
At least one whitespace character MUST separate the value literal
from the unit. The only accepted form is "value unit"; any other
form (no separator or colon) is a hard error.
State-machine flow:
1. Whitespace from number-body states (zero_intro, copy_number_byte,
fraction_intro, copy_fraction_byte, copy_exp_byte) fires
ACT_to_number_outro → number_outro. Strings enter
string_outro_nosp after the closing quote; whitespace
transitions to string_outro. A comment's terminating newline
also counts as whitespace (comment_outro promotes string_outro_nosp
→ string_outro).
2. From number_outro / string_outro (at least one ws consumed):
alpha / "_" / "$" / "%" / "(" / UTF-8-leader (0xC2-0xF4)
→ ACT_inline_unit_intro
3. ACT_inline_unit_intro pushes the first byte and enters
inline_unit_body directly.
4. inline_unit_body accumulates unit characters (including ":"
as a valid mid-unit byte) until whitespace (→
inline_unit_outro) or ";" (→ value_outro via finalize).
Inside an array element (in_array_element flag set),
ACT_inline_unit_intro immediately rejects with
error_unexpected_input_byte.
The validator's bvn_val_receive calls bvn_parse_unit on the
inline buffer and compares the result to v->parsed_unit using
memcmp on the full value_unit_t structure (valid because both
structs are zero-initialised before parsing and all padding is
zeroed). When has_annotation_unit is true and the comparison
fails, the validator returns error_unit_mismatch.
When no annotation unit was present, the inline unit is silently
adopted as the effective unit.
*)