diff --git a/src/bast.rs b/src/bast.rs new file mode 100644 index 0000000..95f9ff5 --- /dev/null +++ b/src/bast.rs @@ -0,0 +1,1747 @@ +//! BAST document parser — the typed surface over a BAST (Binary Abstract +//! Syntax Tree) document that the layout engines, materializer, and +//! BAST-native validator walk instead of the v0.1.0 `get_alktype_kind*` +//! custom-keyword accessors. +//! +//! This is step 3 of the BAST pivot +//! ([`docs/plans/bast-implementation.md`](../docs/plans/bast-implementation.md)). +//! The format is normative in +//! [`docs/architecture/bast-format.md`](../docs/architecture/bast-format.md); +//! the POC lives on branch `bast-validator-poc` as `src/bast_poc.rs`. +//! +//! ## Design +//! +//! The parser produces a **typed tree** ([`BastDoc`]/[`BastDef`]/[`BastType`]/…) +//! that borrows from the source [`serde_json::Value`]. Three consumers +//! (layout, materialize, validate) walk the same tree, so a typed view +//! pays for itself: each walks matched arms over [`BastType`] instead of +//! re-parsing raw JSON at every node. Borrowing (not cloning) keeps the +//! parse allocation-free beyond the small typed nodes themselves. +//! +//! `$ref` resolution is a single hash lookup against `$defs` — BAST refs +//! are always full JSON Pointers restricted to `#/$defs/` (no +//! `normalize_refs`, no `inline_union_variant_refs`; both are removed in +//! step 8). Union variant `$ref`s are resolved **lazily** by the +//! materializer/validator via [`BastDoc::resolve_typeref`]; the parser +//! only records the [`BastRef`] target name. +//! +//! ## Untrusted input +//! +//! Every path that walks a BAST document returns +//! [`AlkTypeError::Schema`] on a malformed +//! document, never `panic!`/`unreachable!`/`unwrap` (AGENTS.md §3 — the +//! downstream `alkcall` consumer accepts schemas from arbitrary internet +//! peers). Overflow-safe arithmetic (`checked_add`, `usize::try_from`) +//! is used for any offset/count cast (AGENTS.md §4). + +use crate::error::AlkTypeError; +use crate::schema::{AlkTypeKind, Endian, VariableEncoding}; +use serde_json::Value; + +const DEFS_KEY: &str = "$defs"; +const REF_PREFIX: &str = "#/$defs/"; + +/// A parsed BAST document: the typed view over `{ "$defs": { ... } }` +/// plus the chosen root name (D-BAST-001 — the root type name is a +/// required `compile()` parameter, not a convention). +/// +/// Built once via [`BastDoc::new`] from the raw BAST `Value`; the +/// downstream `compile()` call (step 4) will construct this and hand it +/// to the layout engines. The tree borrows from `root` — keep the +/// source `Value` alive for the lifetime of the doc. +#[derive(Debug)] +pub struct BastDoc<'a> { + root: &'a Value, + root_name: &'a str, + root_def: BastDef<'a>, +} + +impl<'a> BastDoc<'a> { + /// Parse a BAST document, selecting `root_name` as the top-level type. + /// + /// Validates the document structurally as it parses: the `$defs` + /// block is present, the root entry exists, and every reachable + /// definition parses to a typed [`BastDef`]. A malformed document + /// surfaces as [`AlkTypeError::Schema`], never a panic. Only the + /// root definition and the definitions it (transitively) references + /// are parsed eagerly; orphan `$defs` entries are not checked. + /// + /// The raw `Value` is also retained so lazy `$ref` resolution + /// ([`BastDoc::resolve_typeref`]) can reach any `$defs` entry at access time. + pub fn new(root: &'a Value, root_name: &'a str) -> Result { + let raw_root_def = Self::lookup_def_raw(root, root_name)?; + let root_def = BastDef::parse(raw_root_def, root_name, "")?; + let doc = Self { + root, + root_name, + root_def, + }; + Ok(doc) + } + + /// The root type name this doc was compiled with. + pub fn root_name(&self) -> &'a str { + self.root_name + } + + /// The parsed root type definition. + pub fn root_def(&self) -> &BastDef<'a> { + &self.root_def + } + + /// Look up a raw `$defs` entry by name. Returns the raw JSON node. + /// + /// The single hash lookup that replaces v0.1.0's `normalize_refs` + + /// `resolve_ref` machinery. BAST refs are always `#/$defs/`; + /// no external refs, no fragment-only pointers, no bare names. + pub fn lookup_def(&self, name: &str) -> Result<&'a Value, AlkTypeError> { + Self::lookup_def_raw(self.root, name) + } + + /// Resolve a [`BastType::Ref`] to the [`BastDef`] it names. + /// + /// Used lazily by the materializer and BAST-native validator (step 5) + /// for union variant dispatch and `$ref` fields. The parser does not + /// inline refs at construction time — this is the replacement for + /// v0.1.0's `inline_union_variant_refs`. + pub fn resolve_ref(&self, r: &BastRef<'a>) -> Result, AlkTypeError> { + let raw = self.lookup_def(r.name())?; + BastDef::parse(raw, r.name(), "") + } + + /// Resolve a [`BastType`] that may be a [`Ref`](BastType::Ref) into + /// the concrete [`BastType`] it points at, or return the inline type + /// unchanged. + /// + /// The composite-walkers (struct fields, array elements, record + /// values, union mappings) call this to deref one level. Deeper + /// `$ref` chains are resolved by recursing through the consumer. + pub fn resolve_typeref(&self, ty: &BastType<'a>) -> Result, AlkTypeError> { + match ty { + BastType::Ref(r) => { + let def = self.resolve_ref(r)?; + Ok(BastType::from_def(def)) + } + other => Ok(other.clone()), + } + } + + /// Resolve a [`BastType`] that may be a [`Ref`](BastType::Ref) into + /// the [`BastDef`] it names, or wrap an inline type in a synthetic + /// [`BastDef`]. + /// + /// Convenient for the validator/materializer, which work in terms of + /// [`BastDef`] (struct/union/enum) and need to handle both + /// `{ "$ref": "#/$defs/X" }` and inline `{ "kind": "struct", ... }` + /// uniformly. + pub fn resolve_typeref_as_def( + &self, + ty: &BastType<'a>, + owning_path: &str, + ) -> Result, AlkTypeError> { + match ty { + BastType::Ref(r) => self.resolve_ref(r), + BastType::Struct(s) => Ok(BastDef { + name: "", + kind: BastDefKind::Struct(s.clone()), + source: s.source, + }), + BastType::Union(u) => Ok(BastDef { + name: "", + kind: BastDefKind::Union(u.clone()), + source: u.source, + }), + BastType::Enum(e) => Ok(BastDef { + name: "", + kind: BastDefKind::Enum(e.clone()), + source: e.source, + }), + BastType::Primitive(_) | BastType::Array(_) | BastType::Record(_) => { + Err(AlkTypeError::Schema(format!( + "bast: type at {owning_path} is not a struct/union/enum (got {ty})" + ))) + } + } + } + + fn lookup_def_raw(root: &'a Value, name: &str) -> Result<&'a Value, AlkTypeError> { + let defs = root + .get(DEFS_KEY) + .and_then(Value::as_object) + .ok_or_else(|| AlkTypeError::Schema("bast: document missing $defs".to_string()))?; + defs.get(name).ok_or_else(|| { + AlkTypeError::Schema(format!("bast: $defs has no entry {name:?}")) + }) + } +} + +/// A named `$defs` entry, parsed into its typed form. +/// +/// The three composite kinds that can live at the top level: `struct`, +/// `union`, `enum` (the meta-schema's `TypeDef.oneOf`). Primitives, +/// arrays, and records only appear as field/element/value types +/// ([`BastType`]), not as named definitions. +#[derive(Debug, Clone)] +pub struct BastDef<'a> { + name: &'a str, + kind: BastDefKind<'a>, + source: &'a Value, +} + +impl<'a> BastDef<'a> { + /// The `$defs` key this definition was registered under. Empty + /// string for synthetic definitions produced by + /// [`BastDoc::resolve_typeref_as_def`] from an inline type. + pub fn name(&self) -> &'a str { + self.name + } + + /// The typed kind — struct, union, or enum. + pub fn kind(&self) -> &BastDefKind<'a> { + &self.kind + } + + /// The raw JSON node this definition was parsed from. Retained for + /// consumers that need properties the typed view doesn't expose yet + /// (e.g. the layout engines during the step 4 migration). + pub fn source(&self) -> &'a Value { + self.source + } + + fn parse(node: &'a Value, name: &'a str, path: &str) -> Result { + let kind_str = node + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: def {name:?} at {path} has no `kind` string" + )) + })?; + let alk_kind = AlkTypeKind::from_bast_str(kind_str)?; + let kind = match alk_kind { + AlkTypeKind::Struct => { + BastDefKind::Struct(BastStruct::parse(node, path)?) + } + AlkTypeKind::Union => { + BastDefKind::Union(BastUnion::parse(node, path)?) + } + AlkTypeKind::Enum => { + BastDefKind::Enum(BastEnum::parse(node, path)?) + } + other => { + return Err(AlkTypeError::Schema(format!( + "bast: def {name:?} at {path} has top-level kind {other}, but $defs entries must be struct/union/enum" + ))) + } + }; + Ok(Self { + name, + kind, + source: node, + }) + } +} + +/// The typed kind of a [`BastDef`]. +#[derive(Debug, Clone)] +pub enum BastDefKind<'a> { + /// `kind: "struct"` — an ordered list of fields. + Struct(BastStruct<'a>), + /// `kind: "union"` — discriminator + mapping of variant TypeRefs. + Union(BastUnion<'a>), + /// `kind: "enum"` — a non-empty list of string values. + Enum(BastEnum<'a>), +} + +impl<'a> BastDefKind<'a> { + /// The [`AlkTypeKind`] of this definition. + pub fn alk_kind(&self) -> AlkTypeKind { + match self { + BastDefKind::Struct(_) => AlkTypeKind::Struct, + BastDefKind::Union(_) => AlkTypeKind::Union, + BastDefKind::Enum(_) => AlkTypeKind::Enum, + } + } +} + +/// `kind: "struct"` — the most common top-level type. +/// +/// Field order is unambiguous: `fields` is an array, not an object with +/// `properties`, so byte order is the array position +/// ([bast-format.md §Design Principles](../docs/architecture/bast-format.md#design-principles) +/// #4). +#[derive(Debug, Clone)] +pub struct BastStruct<'a> { + endian: Endian, + align: Option, + fields: Vec>, + source: &'a Value, +} + +impl<'a> BastStruct<'a> { + /// The struct-level default endianness. Defaults to + /// [`Endian::Little`] when absent (ADR-003). + pub fn endian(&self) -> Endian { + self.endian + } + + /// The struct-level alignment, only meaningful in aligned static + /// mode (ADR-002/003). `None` when not declared. + pub fn align(&self) -> Option { + self.align + } + + /// The ordered field list. Array position is field order. + pub fn fields(&self) -> &[BastField<'a>] { + &self.fields + } + + /// The raw JSON node this struct was parsed from. + pub fn source(&self) -> &'a Value { + self.source + } + + fn parse(node: &'a Value, path: &str) -> Result { + let endian = parse_endian_opt(node).unwrap_or(Endian::Little); + let align = parse_align(node); + let raw_fields = node + .get("fields") + .and_then(Value::as_array) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: struct at {path} has no `fields` array" + )) + })?; + let mut fields = Vec::new(); + for (i, raw_field) in raw_fields.iter().enumerate() { + let field_path = format!("{path}.fields[{i}]"); + fields.push(BastField::parse(raw_field, &field_path)?); + } + Ok(Self { + endian, + align, + fields, + source: node, + }) + } +} + +/// A field within a struct or field-name-discriminator union. +#[derive(Debug, Clone)] +pub struct BastField<'a> { + name: &'a str, + ty: BastType<'a>, + endian: Option, + align: Option, + encoding: VariableEncoding, + max_length: Option, + source: &'a Value, +} + +impl<'a> BastField<'a> { + /// The field name. Guaranteed to match `^[a-zA-Z_][a-zA-Z0-9_]*$` by + /// the meta-schema; the parser does not re-check the pattern. + pub fn name(&self) -> &'a str { + self.name + } + + /// The field's type — a primitive, `$ref`, array, or record. + pub fn ty(&self) -> &BastType<'a> { + &self.ty + } + + /// Field-level endianness override. `None` means inherit the + /// struct/union default. + pub fn endian(&self) -> Option { + self.endian + } + + /// Field-level alignment override (aligned mode only). `None` means + /// inherit the struct default / natural alignment. + pub fn align(&self) -> Option { + self.align + } + + /// The variable-length encoding strategy. Defaults to + /// [`VariableEncoding::LengthPrefixed`] (ADR-003). + pub fn encoding(&self) -> VariableEncoding { + self.encoding + } + + /// The byte-length cap. Enforced as a validation constraint + /// (packed mode) or a fixed-size reservation (aligned mode) per + /// ADR-003. + pub fn max_length(&self) -> Option { + self.max_length + } + + /// The raw JSON node this field was parsed from. + pub fn source(&self) -> &'a Value { + self.source + } + + /// The effective endianness: this field's override, or the container + /// default. + pub fn effective_endian(&self, default: Endian) -> Endian { + self.endian.unwrap_or(default) + } + + fn parse(node: &'a Value, path: &str) -> Result { + let name = node + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: field at {path} has no `name` string" + )) + })?; + let raw_kind = node + .get("kind") + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: field {name:?} at {path} has no `kind`" + )) + })?; + let ty = BastType::parse(raw_kind, path)?; + let endian = parse_endian_opt(node); + let align = parse_align(node); + let encoding = parse_encoding(node); + let max_length = parse_max_length(node); + Ok(Self { + name, + ty, + endian, + align, + encoding, + max_length, + source: node, + }) + } +} + +/// `kind: "union"` — a tagged union with a byte-offset or field-name +/// discriminator. +/// +/// Variant `$ref`s are resolved **lazily** by the materializer/validator +/// via [`BastDoc::resolve_typeref`] — no compile-time inlining (the +/// replacement for v0.1.0's `inline_union_variant_refs`). +#[derive(Debug, Clone)] +pub struct BastUnion<'a> { + endian: Endian, + discriminator: BastDiscriminator<'a>, + fields: Vec>, + mapping: Vec<(&'a str, BastType<'a>)>, + source: &'a Value, +} + +impl<'a> BastUnion<'a> { + /// The union-level default endianness for variant fields. + pub fn endian(&self) -> Endian { + self.endian + } + + /// The discriminator — byte-offset or field-name. + pub fn discriminator(&self) -> &BastDiscriminator<'a> { + &self.discriminator + } + + /// The optional `fields` array. Only valid with field-name + /// discriminators (D-BAST-005); empty for byte-offset discriminators. + pub fn fields(&self) -> &[BastField<'a>] { + &self.fields + } + + /// The `mapping` entries, in document order. Each key is a + /// stringified discriminator value; each value is the variant + /// [`BastType`] (typically a `$ref`). + pub fn mapping(&self) -> &[(&'a str, BastType<'a>)] { + &self.mapping + } + + /// Look up the variant [`BastType`] for a discriminator value. + pub fn variant_for(&self, key: &str) -> Option<&BastType<'a>> { + self.mapping + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v) + } + + /// The raw JSON node this union was parsed from. + pub fn source(&self) -> &'a Value { + self.source + } + + fn parse(node: &'a Value, path: &str) -> Result { + let endian = parse_endian_opt(node).unwrap_or(Endian::Little); + let discriminator = BastDiscriminator::parse(node, path)?; + let raw_fields = node.get("fields").and_then(Value::as_array); + let fields = match raw_fields { + Some(arr) => { + if !matches!(discriminator, BastDiscriminator::Field { .. }) { + return Err(AlkTypeError::Schema(format!( + "bast: union at {path} has `fields` but a non-field discriminator (fields are only valid with field-name discriminators, D-BAST-005)" + ))); + } + let mut out = Vec::new(); + for (i, raw_field) in arr.iter().enumerate() { + let field_path = format!("{path}.fields[{i}]"); + out.push(BastField::parse(raw_field, &field_path)?); + } + out + } + None => { + if matches!(discriminator, BastDiscriminator::Field { .. }) { + return Err(AlkTypeError::Schema(format!( + "bast: field-name union at {path} has no `fields` array (the discriminator field must be declared)" + ))); + } + Vec::new() + } + }; + let raw_mapping = node + .get("mapping") + .and_then(Value::as_object) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: union at {path} has no `mapping` object" + )) + })?; + let mut mapping = Vec::new(); + for (key, value) in raw_mapping.iter() { + let entry_path = format!("{path}.mapping[{key}]"); + let ty = BastType::parse(value, &entry_path)?; + mapping.push((key.as_str(), ty)); + } + if mapping.is_empty() { + return Err(AlkTypeError::Schema(format!( + "bast: union at {path} has an empty `mapping`" + ))); + } + Ok(Self { + endian, + discriminator, + fields, + mapping, + source: node, + }) + } +} + +/// A union discriminator — byte-offset or field-name (ADR-003, D-BAST-005). +#[derive(Debug, Clone)] +pub enum BastDiscriminator<'a> { + /// A fixed-size integer at a known byte offset. Mapping keys are + /// stringified integers. + Byte { + /// Byte position of the discriminator within the union's buffer. + offset: usize, + /// The discriminator's integer width (uint8/uint16/uint32). + disc_type: AlkTypeKind, + }, + /// A length-prefixed string field within the union. Mapping keys are + /// string values matching the field's value. The `fields` array + /// declares the discriminator field alongside any shared fields. + Field { + /// The field name that holds the discriminator value. + name: &'a str, + }, +} + +impl<'a> BastDiscriminator<'a> { + fn parse(union_node: &'a Value, path: &str) -> Result { + let disc = union_node.get("discriminator").ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: union at {path} has no `discriminator`" + )) + })?; + let obj = disc.as_object().ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: union at {path} discriminator must be an object" + )) + })?; + let kind = obj + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: union at {path} discriminator has no `kind`" + )) + })?; + match kind { + "byte" => { + let offset = parse_usize_field(disc, "offset", path, "discriminator offset")? + .unwrap_or(0); + let type_str = obj + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: byte discriminator at {path} has no `type`" + )) + })?; + let disc_type = AlkTypeKind::from_bast_str(type_str)?; + if !matches!( + disc_type, + AlkTypeKind::Uint8 | AlkTypeKind::Uint16 | AlkTypeKind::Uint32 + ) { + return Err(AlkTypeError::Schema(format!( + "bast: byte discriminator at {path} type {disc_type} is not uint8/uint16/uint32" + ))); + } + Ok(BastDiscriminator::Byte { offset, disc_type }) + } + "field" => { + let name = obj + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: field discriminator at {path} has no `name`" + )) + })?; + Ok(BastDiscriminator::Field { name }) + } + other => Err(AlkTypeError::Schema(format!( + "bast: unknown discriminator kind {other:?} at {path}" + ))), + } + } +} + +/// `kind: "enum"` — a non-empty list of string values. +/// +/// Binary representation is a `u32` index into [`values`](Self::values), +/// 0-based, encoded per the struct's endianness. The BAST-native +/// validator (step 5) checks the materialized index against +/// `values.len()` — **the fix for the v0.1.0 dead constraint** where the +/// built-in `enum` keyword checked string membership but the materializer +/// emitted a numeric index that never matched. +#[derive(Debug, Clone)] +pub struct BastEnum<'a> { + values: Vec<&'a str>, + source: &'a Value, +} + +impl<'a> BastEnum<'a> { + /// The declared values, in declaration order. Non-empty (enforced + /// by the meta-schema's `minItems: 1` and re-checked here). + pub fn values(&self) -> &[&'a str] { + &self.values + } + + /// The raw JSON node this enum was parsed from. + pub fn source(&self) -> &'a Value { + self.source + } + + fn parse(node: &'a Value, path: &str) -> Result { + let raw_values = node + .get("values") + .and_then(Value::as_array) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: enum at {path} has no `values` array" + )) + })?; + if raw_values.is_empty() { + return Err(AlkTypeError::Schema(format!( + "bast: enum at {path} has an empty `values` array" + ))); + } + let mut values = Vec::with_capacity(raw_values.len()); + for (i, v) in raw_values.iter().enumerate() { + let s = v.as_str().ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: enum at {path} values[{i}] is not a string" + )) + })?; + values.push(s); + } + Ok(Self { + values, + source: node, + }) + } +} + +/// A type reference — the central mechanism for typing fields, array +/// elements, record values, and union variants. +/// +/// Four forms, matching the +/// [TypeRef](../docs/architecture/bast-format.md#typeref) `oneOf`: +/// primitive string, `$ref` object, array object, record object. +/// Composite kinds (`struct`/`union`/`enum`) only appear inline here when +/// a field/element/value is an anonymous composite; named composites go +/// through [`BastType::Ref`]. +#[derive(Debug, Clone)] +pub enum BastType<'a> { + /// A built-in primitive (`"uint32"`, `"string"`, etc.). + Primitive(AlkTypeKind), + /// A `$ref` to a named `$defs` entry: `{ "$ref": "#/$defs/Name" }`. + Ref(BastRef<'a>), + /// `kind: "array"` — a fixed-size array (count required in v1, + /// D-BAST-004). + Array(BastArray<'a>), + /// `kind: "record"` — a string-keyed map. + Record(BastRecord<'a>), + /// An inline `kind: "struct"`. + Struct(BastStruct<'a>), + /// An inline `kind: "union"`. + Union(BastUnion<'a>), + /// An inline `kind: "enum"`. + Enum(BastEnum<'a>), +} + +impl<'a> BastType<'a> { + /// The [`AlkTypeKind`] of this type. For [`Ref`](Self::Ref), this is + /// [`AlkTypeKind::Struct`] — the meta-schema only allows + /// struct/union/enum as named definitions, so a `$ref` always lands + /// on one of those. Consumers that need the concrete kind should + /// resolve the ref via [`BastDoc::resolve_typeref`] first. + pub fn alk_kind(&self) -> AlkTypeKind { + match self { + BastType::Primitive(k) => *k, + BastType::Ref(_) => AlkTypeKind::Struct, + BastType::Array(_) => AlkTypeKind::Array, + BastType::Record(_) => AlkTypeKind::Record, + BastType::Struct(_) => AlkTypeKind::Struct, + BastType::Union(_) => AlkTypeKind::Union, + BastType::Enum(_) => AlkTypeKind::Enum, + } + } + + fn parse(node: &'a Value, path: &str) -> Result { + if let Some(s) = node.as_str() { + let k = AlkTypeKind::from_bast_str(s)?; + return Ok(BastType::Primitive(k)); + } + let obj = node.as_object().ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: type at {path} is neither a primitive string nor an object: {node}" + )) + })?; + if let Some(ref_path) = obj.get("$ref").and_then(Value::as_str) { + let r = BastRef::parse(ref_path, path)?; + return Ok(BastType::Ref(r)); + } + let kind_str = obj + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: type at {path} has no `kind` and no `$ref`" + )) + })?; + let k = AlkTypeKind::from_bast_str(kind_str)?; + match k { + AlkTypeKind::Array => Ok(BastType::Array(BastArray::parse(node, path)?)), + AlkTypeKind::Record => Ok(BastType::Record(BastRecord::parse(node, path)?)), + AlkTypeKind::Struct => Ok(BastType::Struct(BastStruct::parse(node, path)?)), + AlkTypeKind::Union => Ok(BastType::Union(BastUnion::parse(node, path)?)), + AlkTypeKind::Enum => Ok(BastType::Enum(BastEnum::parse(node, path)?)), + other => Err(AlkTypeError::Schema(format!( + "bast: type at {path} has inline kind {other}, which is only valid as a primitive string or a $defs entry" + ))), + } + } + + fn from_def(def: BastDef<'a>) -> Self { + match def.kind { + BastDefKind::Struct(s) => BastType::Struct(s), + BastDefKind::Union(u) => BastType::Union(u), + BastDefKind::Enum(e) => BastType::Enum(e), + } + } +} + +/// A `$ref` to a named `$defs` entry, restricted to `#/$defs/`. +/// +/// The restriction keeps resolution a single hash lookup +/// ([`BastDoc::lookup_def`]) and eliminates the `normalize_refs` step +/// the v0.1.0 engine needed for TypeBox's bare-name refs. +#[derive(Debug, Clone)] +pub struct BastRef<'a> { + name: &'a str, +} + +impl<'a> BastRef<'a> { + /// The referenced `$defs` entry name (e.g. `Read` for + /// `#/$defs/Read`). + pub fn name(&self) -> &'a str { + self.name + } + + fn parse(ref_path: &'a str, path: &str) -> Result { + let name = ref_path.strip_prefix(REF_PREFIX).ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: $ref at {path} is {ref_path:?}, expected \"#/$defs/\"" + )) + })?; + if name.is_empty() { + return Err(AlkTypeError::Schema(format!( + "bast: $ref at {path} has an empty name after \"#/$defs/\"" + ))); + } + Ok(Self { name }) + } +} + +/// `kind: "array"` — a fixed-size array. +/// +/// `count` is required in v1 (D-BAST-004, aligning with OQ-001 — arrays +/// of variable-length elements without a count are deferred). The +/// element type is itself a [`BastType`], so nested `$ref`s work: +/// `{ "kind": "array", "element": { "$ref": "#/$defs/Point" }, "count": 4 }`. +#[derive(Debug, Clone)] +pub struct BastArray<'a> { + element: Box>, + count: usize, + source: &'a Value, +} + +impl<'a> BastArray<'a> { + /// The element type. + pub fn element(&self) -> &BastType<'a> { + &self.element + } + + /// The fixed element count. + pub fn count(&self) -> usize { + self.count + } + + /// The raw JSON node this array type was parsed from. + pub fn source(&self) -> &'a Value { + self.source + } + + fn parse(node: &'a Value, path: &str) -> Result { + let element_node = node.get("element").ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: array at {path} has no `element`" + )) + })?; + let element = BastType::parse(element_node, &format!("{path}.element"))?; + let count = parse_usize_field(node, "count", path, "array count")?.ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: array at {path} has no `count` (variable-length arrays are not supported in v1, D-BAST-004)" + )) + })?; + Ok(Self { + element: Box::new(element), + count, + source: node, + }) + } +} + +/// `kind: "record"` — a string-keyed map. +/// +/// Binary layout: a count-prefixed sequence of `(key, value)` pairs — +/// `[count: u32][key_len: u32][key_bytes][value]...` repeated `count` +/// times (per [bast-format.md §Record](../docs/architecture/bast-format.md#record)). +#[derive(Debug, Clone)] +pub struct BastRecord<'a> { + values: Box>, + source: &'a Value, +} + +impl<'a> BastRecord<'a> { + /// The value type. + pub fn values(&self) -> &BastType<'a> { + &self.values + } + + /// The raw JSON node this record type was parsed from. + pub fn source(&self) -> &'a Value { + self.source + } + + fn parse(node: &'a Value, path: &str) -> Result { + let values_node = node.get("values").ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: record at {path} has no `values`" + )) + })?; + let values = BastType::parse(values_node, &format!("{path}.values"))?; + Ok(Self { + values: Box::new(values), + source: node, + }) + } +} + +// --------------------------------------------------------------------------- +// Shared annotation parsers (BAST-property form). +// +// These read type-level/field-level properties (`endian`, `align`, +// `encoding`, `maxLength`) directly off a BAST node, instead of the +// v0.1.0 keyword-value objects that `schema::parse_*` read. Their +// *semantics* are unchanged (ADR-003); only the input location moved. +// They're `fn`s rather than methods on the typed nodes so the layout +// engines can call them on raw `&Value` during the step 4 migration. +// --------------------------------------------------------------------------- + +fn parse_endian_opt(node: &Value) -> Option { + match node.as_object().and_then(|o| o.get("endian")).and_then(Value::as_str) { + Some("big") => Some(Endian::Big), + Some("little") => Some(Endian::Little), + _ => None, + } +} + +fn parse_align(node: &Value) -> Option { + node.as_object() + .and_then(|o| o.get("align")) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) +} + +fn parse_max_length(node: &Value) -> Option { + node.as_object() + .and_then(|o| o.get("maxLength")) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) +} + +fn parse_encoding(node: &Value) -> VariableEncoding { + match node.as_object().and_then(|o| o.get("encoding")).and_then(Value::as_str) { + Some("offset-indirect") => VariableEncoding::OffsetIndirect, + _ => VariableEncoding::LengthPrefixed, + } +} + +fn parse_usize_field( + node: &Value, + key: &str, + path: &str, + label: &str, +) -> Result, AlkTypeError> { + let obj = match node.as_object() { + Some(o) => o, + None => return Ok(None), + }; + match obj.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(None), + Some(v) => { + let n = v.as_u64().ok_or_else(|| { + AlkTypeError::Schema(format!( + "bast: {label} at {path} is not a non-negative integer" + )) + })?; + let n = usize::try_from(n).map_err(|_| { + AlkTypeError::Schema(format!( + "bast: {label} at {path} (= {n}) overflows usize" + )) + })?; + Ok(Some(n)) + } + } +} + +impl<'a> std::fmt::Display for BastType<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BastType::Primitive(k) => write!(f, "{}", k.to_bast_str()), + BastType::Ref(r) => write!(f, "$ref:#/$defs/({})", r.name()), + BastType::Array(a) => write!(f, "array<{}>[{}]", a.element, a.count), + BastType::Record(r) => write!(f, "record<{}>", r.values()), + BastType::Struct(_) => write!(f, "struct"), + BastType::Union(_) => write!(f, "union"), + BastType::Enum(_) => write!(f, "enum"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn doc_from<'a>(root: &'a Value, root_name: &'a str) -> BastDoc<'a> { + BastDoc::new(root, root_name).expect("bast doc") + } + + // ----- BastDoc construction --------------------------------------- + + #[test] + fn new_parses_simple_struct_root() { + let root = json!({ + "$defs": { + "ChunkHeader": { + "kind": "struct", + "endian": "big", + "fields": [ + { "name": "channel_id", "kind": "uint32" }, + { "name": "length", "kind": "uint32" } + ] + } + } + }); + let d = BastDoc::new(&root, "ChunkHeader").expect("doc"); + assert_eq!(d.root_name(), "ChunkHeader"); + let def = d.root_def(); + assert!(matches!(def.kind(), BastDefKind::Struct(_))); + let s = match def.kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + assert_eq!(s.endian(), Endian::Big); + assert_eq!(s.fields().len(), 2); + assert_eq!(s.fields()[0].name(), "channel_id"); + assert!(matches!(s.fields()[0].ty(), BastType::Primitive(AlkTypeKind::Uint32))); + } + + #[test] + fn missing_defs_is_schema_error() { + let root = json!({ "type": "object" }); + let err = BastDoc::new(&root, "X").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn missing_root_def_is_schema_error() { + let root = json!({ "$defs": { "Other": { "kind": "struct", "fields": [] } } }); + let err = BastDoc::new(&root, "Missing").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn malformed_kind_is_schema_error_not_panic() { + let root = json!({ "$defs": { "S": { "kind": "not-a-real-kind" } } }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn top_level_primitive_kind_is_error() { + let root = json!({ "$defs": { "S": { "kind": "uint32" } } }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn struct_missing_fields_is_error() { + let root = json!({ "$defs": { "S": { "kind": "struct" } } }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn field_missing_name_is_error() { + let root = json!({ + "$defs": { "S": { "kind": "struct", "fields": [ { "kind": "uint32" } ] } } + }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn field_missing_kind_is_error() { + let root = json!({ + "$defs": { "S": { "kind": "struct", "fields": [ { "name": "id" } ] } } + }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + // ----- Endianness / annotations ----------------------------------- + + #[test] + fn struct_endian_defaults_to_little() { + let root = json!({ + "$defs": { "S": { "kind": "struct", "fields": [] } } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + assert_eq!(s.endian(), Endian::Little); + assert_eq!(s.align(), None); + } + + #[test] + fn struct_align_parsed() { + let root = json!({ + "$defs": { "S": { "kind": "struct", "align": 256, "fields": [] } } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + assert_eq!(s.align(), Some(256)); + } + + #[test] + fn field_endian_override_and_encoding() { + let root = json!({ + "$defs": { + "S": { + "kind": "struct", + "endian": "big", + "fields": [ + { "name": "blob", "kind": "bytes", "encoding": "offset-indirect", "maxLength": 256, "endian": "little" } + ] + } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + let f = &s.fields()[0]; + assert_eq!(f.name(), "blob"); + assert_eq!(f.endian(), Some(Endian::Little)); + assert_eq!(f.encoding(), VariableEncoding::OffsetIndirect); + assert_eq!(f.max_length(), Some(256)); + assert_eq!(f.effective_endian(Endian::Big), Endian::Little); + } + + #[test] + fn field_encoding_defaults_to_length_prefixed() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ { "name": "s", "kind": "string" } ] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + assert_eq!(s.fields()[0].encoding(), VariableEncoding::LengthPrefixed); + } + + // ----- TypeRef: primitives, $ref, array, record ------------------- + + #[test] + fn typeref_primitive_round_trips() { + let root = json!({ + "$defs": { "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": "int8" }, + { "name": "b", "kind": "uint64" }, + { "name": "c", "kind": "bool" }, + { "name": "d", "kind": "timestamp" } + ] } } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + assert!(matches!(s.fields()[0].ty(), BastType::Primitive(AlkTypeKind::Int8))); + assert!(matches!(s.fields()[1].ty(), BastType::Primitive(AlkTypeKind::Uint64))); + assert!(matches!(s.fields()[2].ty(), BastType::Primitive(AlkTypeKind::Boolean))); + assert!(matches!(s.fields()[3].ty(), BastType::Primitive(AlkTypeKind::Timestamp))); + } + + #[test] + fn typeref_ref_parses_name() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "status", "kind": { "$ref": "#/$defs/StatusCode" } } + ] }, + "StatusCode": { "kind": "enum", "values": ["Ok", "Error"] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + match s.fields()[0].ty() { + BastType::Ref(r) => assert_eq!(r.name(), "StatusCode"), + other => panic!("expected Ref, got {other:?}"), + } + } + + #[test] + fn typeref_ref_malformed_is_error() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "x", "kind": { "$ref": "not-a-pointer" } } + ] } + } + }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn typeref_ref_bare_name_is_error() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "x", "kind": { "$ref": "Read" } } + ] } + } + }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn typeref_array_parses_element_and_count() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "pts", "kind": { "kind": "array", "element": "float32", "count": 3 } } + ] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + match s.fields()[0].ty() { + BastType::Array(a) => { + assert_eq!(a.count(), 3); + assert!(matches!(a.element(), BastType::Primitive(AlkTypeKind::Float32))); + } + other => panic!("expected Array, got {other:?}"), + } + } + + #[test] + fn typeref_array_missing_count_is_error() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "pts", "kind": { "kind": "array", "element": "float32" } } + ] } + } + }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn typeref_array_with_ref_element() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "pts", "kind": { + "kind": "array", + "element": { "$ref": "#/$defs/Point" }, + "count": 4 + } } + ] }, + "Point": { "kind": "struct", "fields": [ + { "name": "x", "kind": "uint16" }, + { "name": "y", "kind": "uint16" } + ] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + match s.fields()[0].ty() { + BastType::Array(a) => { + assert_eq!(a.count(), 4); + assert!(matches!(a.element(), BastType::Ref(r) if r.name() == "Point")); + } + other => panic!("expected Array, got {other:?}"), + } + } + + #[test] + fn typeref_record_parses_values() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "counts", "kind": { "kind": "record", "values": "uint32" } } + ] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + match s.fields()[0].ty() { + BastType::Record(r) => { + assert!(matches!(r.values(), BastType::Primitive(AlkTypeKind::Uint32))); + } + other => panic!("expected Record, got {other:?}"), + } + } + + #[test] + fn typeref_record_missing_values_is_error() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "counts", "kind": { "kind": "record" } } + ] } + } + }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + // ----- $ref resolution -------------------------------------------- + + #[test] + fn resolve_ref_returns_named_def() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "status", "kind": { "$ref": "#/$defs/StatusCode" } } + ] }, + "StatusCode": { "kind": "enum", "values": ["Ok", "Error"] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + let r = match s.fields()[0].ty() { + BastType::Ref(r) => r, + _ => unreachable!(), + }; + let resolved = d.resolve_ref(r).expect("resolve"); + match resolved.kind() { + BastDefKind::Enum(e) => assert_eq!(e.values(), &["Ok", "Error"]), + other => panic!("expected Enum, got {other:?}"), + } + } + + #[test] + fn resolve_ref_unknown_target_is_error() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "x", "kind": { "$ref": "#/$defs/Ghost" } } + ] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + let r = match s.fields()[0].ty() { + BastType::Ref(r) => r, + _ => unreachable!(), + }; + let err = d.resolve_ref(r).unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn resolve_typeref_derefs_ref_one_level() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "status", "kind": { "$ref": "#/$defs/StatusCode" } } + ] }, + "StatusCode": { "kind": "enum", "values": ["Ok"] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + let resolved = d.resolve_typeref(s.fields()[0].ty()).expect("resolve"); + assert!(matches!(resolved, BastType::Enum(_))); + } + + #[test] + fn resolve_typeref_passthrough_for_inline_types() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "id", "kind": "uint32" } + ] } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + let resolved = d.resolve_typeref(s.fields()[0].ty()).expect("resolve"); + assert!(matches!(resolved, BastType::Primitive(AlkTypeKind::Uint32))); + } + + #[test] + fn lookup_def_returns_raw_node() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [] }, + "Other": { "kind": "enum", "values": ["X"] } + } + }); + let d = doc_from(&root, "S"); + let raw = d.lookup_def("Other").expect("lookup"); + assert_eq!(raw["kind"], "enum"); + } + + // ----- Enum -------------------------------------------------------- + + #[test] + fn enum_parses_values_in_order() { + let root = json!({ + "$defs": { + "E": { "kind": "enum", "values": ["Ok", "Error", "Pending"] } + } + }); + let d = doc_from(&root, "E"); + let e = match d.root_def().kind() { + BastDefKind::Enum(e) => e, + _ => unreachable!(), + }; + assert_eq!(e.values(), &["Ok", "Error", "Pending"]); + } + + #[test] + fn enum_empty_values_is_error() { + let root = json!({ "$defs": { "E": { "kind": "enum", "values": [] } } }); + let err = BastDoc::new(&root, "E").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn enum_missing_values_is_error() { + let root = json!({ "$defs": { "E": { "kind": "enum" } } }); + let err = BastDoc::new(&root, "E").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn enum_non_string_value_is_error() { + let root = json!({ "$defs": { "E": { "kind": "enum", "values": ["Ok", 5] } } }); + let err = BastDoc::new(&root, "E").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + // ----- Union: byte discriminator ----------------------------------- + + #[test] + fn union_byte_disc_parses() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "endian": "big", + "discriminator": { "kind": "byte", "offset": 0, "type": "uint8" }, + "mapping": { + "1": { "$ref": "#/$defs/Init" }, + "3": { "$ref": "#/$defs/Open" } + } + }, + "Init": { "kind": "struct", "fields": [] }, + "Open": { "kind": "struct", "fields": [] } + } + }); + let d = doc_from(&root, "U"); + let u = match d.root_def().kind() { + BastDefKind::Union(u) => u, + _ => unreachable!(), + }; + assert_eq!(u.endian(), Endian::Big); + assert!(matches!(u.discriminator(), BastDiscriminator::Byte { offset: 0, disc_type: AlkTypeKind::Uint8 })); + assert_eq!(u.fields().len(), 0); + assert_eq!(u.mapping().len(), 2); + assert!(u.variant_for("1").is_some()); + assert!(u.variant_for("99").is_none()); + } + + #[test] + fn union_byte_disc_uint16_type() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "byte", "offset": 4, "type": "uint16" }, + "mapping": { "10": { "$ref": "#/$defs/V" } } + }, + "V": { "kind": "struct", "fields": [] } + } + }); + let d = doc_from(&root, "U"); + let u = match d.root_def().kind() { + BastDefKind::Union(u) => u, + _ => unreachable!(), + }; + assert!(matches!(u.discriminator(), BastDiscriminator::Byte { offset: 4, disc_type: AlkTypeKind::Uint16 })); + } + + #[test] + fn union_byte_disc_invalid_type_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "byte", "type": "float32" }, + "mapping": { "1": { "$ref": "#/$defs/V" } } + }, + "V": { "kind": "struct", "fields": [] } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn union_byte_disc_missing_type_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "byte", "offset": 0 }, + "mapping": { "1": { "$ref": "#/$defs/V" } } + }, + "V": { "kind": "struct", "fields": [] } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn union_missing_discriminator_is_error() { + let root = json!({ + "$defs": { + "U": { "kind": "union", "mapping": { "1": { "$ref": "#/$defs/V" } } }, + "V": { "kind": "struct", "fields": [] } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn union_missing_mapping_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "byte", "offset": 0, "type": "uint8" } + } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn union_empty_mapping_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "byte", "offset": 0, "type": "uint8" }, + "mapping": {} + } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn union_unknown_disc_kind_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "magic" }, + "mapping": { "1": { "$ref": "#/$defs/V" } } + }, + "V": { "kind": "struct", "fields": [] } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + // ----- Union: field-name discriminator (D-BAST-005) --------------- + + #[test] + fn union_field_disc_parses_with_fields() { + let root = json!({ + "$defs": { + "Event": { + "kind": "union", + "discriminator": { "kind": "field", "name": "type" }, + "fields": [ { "name": "type", "kind": "string" } ], + "mapping": { "data": { "$ref": "#/$defs/Data" } } + }, + "Data": { "kind": "struct", "fields": [] } + } + }); + let d = doc_from(&root, "Event"); + let u = match d.root_def().kind() { + BastDefKind::Union(u) => u, + _ => unreachable!(), + }; + assert!(matches!(u.discriminator(), BastDiscriminator::Field { name } if *name == "type")); + assert_eq!(u.fields().len(), 1); + assert_eq!(u.fields()[0].name(), "type"); + assert!(matches!(u.variant_for("data"), Some(BastType::Ref(_)))); + } + + #[test] + fn union_field_disc_missing_fields_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "field", "name": "type" }, + "mapping": { "data": { "$ref": "#/$defs/V" } } + }, + "V": { "kind": "struct", "fields": [] } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn union_field_disc_missing_name_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "field" }, + "mapping": { "data": { "$ref": "#/$defs/V" } } + }, + "V": { "kind": "struct", "fields": [] } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn union_byte_disc_with_fields_is_error() { + let root = json!({ + "$defs": { + "U": { + "kind": "union", + "discriminator": { "kind": "byte", "offset": 0, "type": "uint8" }, + "fields": [ { "name": "type", "kind": "string" } ], + "mapping": { "1": { "$ref": "#/$defs/V" } } + }, + "V": { "kind": "struct", "fields": [] } + } + }); + let err = BastDoc::new(&root, "U").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + // ----- Nested composition ------------------------------------------ + + #[test] + fn nested_struct_with_ref_field() { + let root = json!({ + "$defs": { + "Outer": { + "kind": "struct", + "fields": [ + { "name": "magic", "kind": "uint32" }, + { "name": "body", "kind": { "$ref": "#/$defs/Inner" } } + ] + }, + "Inner": { + "kind": "struct", + "fields": [ { "name": "count", "kind": "uint8" } ] + } + } + }); + let d = doc_from(&root, "Outer"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + assert!(matches!(s.fields()[0].ty(), BastType::Primitive(AlkTypeKind::Uint32))); + assert!(matches!(s.fields()[1].ty(), BastType::Ref(r) if r.name() == "Inner")); + } + + #[test] + fn inline_struct_field_parses() { + let root = json!({ + "$defs": { + "S": { + "kind": "struct", + "fields": [ + { "name": "inner", "kind": { + "kind": "struct", + "fields": [ { "name": "x", "kind": "uint16" } ] + } } + ] + } + } + }); + let d = doc_from(&root, "S"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + match s.fields()[0].ty() { + BastType::Struct(inner) => assert_eq!(inner.fields().len(), 1), + other => panic!("expected inline Struct, got {other:?}"), + } + } + + // ----- Display ----------------------------------------------------- + + #[test] + fn bast_type_display_formats() { + let prim = BastType::Primitive(AlkTypeKind::Uint32); + assert_eq!(format!("{prim}"), "uint32"); + let arr = BastType::Array(BastArray { + element: Box::new(BastType::Primitive(AlkTypeKind::Float32)), + count: 3, + source: &Value::Null, + }); + assert_eq!(format!("{arr}"), "array[3]"); + } + + // ----- Overflow-safe usize parsing --------------------------------- + + #[test] + fn array_count_non_integer_is_error() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": { "kind": "array", "element": "uint8", "count": "three" } } + ] } + } + }); + let err = BastDoc::new(&root, "S").unwrap_err(); + assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); + } + + #[test] + fn array_count_large_u64_parses_on_64bit() { + let root = json!({ + "$defs": { + "S": { "kind": "struct", "fields": [ + { "name": "a", "kind": { "kind": "array", "element": "uint8", "count": u64::MAX } } + ] } + } + }); + let d = BastDoc::new(&root, "S").expect("parses on usize>=u64 targets"); + let s = match d.root_def().kind() { + BastDefKind::Struct(s) => s, + _ => unreachable!(), + }; + match s.fields()[0].ty() { + BastType::Array(a) => assert_eq!(a.count(), usize::try_from(u64::MAX).unwrap()), + other => panic!("expected Array, got {other:?}"), + } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 93ce417..267edd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ #[macro_use] mod macros; +pub mod bast; pub mod bast_meta; pub mod builder; pub mod data_access; @@ -42,6 +43,10 @@ pub mod tunion; pub mod validation; pub use bast_meta::BAST_META_SCHEMA; +pub use bast::{ + BastArray, BastDef, BastDefKind, BastDiscriminator, BastDoc, BastEnum, BastField, BastRef, + BastRecord, BastStruct, BastType, BastUnion, +}; pub use builder::{Definitions, Discriminator, Schema}; pub use engine::{LayoutMode, AlkTypeEngine}; pub use error::AlkTypeError;