Files
alktype/src/bast.rs
T
glm-5.3-flash 9803d3b768 Pre-publish review #008: gate int_keys on canonical keys, restore no-prealloc array rule
Review #008 (docs/reviews/008-pre-publish-review.md) audits the two
post-#007 unreviewed commits (dea96f0 bench port, d4635d2 perf) before
the first crates.io publish of 0.3.0.

- F1: the int_keys integer dispatch accepted non-canonical mapping
  keys ("01", "+1" parse as u64 1) — the reader dispatched disc 1
  while the materializer, validation plan, and tunion rejected the
  same buffer. compile_int_keys now builds the table only when every
  key is canonical (v.to_string() == key); otherwise the string
  fallback applies (agreement restored, perf kept for canonical
  mappings). Tests: r8_non_canonical_mapping_key_disables_int_dispatch,
  r8_canonical_mapping_keys_keep_int_dispatch.
- F2: d4635d2 reintroduced Vec::with_capacity(count) on both array
  materializers. Bounded per array by MAX_ARRAY_ELEMENTS but nesting
  compounds: probe (counting allocator) measured ~477 MB simultaneous
  allocation from a ~1 KB schema + empty buffer (100-level stride-0
  chain, all legal under the caps). H1's layer-1 rule restored:
  Vec::new() + push. Bench unchanged (packet read 220µs vs 246µs
  baseline). Test: r8_deeply_nested_stride0_array_rejects_before_bulk_prealloc.
