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).
1761 lines
60 KiB
Rust
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:?}"),
|
|
}
|
|
}
|
|
} |