Files
alktype/src/bast.rs
T
deepseek-v4-pro 510553d800 Remove the Timestamp kind
The Timestamp kind was a residual from an early research reference. It
was byte-identical to String everywhere (length-prefixed UTF-8) and its
only distinguishing behavior was a hand-rolled non-strict RFC 3339 check
that the docs admitted was incomplete (Feb 31 passes, seconds range
unchecked, no leap seconds). JSON-level timestamp validation is
jsonschema's job (format: date-time on the validate_json path), not
alktype's.

Removes the AlkTypeKind::Timestamp variant, its to_bast_str/from_bast_str
mapping, the builder's Schema::timestamp() constructor, the
validate_timestamp/is_rfc3339_timestamp validator arms, and the
materializer/reader/engine timestamp arms. Updates the meta-schema
primitive enum (14 -> 13), the spec docs (bast-format.md, schema-layer.md,
builder.md, validation.md, overview.md, data-access.md, README.md), and
the kind-count references (19 -> 18).

Verification: cargo test --release (407 pass), cargo clippy --all-targets
-- -D warnings (clean), cargo doc --no-deps (clean), cargo build --target
wasm32-unknown-unknown --release (clean).
2026-08-16 09:12:33 +00:00

1761 lines
60 KiB
Rust

//! BAST document parser — the typed surface over a BAST (Binary Abstract
//! Syntax Tree) document that the layout engines, materializer, and
//! BAST-native validator walk.
//!
//! 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/<name>`. 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<Self, AlkTypeError> {
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 ref-normalization
/// machinery. BAST refs are always `#/$defs/<name>`;
/// 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 — variant refs stay as
/// [`BastRef`]s and resolve on demand.
pub fn resolve_ref(&self, r: &BastRef<'a>) -> Result<BastDef<'a>, 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<BastType<'a>, 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<BastDef<'a>, 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<Self, AlkTypeError> {
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<usize>,
fields: Vec<BastField<'a>>,
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<usize> {
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<Self, AlkTypeError> {
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<Endian>,
align: Option<usize>,
encoding: VariableEncoding,
max_length: Option<usize>,
source: &'a Value,
}
impl<'a> BastField<'a> {
/// Construct a synthetic field wrapping `ty` with no annotations.
/// Used internally by the materializer for array elements, record
/// values, and union variants — these are TypeRefs, not fields, so
/// they don't carry field-level annotations. `source` is the
/// `BastType`'s source node (or `&Value::Null` for primitives).
pub(crate) fn synthetic(ty: BastType<'a>, source: &'a Value) -> Self {
Self {
name: "",
ty,
endian: None,
align: None,
encoding: VariableEncoding::LengthPrefixed,
max_length: None,
source,
}
}
/// 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<Endian> {
self.endian
}
/// Field-level alignment override (aligned mode only). `None` means
/// inherit the struct default / natural alignment.
pub fn align(&self) -> Option<usize> {
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<usize> {
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<Self, AlkTypeError> {
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.
#[derive(Debug, Clone)]
pub struct BastUnion<'a> {
endian: Endian,
discriminator: BastDiscriminator<'a>,
fields: Vec<BastField<'a>>,
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<Self, AlkTypeError> {
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<Self, AlkTypeError> {
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<Self, AlkTypeError> {
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.
///
/// Seven forms, matching the
/// [TypeRef](../docs/architecture/bast-format.md#typeref) `oneOf`:
/// primitive string, `$ref` object, array object, record object, and
/// inline `struct`/`union`/`enum`. Composite kinds (`struct`/`union`/
/// `enum`) 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<Self, AlkTypeError> {
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/<name>`.
///
/// The restriction keeps resolution a single hash lookup
/// ([`BastDoc::lookup_def`]); the v0.1.0 engine needed a normalization
/// pass for TypeBox's bare-name refs, but BAST forbids them.
#[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<Self, AlkTypeError> {
let name = ref_path.strip_prefix(REF_PREFIX).ok_or_else(|| {
AlkTypeError::Schema(format!(
"bast: $ref at {path} is {ref_path:?}, expected \"#/$defs/<name>\""
))
})?;
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<BastType<'a>>,
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<Self, AlkTypeError> {
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<BastType<'a>>,
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<Self, AlkTypeError> {
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. Their *semantics*
// are unchanged (ADR-003); only the input location moved from the
// v0.1.0 keyword-value objects to BAST type-level properties. 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<Endian> {
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<usize> {
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<usize> {
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<Option<usize>, 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": "bytes" }
] } }
});
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::Bytes)));
}
#[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<float32>[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:?}"),
}
}
}