- N3a disposition (review #007's deferred item): BastField::synthetic
  (pub(crate), zero callers, #[allow(dead_code)]) deleted; the seven
  source() accessors are public API and stay (semver decision —
  removal needs an explicit ask); resolve_typeref_as_def's inline
  struct/union/enum arms probe-verified reachable (inline struct
  union variants are legal) — kept.
- CHANGELOG: 0.3.0 entry (compiled forms, breaking surface, hardening
  fixes, coverage). README: ReadPlan/ValidationPlan roles, union
  conventions, untrusted-schema bounds.

Verification: 569 tests green (491 lib + 17 + 34 + 15 + 12, + 2
ignored doctests), clippy -D warnings clean, cargo doc 0 warnings,
wasm32 build green, cargo publish --dry-run clean.
2026-09-07 10:58:49 +00:00

2020 lines
72 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 owns its data (ADR-012 §2a). 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. Ownership (not borrowing) lets [`BastDoc`] be stored on
//! its consumers (`AlkTypeEngine`, `LayoutBuilder`) without lifetime
//! entanglement — the clone happens once at [`BastDoc::new`], not per
//! field.
//!
//! `$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, MAX_ALIGN, MAX_ARRAY_ELEMENTS, MAX_LENGTH,
};
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 is **owned** (ADR-012 §2a): `new`
/// clones the root `Value` and the root name into the doc, so the doc
/// can be stored on consumers without borrowing from the caller's
/// input.
#[derive(Debug, Clone)]
pub struct BastDoc {
root: Value,
root_name: String,
root_def: BastDef,
}
impl BastDoc {
/// 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 cloned into the doc so lazy `$ref` resolution
/// ([`BastDoc::resolve_typeref`]) can reach any `$defs` entry at
/// access time.
pub fn new(root: &Value, root_name: &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, "", root)?;
let doc = Self {
root: root.clone(),
root_name: root_name.to_string(),
root_def,
};
Ok(doc)
}
/// The root type name this doc was compiled with.
pub fn root_name(&self) -> &str {
&self.root_name
}
/// The parsed root type definition.
pub fn root_def(&self) -> &BastDef {
&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<&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) -> Result<BastDef, AlkTypeError> {
let raw = self.lookup_def(r.name())?;
BastDef::parse(raw, r.name(), "", &self.root)
}
/// 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) -> Result<BastType, 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,
owning_path: &str,
) -> Result<BastDef, AlkTypeError> {
match ty {
BastType::Ref(r) => self.resolve_ref(r),
BastType::Struct(s) => Ok(BastDef {
name: String::new(),
kind: BastDefKind::Struct(s.clone()),
source: s.source.clone(),
}),
BastType::Union(u) => Ok(BastDef {
name: String::new(),
kind: BastDefKind::Union(u.clone()),
source: u.source.clone(),
}),
BastType::Enum(e) => Ok(BastDef {
name: String::new(),
kind: BastDefKind::Enum(e.clone()),
source: e.source.clone(),
}),
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<'d>(root: &'d Value, name: &str) -> Result<&'d 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 {
name: String,
kind: BastDefKind,
source: Value,
}
impl BastDef {
/// 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) -> &str {
&self.name
}
/// The typed kind — struct, union, or enum.
pub fn kind(&self) -> &BastDefKind {
&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) -> &Value {
&self.source
}
fn parse(node: &Value, name: &str, path: &str, doc_root: &Value) -> 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, doc_root)?)
}
AlkTypeKind::Union => {
BastDefKind::Union(BastUnion::parse(node, path, doc_root)?)
}
AlkTypeKind::Enum => {
BastDefKind::Enum(BastEnum::parse(node, path, doc_root)?)
}
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: name.to_string(),
kind,
source: node.clone(),
})
}
}
/// The typed kind of a [`BastDef`].
#[derive(Debug, Clone)]
pub enum BastDefKind {
/// `kind: "struct"` — an ordered list of fields.
Struct(BastStruct),
/// `kind: "union"` — discriminator + mapping of variant TypeRefs.
Union(BastUnion),
/// `kind: "enum"` — a non-empty list of string values.
Enum(BastEnum),
}
impl BastDefKind {
/// 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 {
endian: Endian,
align: Option<usize>,
fields: Vec<BastField>,
source: Value,
}
impl BastStruct {
/// 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] {
&self.fields
}
/// The raw JSON node this struct was parsed from.
pub fn source(&self) -> &Value {
&self.source
}
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let endian = parse_endian_opt(node).unwrap_or(Endian::Little);
let align = parse_align(node, path)?;
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, doc_root)?);
}
Ok(Self {
endian,
align,
fields,
source: node.clone(),
})
}
}
/// A field within a struct or field-name-discriminator union.
#[derive(Debug, Clone)]
pub struct BastField {
name: String,
ty: BastType,
endian: Option<Endian>,
align: Option<usize>,
encoding: VariableEncoding,
max_length: Option<usize>,
source: Value,
}
impl BastField {
/// 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) -> &str {
&self.name
}
/// The field's type — a primitive, `$ref`, array, or record.
pub fn ty(&self) -> &BastType {
&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. Only accepted on `string` and `bytes` fields (review
/// #006 N3: on any other kind the annotation was silently
/// unenforced by `validate_bytes`; rejected at parse now).
pub fn max_length(&self) -> Option<usize> {
self.max_length
}
/// The raw JSON node this field was parsed from.
pub fn source(&self) -> &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: &Value, path: &str, doc_root: &Value) -> 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, doc_root)?;
let max_length = parse_max_length(node, path)?;
if max_length.is_some() && !matches!(
&ty,
BastType::Primitive(AlkTypeKind::String) | BastType::Primitive(AlkTypeKind::Bytes)
) {
return Err(AlkTypeError::Schema(format!(
"bast: field {name:?} at {path} declares `maxLength` but its kind \
({ty}) is not a variable-length primitive — `maxLength` is only \
honored on `string` and `bytes` fields (review #006 N3: on any \
other kind the annotation was silently unenforced by \
validate_bytes)"
)));
}
let endian = parse_endian_opt(node);
let align = parse_align(node, path)?;
let encoding = parse_encoding(node);
Ok(Self {
name: name.to_string(),
ty,
endian,
align,
encoding,
max_length,
source: node.clone(),
})
}
}
/// `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 {
endian: Endian,
discriminator: BastDiscriminator,
fields: Vec<BastField>,
mapping: Vec<(String, BastType)>,
source: Value,
}
impl BastUnion {
/// 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 {
&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] {
&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) -> &[(String, BastType)] {
&self.mapping
}
/// Look up the variant [`BastType`] for a discriminator value.
pub fn variant_for(&self, key: &str) -> Option<&BastType> {
self.mapping
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
}
/// The raw JSON node this union was parsed from.
pub fn source(&self) -> &Value {
&self.source
}
fn parse(node: &Value, path: &str, doc_root: &Value) -> 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, doc_root)?);
}
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, doc_root)?;
mapping.push((key.clone(), ty));
}
if mapping.is_empty() {
return Err(AlkTypeError::Schema(format!(
"bast: union at {path} has an empty `mapping`"
)));
}
if let BastDiscriminator::Field { name } = &discriminator {
if !fields.iter().any(|f| f.name() == name.as_str()) {
return Err(AlkTypeError::Schema(format!(
"bast: field-name union at {path} has no field {name:?} in `fields` (the discriminator field must be declared)"
)));
}
let mut names: Vec<&str> = fields.iter().map(|f| f.name()).collect();
names.sort_unstable();
let dup = names.windows(2).find(|pair| pair[0] == pair[1]);
if let Some([a, _]) = dup {
return Err(AlkTypeError::Schema(format!(
"bast: field-name union at {path} declares field {a:?} more than once in \
`fields` (the shared-then-variant wire convention lays the shared fields \
out once; a variant must not re-declare the discriminator or any shared \
field)"
)));
}
if fields[0].name() != name.as_str() {
return Err(AlkTypeError::Schema(format!(
"bast: field-name union at {path} has the discriminator field {name:?} at \
position {}, but it must be the first entry in `fields` (the packed reader \
reads the discriminator at the union's start offset; a later position would \
make it read the wrong field's bytes — review #006 H3 item 2)",
fields
.iter()
.position(|f| f.name() == name.as_str())
.expect("checked above")
)));
}
for (key, variant_ty) in &mapping {
let variant_path = format!("{path}.mapping[{key}]");
let clash = match variant_ty {
BastType::Struct(s) => s
.fields()
.iter()
.find(|vf| fields.iter().any(|sf| sf.name() == vf.name()))
.map(|vf| vf.name().to_string()),
BastType::Ref(r) => {
let variant_node = BastDoc::lookup_def_raw(doc_root, r.name())?;
match BastType::parse(variant_node, &variant_path, doc_root)? {
BastType::Struct(s) => s
.fields()
.iter()
.find(|vf| fields.iter().any(|sf| sf.name() == vf.name()))
.map(|vf| vf.name().to_string()),
other => {
return Err(AlkTypeError::Schema(format!(
"bast: union at {path} variant '{key}' must be a struct, got {}",
other.alk_kind()
)));
}
}
}
other => {
return Err(AlkTypeError::Schema(format!(
"bast: union at {path} variant '{key}' must be a struct, got {}",
other.alk_kind()
)));
}
};
if let Some(field_name) = clash {
return Err(AlkTypeError::Schema(format!(
"bast: union at {path} variant '{key}' re-declares field {field_name:?} \
which is already in the union's `fields` (the shared-then-variant wire \
convention forbids variants from re-declaring shared fields)"
)));
}
}
}
Ok(Self {
endian,
discriminator,
fields,
mapping,
source: node.clone(),
})
}
}
/// A union discriminator — byte-offset or field-name (ADR-003, D-BAST-005).
#[derive(Debug, Clone)]
pub enum BastDiscriminator {
/// 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 (which must be the first entry)
/// alongside any shared fields.
Field {
/// The field name that holds the discriminator value.
name: String,
},
}
impl BastDiscriminator {
fn parse(union_node: &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: name.to_string(),
})
}
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 {
values: Vec<String>,
source: Value,
}
impl BastEnum {
/// The declared values, in declaration order. Non-empty (enforced
/// by the meta-schema's `minItems: 1` and re-checked here).
pub fn values(&self) -> &[String] {
&self.values
}
/// The raw JSON node this enum was parsed from.
pub fn source(&self) -> &Value {
&self.source
}
fn parse(node: &Value, path: &str, _doc_root: &Value) -> 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.to_string());
}
Ok(Self {
values,
source: node.clone(),
})
}
}
/// 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 built-in primitive (`"uint32"`, `"string"`, etc.).
Primitive(AlkTypeKind),
/// A `$ref` to a named `$defs` entry: `{ "$ref": "#/$defs/Name" }`.
Ref(BastRef),
/// `kind: "array"` — a fixed-size array (count required in v1,
/// D-BAST-004).
Array(BastArray),
/// `kind: "record"` — a string-keyed map.
Record(BastRecord),
/// An inline `kind: "struct"`.
Struct(BastStruct),
/// An inline `kind: "union"`.
Union(BastUnion),
/// An inline `kind: "enum"`.
Enum(BastEnum),
}
impl BastType {
/// 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: &Value, path: &str, doc_root: &Value) -> 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, doc_root)?)),
AlkTypeKind::Record => Ok(BastType::Record(BastRecord::parse(node, path, doc_root)?)),
AlkTypeKind::Struct => Ok(BastType::Struct(BastStruct::parse(node, path, doc_root)?)),
AlkTypeKind::Union => Ok(BastType::Union(BastUnion::parse(node, path, doc_root)?)),
AlkTypeKind::Enum => Ok(BastType::Enum(BastEnum::parse(node, path, doc_root)?)),
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) -> 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 {
name: String,
}
impl BastRef {
/// The referenced `$defs` entry name (e.g. `Read` for
/// `#/$defs/Read`).
pub fn name(&self) -> &str {
&self.name
}
fn parse(ref_path: &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: name.to_string(),
})
}
}
/// `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 {
element: Box<BastType>,
count: usize,
source: Value,
}
impl BastArray {
/// The element type.
pub fn element(&self) -> &BastType {
&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) -> &Value {
&self.source
}
fn parse(node: &Value, path: &str, doc_root: &Value) -> 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"), doc_root)?;
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)"
))
})?;
if count > MAX_ARRAY_ELEMENTS {
return Err(AlkTypeError::Schema(format!(
"bast: array at {path} declares count {count}, which exceeds the \
compile-time limit of {MAX_ARRAY_ELEMENTS} elements (untrusted schemas \
must not be able to request unbounded per-element expansion)"
)));
}
Ok(Self {
element: Box::new(element),
count,
source: node.clone(),
})
}
}
/// `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 {
values: Box<BastType>,
source: Value,
}
impl BastRecord {
/// The value type.
pub fn values(&self) -> &BastType {
&self.values
}
/// The raw JSON node this record type was parsed from.
pub fn source(&self) -> &Value {
&self.source
}
fn parse(node: &Value, path: &str, doc_root: &Value) -> 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"), doc_root)?;
Ok(Self {
values: Box::new(values),
source: node.clone(),
})
}
}
// ---------------------------------------------------------------------------
// 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,
}
}
/// Parse a struct- or field-level `align` annotation. Over-sized values
/// are a clean `Schema` error (review #006 N2: an unbounded align let a
/// one-field schema declare an exabyte-scale layout — `align_up` rounds
/// the running offset by the full annotation).
fn parse_align(node: &Value, path: &str) -> Result<Option<usize>, AlkTypeError> {
let raw = match node.as_object().and_then(|o| o.get("align")) {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
let n = raw.as_u64().ok_or_else(|| {
AlkTypeError::Schema(format!(
"bast: align at {path} is not a non-negative integer"
))
})?;
let n = usize::try_from(n).map_err(|_| {
AlkTypeError::Schema(format!(
"bast: align at {path} (= {n}) overflows usize"
))
})?;
if n > MAX_ALIGN {
return Err(AlkTypeError::Schema(format!(
"bast: align at {path} (= {n}) exceeds the maximum of {MAX_ALIGN} \
(review #006 N2: honest layouts never need more than page granularity)"
)));
}
Ok(Some(n))
}
/// Parse a field's `maxLength` annotation. Over-sized values are a
/// clean `Schema` error (review #007 F2 — the N2 pattern: a
/// reservation contributes its full `maxLength` to `total_size`, so an
/// unbounded value lets a one-field schema declare terabyte-scale
/// layouts). Values that overflow `usize` are rejected, not silently
/// dropped — a silent drop would change layout semantics without
/// telling the consumer.
fn parse_max_length(node: &Value, path: &str) -> Result<Option<usize>, AlkTypeError> {
let raw = match node.as_object().and_then(|o| o.get("maxLength")) {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
let n = raw.as_u64().ok_or_else(|| {
AlkTypeError::Schema(format!(
"bast: maxLength at {path} is not a non-negative integer"
))
})?;
let n = usize::try_from(n).map_err(|_| {
AlkTypeError::Schema(format!(
"bast: maxLength at {path} (= {n}) overflows usize"
))
})?;
if n > MAX_LENGTH {
return Err(AlkTypeError::Schema(format!(
"bast: maxLength at {path} (= {n}) exceeds the maximum of {MAX_LENGTH} \
(review #007 F2: a reservation contributes its full value to total_size; \
honest layouts never need more than the largest legal array)"
)));
}
Ok(Some(n))
}
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 std::fmt::Display for BastType {
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(root: &Value, root_name: &str) -> BastDoc {
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);
}
// ----- N3: maxLength is string/bytes-only --------------------------
#[test]
fn n3_max_length_on_record_field_rejected_at_parse() {
// N3's probe shape: on a record field the annotation did
// nothing — validate_bytes never bounded the entry region by
// it. Rejected at parse now, mirroring M5's aligned-mode
// posture.
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"fields": [
{ "name": "counts", "kind": { "kind": "record", "values": "uint16" }, "maxLength": 8 }
]
}
}
});
let err = BastDoc::new(&root, "S").unwrap_err();
match err {
AlkTypeError::Schema(msg) => {
assert!(msg.contains("maxLength"), "msg: {msg}");
assert!(msg.contains("counts"), "msg: {msg}");
assert!(msg.contains("string"), "msg: {msg}");
}
other => panic!("expected Schema, got {other:?}"),
}
}
#[test]
fn n3_max_length_on_fixed_primitive_rejected_at_parse() {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"fields": [
{ "name": "id", "kind": "uint32", "maxLength": 8 }
]
}
}
});
let err = BastDoc::new(&root, "S").unwrap_err();
match err {
AlkTypeError::Schema(msg) => {
assert!(msg.contains("maxLength"), "msg: {msg}");
assert!(msg.contains("id"), "msg: {msg}");
}
other => panic!("expected Schema, got {other:?}"),
}
}
#[test]
fn n3_max_length_on_array_field_rejected_at_parse() {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"fields": [
{ "name": "tags", "kind": { "kind": "array", "element": "string", "count": 4 }, "maxLength": 16 }
]
}
}
});
let err = BastDoc::new(&root, "S").unwrap_err();
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
}
#[test]
fn n3_max_length_on_struct_kind_rejected_at_parse() {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"fields": [
{ "name": "nested", "kind": { "kind": "struct", "fields": [ { "name": "a", "kind": "uint8" } ] }, "maxLength": 8 }
]
}
}
});
let err = BastDoc::new(&root, "S").unwrap_err();
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
}
#[test]
fn n3_max_length_on_string_and_bytes_still_accepted() {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"fields": [
{ "name": "name", "kind": "string", "maxLength": 256 },
{ "name": "blob", "kind": "bytes", "maxLength": 64 }
]
}
}
});
let d = doc_from(&root, "S");
let s = match d.root_def().kind() {
BastDefKind::Struct(s) => s,
_ => unreachable!(),
};
assert_eq!(s.fields()[0].max_length(), Some(256));
assert_eq!(s.fields()[1].max_length(), Some(64));
}
#[test]
fn n3_max_length_on_union_field_disc_shared_field_rejected_at_parse() {
// The union's `fields` parse through the same BastField::parse
// choke point, so the gate applies there too.
let root = json!({
"$defs": {
"U": {
"kind": "union",
"discriminator": { "kind": "field", "name": "type" },
"fields": [
{ "name": "type", "kind": "uint8" },
{ "name": "payload", "kind": { "kind": "record", "values": "uint8" }, "maxLength": 16 }
],
"mapping": { "1": { "kind": "struct", "fields": [] } }
}
}
});
let err = BastDoc::new(&root, "U").unwrap_err();
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
}
// ----- 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_rejected_by_compile_cap() {
// Pre-0.3.1 this parsed u64::MAX verbatim (H1: the count fed
// unbounded per-element expansion downstream). The cap now
// rejects it at parse, before any walker sees the array.
let root = json!({
"$defs": {
"S": { "kind": "struct", "fields": [
{ "name": "a", "kind": { "kind": "array", "element": "uint8", "count": u64::MAX } }
] }
}
});
let err = BastDoc::new(&root, "S").unwrap_err();
match err {
AlkTypeError::Schema(reason) => {
assert!(reason.contains("compile-time limit"), "reason: {reason}");
}
other => panic!("expected Schema error, got {other:?}"),
}
}
}