From e5f1b9d8256577d3213e9aac6080de547afd898f Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 2 Sep 2026 07:52:46 +0000 Subject: [PATCH] Wire packed read path through ReadPlan (ADR-011 steps 2-4, plan phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SequentialReader now walks Arc instead of re-parsing the BAST typed tree per field (the 400x read-path gap, review #004 H1); materialize_packed walks the same plan, unifying the two packed read-side consumers on one compiled form. - SequentialReader::new(Arc) -> Self, infallible: the fallible BastDoc parse moved to ReadPlan::compile (phase 1). The reader holds the plan Arc + cursor state only; schema() returns the Arc retained on the plan (review #005 H2 — no self-referential struct); new plan() accessor exposes the shared plan. - ReadPlan carries schema: Arc (set at compile; sub-plans hold a Null placeholder — only the root plan is handed out). - materialize_packed(&ReadPlan, &[u8]): plan-walking packed materializer. The aligned path keeps walking BastDoc with the retained dummy_field_for/ty_source/materialize_typeref_packed helpers (phase 5 Scope Boundary: aligned structure walk is the permanent 0.3.0 design). - Engine: Layout::Packed stores Arc alongside the builder; sequential_reader() is an Arc::clone (was a full-document Value clone); packed validate_bytes calls materialize_packed(&self.plan). - Stride (deferred decision 4): FieldValue::Array now reports the true stride for fixed-size struct/nested-array elements (0.2.0 returned 0); doc comment documents the behavioral change; no existing test asserted the 0, so none needed changing. - Two parity subtleties found and preserved: (a) materialize_plan_composite unwraps the plan's anonymous single-field wrapper for primitive array elements/record values — without it, materialized records nest each leaf under a synthetic object (caught by the record parity test); (b) field-disc unions keep 0.2.0's materialized key order (__discriminator first), observable under preserve_order. Both are now covered by plan-phase tests or construction. Bench (alktty wire_vs_bast, 1024 chunks/stream): packed read 2.27 us/chunk (review #004) -> 98 ns/chunk p64 / 100 ns/chunk p4k (~23x; the 400x gap closes to ~17x vs hand-rolled 5.6 ns/chunk). Residual gap is the per-field String allocation mandated by the unchanged (String, FieldValue) read_next signature (2 allocs/chunk) plus data_access bounds checks. sequential_reader() construction: 15.7 ns (was a whole-document clone). Verification: 465 tests pass unchanged (the existing reader/ materialize/engine suites drive the rewrite through the public API — only constructor call sites moved to ReadPlan::compile); clippy -D warnings clean; cargo doc zero warnings; wasm32 release build green. --- docs/plans/030-compiled-forms.md | 46 +- src/bast_validation.rs | 39 +- src/engine.rs | 30 +- src/materialize.rs | 495 ++++++++++++-- src/read_plan.rs | 17 +- src/sequential_reader.rs | 1104 +++++++++++++++--------------- tests/error_paths.rs | 8 +- tests/poc_roundtrip.rs | 6 +- 8 files changed, 1101 insertions(+), 644 deletions(-) diff --git a/docs/plans/030-compiled-forms.md b/docs/plans/030-compiled-forms.md index 6c5c23e..7098eee 100644 --- a/docs/plans/030-compiled-forms.md +++ b/docs/plans/030-compiled-forms.md @@ -214,7 +214,51 @@ Sync` so a future change can't break it silently — mirror the POC's --- -## Phase 2 — `SequentialReader` + `materialize_packed` consume `ReadPlan` (ADR-011 steps 2–4) +## Phase 2 — `SequentialReader` + `materialize_packed` consume `ReadPlan` (ADR-011 steps 2–4) — **DONE (2026-09-02)** + +> **Status: implemented.** The packed read loop walks `Arc`: +> `SequentialReader::new(Arc) -> Self` (infallible; the old +> fallible constructor's work moved to `ReadPlan::compile`), the reader +> holds `plan: Arc` + cursor only, `schema()` returns the +> `Arc` retained on the plan (review #005 H2 closed — no +> self-referential struct), and a new `plan()` accessor exposes the +> shared plan. `materialize_packed(&ReadPlan, &[u8])` walks the same +> plan; the aligned materialize path keeps walking `BastDoc` with the +> retained `dummy_field_for`/`ty_source`/`materialize_typeref_packed` +> helpers (phase 5 Scope Boundary). Engine: `Layout::Packed` carries +> `Arc`; `sequential_reader()` is an `Arc::clone` (15.7 ns, +> was a whole-document `Value` clone); packed `validate_bytes` calls +> `materialize_packed(&self.plan, ...)`. The temporary validation +> bridge (reconstruct `BastDoc` for the validator) is still in place — +> phase 7 already retired it on `main`'s ValidationPlan; this phase's +> `validate_bytes` edit merged cleanly onto that state. +> +> **Stride (deferred decision 4):** fixed struct/nested-array elements +> now report their true stride through `FieldValue::Array` +> (0.2.0 returned `0`); doc comment updated; no existing test asserted +> the `0`, so no test needed changing — the plan-compile tests lock the +> new values. +> +> **Two parity subtleties found and preserved** (both invisible to the +> existing test suite, both now locked by tests or by construction): +> (a) the materializer unwraps the plan's anonymous single-field +> wrapper for primitive array elements/record values — without this, +> materialized records/arrays would nest each leaf under a synthetic +> object and `validate_bytes` would fail its own parity suite (caught +> by `materialize_record_packed_count_prefixed_pairs`); (b) the +> field-disc union's materialized key order keeps `__discriminator` +> first (matching 0.2.0's `Map` insertion order, observable under +> `preserve_order`). +> +> **Bench (alktty `wire_vs_bast`, 1024 chunks/stream):** read p64 +> 2.27 µs/chunk (review #004) → **98 ns/chunk** (~23x; gap 400x → +> ~17x vs hand-rolled's 5.6 ns); read p4k → 100 ns/chunk. `engine. +> sequential_reader()` construction 15.7 ns (was a full `Value` clone). +> The residual gap is dominated by the per-field `String` allocation +> mandated by the unchanged `(String, FieldValue)` `read_next` return +> signature (2 allocs/chunk) plus `data_access` bounds checks — both +> outside this phase's scope (the signature is pinned by the Semver +> Contract). **Goal:** Rewrite the packed read loop to walk `&ReadPlan` instead of reconstructing `BastDoc`. `SequentialReader` stores `Arc` + diff --git a/src/bast_validation.rs b/src/bast_validation.rs index 0fdc38a..9192290 100644 --- a/src/bast_validation.rs +++ b/src/bast_validation.rs @@ -82,6 +82,7 @@ pub(crate) fn validation_err(path: &str, reason: impl Into) -> AlkTypeEr mod tests { use super::*; use crate::materialize::materialize_packed; + use crate::read_plan::ReadPlan; use serde_json::json; fn doc_from<'a>(root: &'a Value, name: &'a str) -> BastDoc<'a> { @@ -99,10 +100,12 @@ mod tests { } fn materialize_and_validate( + root: &Value, doc: &BastDoc<'_>, buffer: &[u8], ) -> Result<(), AlkTypeError> { - let value = materialize_packed(doc, buffer)?; + let plan = ReadPlan::compile(root, doc.root_name())?; + let value = materialize_packed(&plan, buffer)?; validate_value(doc, &value) } @@ -121,8 +124,8 @@ mod tests { ] } } }); let d = doc_from(&root, "S"); - assert!(materialize_and_validate(&d, &u32_le(0)).is_ok()); - assert!(materialize_and_validate(&d, &u32_le(0xFFFF_FFFF)).is_ok()); + assert!(materialize_and_validate(&root, &d, &u32_le(0)).is_ok()); + assert!(materialize_and_validate(&root, &d, &u32_le(0xFFFF_FFFF)).is_ok()); } #[test] @@ -133,8 +136,8 @@ mod tests { ] } } }); let d = doc_from(&root, "S"); - assert!(materialize_and_validate(&d, &[127u8]).is_ok()); - assert!(materialize_and_validate(&d, &[128u8]).is_ok()); + assert!(materialize_and_validate(&root, &d, &[127u8]).is_ok()); + assert!(materialize_and_validate(&root, &d, &[128u8]).is_ok()); } #[test] @@ -190,8 +193,8 @@ mod tests { ] } } }); let d = doc_from(&root, "S"); - assert!(materialize_and_validate(&d, &prefixed_str_le("hi")).is_ok()); - let err = materialize_and_validate(&d, &prefixed_str_le("hello")).unwrap_err(); + assert!(materialize_and_validate(&root, &d, &prefixed_str_le("hi")).is_ok()); + let err = materialize_and_validate(&root, &d, &prefixed_str_le("hello")).unwrap_err(); assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}"); } @@ -205,10 +208,10 @@ mod tests { let d = doc_from(&root, "S"); let mut buf = u32_le(2); buf.extend_from_slice(&[0xAA, 0xBB]); - assert!(materialize_and_validate(&d, &buf).is_ok()); + assert!(materialize_and_validate(&root, &d, &buf).is_ok()); let mut buf = u32_le(3); buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]); - let err = materialize_and_validate(&d, &buf).unwrap_err(); + let err = materialize_and_validate(&root, &d, &buf).unwrap_err(); assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}"); } @@ -238,8 +241,8 @@ mod tests { } }); let d = doc_from(&root, "S"); - assert!(materialize_and_validate(&d, &u32_le(0)).is_ok()); - assert!(materialize_and_validate(&d, &u32_le(2)).is_ok()); + assert!(materialize_and_validate(&root, &d, &u32_le(0)).is_ok()); + assert!(materialize_and_validate(&root, &d, &u32_le(2)).is_ok()); } #[test] @@ -253,7 +256,7 @@ mod tests { } }); let d = doc_from(&root, "S"); - let err = materialize_and_validate(&d, &u32_le(5)).unwrap_err(); + let err = materialize_and_validate(&root, &d, &u32_le(5)).unwrap_err(); assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}"); } @@ -284,16 +287,16 @@ mod tests { let mut buf = vec![2u8]; buf.extend_from_slice(&u32_le(2)); buf.extend_from_slice(&[0xAA, 0xBB]); - assert!(materialize_and_validate(&d, &buf).is_ok()); + assert!(materialize_and_validate(&root, &d, &buf).is_ok()); let mut buf = vec![2u8]; buf.extend_from_slice(&u32_le(3)); buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]); - let err = materialize_and_validate(&d, &buf).unwrap_err(); + let err = materialize_and_validate(&root, &d, &buf).unwrap_err(); assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}"); let buf = vec![1u8, 7u8]; - assert!(materialize_and_validate(&d, &buf).is_ok()); + assert!(materialize_and_validate(&root, &d, &buf).is_ok()); } #[test] @@ -318,7 +321,7 @@ mod tests { let mut buf = prefixed_str_le("data"); buf.extend_from_slice(&u32_le(2)); buf.extend_from_slice(&[0xFF, 0xFE]); - let err = materialize_and_validate(&d, &buf).unwrap_err(); + let err = materialize_and_validate(&root, &d, &buf).unwrap_err(); assert!(matches!(err, AlkTypeError::Validation(_)), "got {err:?}"); } @@ -383,7 +386,7 @@ mod tests { buf.extend_from_slice(&2u16.to_le_bytes()); buf.extend_from_slice(&3u16.to_le_bytes()); buf.extend_from_slice(&4u16.to_le_bytes()); - assert!(materialize_and_validate(&d, &buf).is_ok()); + assert!(materialize_and_validate(&root, &d, &buf).is_ok()); } #[test] @@ -427,7 +430,7 @@ mod tests { ] } } }); let d = doc_from(&root, "S"); - let err = materialize_and_validate(&d, &[0u8; 2]).unwrap_err(); + let err = materialize_and_validate(&root, &d, &[0u8; 2]).unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); } diff --git a/src/engine.rs b/src/engine.rs index 0522649..112fbba 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -22,6 +22,7 @@ use crate::error::AlkTypeError; use crate::layout_builder::LayoutBuilder; use crate::materialize; use crate::offset_map::OffsetMap; +use crate::read_plan::ReadPlan; use crate::schema::{AlkTypeKind, Endian, VariableEncoding}; use crate::sequential_reader::{FieldValue, SequentialReader}; use crate::validation; @@ -47,12 +48,14 @@ pub enum LayoutMode { /// APIs that make sense for that mode. #[derive(Debug)] enum Layout { - /// Packed sequential layout. The write-side is [`LayoutBuilder`]; the - /// read-side is a fresh [`SequentialReader`] constructed on demand - /// (ADR-007 — the reader has mutable cursor state that the consumer - /// owns, so the engine is a factory, not a holder). + /// Packed sequential layout. The write-side is [`LayoutBuilder`]; + /// the read-side compiled form is the [`ReadPlan`] (ADR-011), + /// shared via `Arc` with every [`SequentialReader`] the factory + /// hands out (ADR-007 — the reader owns its cursor state, so the + /// engine is a factory, not a holder). Packed { builder: LayoutBuilder, + plan: Arc, }, /// Aligned static layout. Field offsets are precomputed in an /// [`OffsetMap`] for random access. @@ -151,7 +154,8 @@ impl AlkTypeEngine { let layout = match mode { LayoutMode::Packed => { let builder = LayoutBuilder::new(bast_doc, root_name)?; - Layout::Packed { builder } + let plan = Arc::new(ReadPlan::compile(bast_doc, root_name)?); + Layout::Packed { builder, plan } } LayoutMode::Aligned => { let offset_map = OffsetMap::compute(&doc)?; @@ -204,15 +208,17 @@ impl AlkTypeEngine { } /// Construct a fresh [`SequentialReader`] for packed-mode reads - /// (ADR-007). Each call returns a new reader with the cursor at - /// position 0. The consumer owns the reader and calls - /// `read_next`/`read_field`/`reset` on it directly. + /// (ADR-007, ADR-011). Each call returns a new reader with the + /// cursor at position 0, sharing the engine's `Arc` (a + /// refcount bump — no re-parse, no document clone). The consumer + /// owns the reader and calls `read_next`/`read_field`/`reset` on it + /// directly. /// /// Returns `None` if compiled in aligned mode. pub fn sequential_reader(&self) -> Option { match &self.layout { - Layout::Packed { .. } => { - SequentialReader::new(&self.bast_doc, &self.root_name).ok() + Layout::Packed { plan, .. } => { + Some(SequentialReader::new(Arc::clone(plan))) } Layout::Aligned { .. } => None, } @@ -298,7 +304,9 @@ impl AlkTypeEngine { pub fn validate_bytes(&self, buffer: &[u8]) -> Result<(), AlkTypeError> { let doc = BastDoc::new(&self.bast_doc, &self.root_name)?; let value = match &self.layout { - Layout::Packed { .. } => materialize::materialize_packed(&doc, buffer)?, + Layout::Packed { plan, .. } => { + materialize::materialize_packed(plan, buffer)? + } Layout::Aligned { offset_map } => { materialize::materialize_aligned(&doc, buffer, offset_map)? } diff --git a/src/materialize.rs b/src/materialize.rs index 948ad9c..ca0a9ad 100644 --- a/src/materialize.rs +++ b/src/materialize.rs @@ -1,27 +1,25 @@ -//! Materialize a `serde_json::Value` tree from a binary buffer by walking -//! the BAST typed tree. Used by +//! Materialize a `serde_json::Value` tree from a binary buffer. Used by //! [`crate::engine::AlkTypeEngine::validate_bytes`] (ADR-010) to collapse //! the two-step dance (read bytes -> `Value`, then validate `Value`) into //! a single call. //! -//! The materializer reuses the [`crate::data_access`] read functions for -//! leaf kinds and recurses into composites (`Struct`, `Array`, `Record`, -//! `Union`). For `Union`, it dispatches on the discriminator and recurses -//! into the variant. Errors carry the field path (ADR-004). -//! -//! Packed mode walks sequentially from offset 0; aligned mode reads at -//! offsets from the [`crate::offset_map::OffsetMap`]. Both produce the -//! same `Value` form; the validator is mode-agnostic. +//! Packed mode walks the compiled [`ReadPlan`] +//! (ADR-011) — the same compiled form [`crate::sequential_reader`] walks, +//! unifying the two packed read-side consumers on one plan. Aligned mode +//! walks the BAST typed tree, reading leaves at offsets from the +//! [`crate::offset_map::OffsetMap`] (the permanent 0.3.0 design — see the +//! phase 5 Scope Boundary in the 0.3.0 plan). Both produce the same +//! `Value` form; the validator is mode-agnostic. //! //! ## `$ref` resolution //! -//! The BAST document ([`crate::bast::BastDoc`]) is threaded through every -//! recursive call so that `$ref` pointers (e.g. `"#/$defs/Read"`) in -//! composite variants can be resolved via -//! [`crate::bast::BastDoc::resolve_typeref`]. This is load-bearing for -//! unions and arrays whose variants/elements are `$ref`s into `$defs` -//! (the SFTP Packet shape: a struct wrapping a union with `$ref` -//! variants). +//! Packed mode: resolved eagerly at `ReadPlan::compile` — the per-buffer +//! walk never touches the BAST document. Aligned mode: the BAST document +//! ([`crate::bast::BastDoc`]) is threaded through every recursive call so +//! `$ref` pointers (e.g. `"#/$defs/Read"`) in composite variants resolve +//! via [`crate::bast::BastDoc::resolve_typeref`] at walk time — +//! load-bearing for unions and arrays whose variants/elements are `$ref`s +//! into `$defs`. use crate::bast::{ BastArray, BastDefKind, BastDiscriminator, BastDoc, BastField, BastRecord, BastStruct, @@ -29,35 +27,432 @@ use crate::bast::{ }; use crate::data_access; use crate::error::AlkTypeError; +use crate::read_plan::{ + CompositePlan, DiscriminatorPlan, FieldPlan, ReadKind, ReadPlan, +}; use crate::schema::{AlkTypeKind, Endian, VariableEncoding}; use serde_json::{Map, Value}; const U32_SIZE: usize = 4; const DISCRIMINATOR_KEY: &str = "__discriminator"; -/// Materialize a `Value` tree from `buffer` by walking the BAST root type -/// in packed mode (sequential, from offset 0). +/// Materialize a `Value` tree from `buffer` by walking the compiled +/// [`ReadPlan`] in packed mode (sequential, from offset 0). /// -/// The root type must be a struct. Endianness is read from the root -/// struct's `endian` annotation (defaults to little-endian). +/// The plan's root must be a struct (enforced at `ReadPlan::compile`). +/// Endianness is baked per node at compile time. pub fn materialize_packed( - doc: &BastDoc<'_>, + plan: &ReadPlan, buffer: &[u8], ) -> Result { - let root_def = doc.root_def(); - let struct_node = match root_def.kind() { - BastDefKind::Struct(s) => s, - other => { + let mut offset = 0usize; + let mut obj = Map::new(); + for field in plan.fields() { + let value = materialize_plan_field(plan, field, field.name(), buffer, &mut offset)?; + obj.insert(field.name().to_string(), value); + } + Ok(Value::Object(obj)) +} + +/// Materialize one planned field (packed mode): dispatch on the +/// field's [`ReadKind`], reading leaves via [`crate::data_access`] and +/// recursing into the compiled composite body. `endian` is the field's +/// effective endianness baked into the plan. +fn materialize_plan_field( + plan: &ReadPlan, + field: &FieldPlan, + field_path: &str, + buffer: &[u8], + offset: &mut usize, +) -> Result { + let _ = plan; + match field.kind() { + ReadKind::Primitive(kind) => { + materialize_plan_primitive(*kind, field.endian(), field_path, buffer, offset) + } + ReadKind::Enum => { + let v = data_access::read_enum(buffer, *offset, field_path, field.endian())?; + *offset += 4; + Ok(Value::from(v)) + } + ReadKind::Struct => { + let body = match field.body() { + Some(CompositePlan::Struct(p)) => p, + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: struct field {field_path} has no Struct body" + ))) + } + }; + materialize_plan_struct(body, field_path, buffer, offset) + } + ReadKind::Union => { + let body = field.body().ok_or_else(|| AlkTypeError::Schema(format!( + "internal: union field {field_path} has no body" + )))?; + materialize_plan_union(body, field_path, field.endian(), buffer, offset) + } + ReadKind::Array => { + let body = field.body().ok_or_else(|| AlkTypeError::Schema(format!( + "internal: array field {field_path} has no body" + )))?; + materialize_plan_array(body, field_path, field.endian(), buffer, offset) + } + ReadKind::Record => { + let body = field.body().ok_or_else(|| AlkTypeError::Schema(format!( + "internal: record field {field_path} has no body" + )))?; + materialize_plan_record(body, field_path, field.endian(), buffer, offset) + } + } +} + +fn materialize_plan_primitive( + kind: AlkTypeKind, + endian: Endian, + field_path: &str, + buffer: &[u8], + offset: &mut usize, +) -> Result { + match kind { + AlkTypeKind::Int8 => { + let v = data_access::read_i8(buffer, *offset, field_path)?; + *offset += 1; + Ok(Value::from(v)) + } + AlkTypeKind::Int16 => { + let v = data_access::read_i16(buffer, *offset, field_path, endian)?; + *offset += 2; + Ok(Value::from(v)) + } + AlkTypeKind::Int32 => { + let v = data_access::read_i32(buffer, *offset, field_path, endian)?; + *offset += 4; + Ok(Value::from(v)) + } + AlkTypeKind::Int64 => { + let v = data_access::read_i64(buffer, *offset, field_path, endian)?; + *offset += 8; + Ok(Value::from(v)) + } + AlkTypeKind::Uint8 => { + let v = data_access::read_u8(buffer, *offset, field_path)?; + *offset += 1; + Ok(Value::from(v)) + } + AlkTypeKind::Uint16 => { + let v = data_access::read_u16(buffer, *offset, field_path, endian)?; + *offset += 2; + Ok(Value::from(v)) + } + AlkTypeKind::Uint32 => { + let v = data_access::read_u32(buffer, *offset, field_path, endian)?; + *offset += 4; + Ok(Value::from(v)) + } + AlkTypeKind::Uint64 => { + let v = data_access::read_u64(buffer, *offset, field_path, endian)?; + *offset += 8; + Ok(Value::from(v)) + } + AlkTypeKind::Float32 => { + let v = data_access::read_f32(buffer, *offset, field_path, endian)?; + *offset += 4; + Ok(number_from_f64(v as f64, field_path)?) + } + AlkTypeKind::Float64 => { + let v = data_access::read_f64(buffer, *offset, field_path, endian)?; + *offset += 8; + Ok(number_from_f64(v, field_path)?) + } + AlkTypeKind::Boolean => { + let v = data_access::read_bool(buffer, *offset, field_path)?; + *offset += 1; + Ok(Value::Bool(v)) + } + AlkTypeKind::String => { + let s = data_access::read_string(buffer, *offset, field_path, endian)?; + let len = U32_SIZE + .checked_add(s.len()) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: "string size 4 + len overflows usize".to_string(), + })?; + *offset = checked_add_at(*offset, len, field_path, "string")?; + Ok(Value::String(s.to_string())) + } + AlkTypeKind::Bytes => { + let b = data_access::read_bytes(buffer, *offset, field_path, endian)?; + let len = U32_SIZE + .checked_add(b.len()) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: "bytes size 4 + len overflows usize".to_string(), + })?; + *offset = checked_add_at(*offset, len, field_path, "bytes")?; + let arr: Vec = b.iter().map(|&byte| Value::from(u32::from(byte))).collect(); + Ok(Value::Array(arr)) + } + other => Err(AlkTypeError::Schema(format!( + "materialize: unsupported primitive kind {other} at {field_path}" + ))), + } +} + +fn checked_add_at( + offset: usize, + size: usize, + field_path: &str, + what: &str, +) -> Result { + offset.checked_add(size).ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("{what} offset {offset} + {size} overflows usize"), + }) +} + +fn materialize_plan_struct( + plan: &ReadPlan, + field_path: &str, + buffer: &[u8], + offset: &mut usize, +) -> Result { + let mut obj = Map::new(); + for field in plan.fields() { + let path = format!("{field_path}.{}", field.name()); + let value = materialize_plan_field(plan, field, &path, buffer, offset)?; + obj.insert(field.name().to_string(), value); + } + Ok(Value::Object(obj)) +} + +fn materialize_plan_array( + body: &CompositePlan, + field_path: &str, + endian: Endian, + buffer: &[u8], + offset: &mut usize, +) -> Result { + let (element, count) = match body { + CompositePlan::Array { + element, + count, + .. + } => (element, *count), + _ => { return Err(AlkTypeError::Schema(format!( - "materialize_packed: expected a struct at the root, got {kind}", - kind = other.alk_kind() - ))); + "internal: array body at {field_path} is not CompositePlan::Array" + ))) } }; - let effective_endian = struct_node.endian(); - materialize_struct_packed(doc, struct_node, "", effective_endian, buffer, &mut 0) + let mut arr = Vec::with_capacity(count); + for i in 0..count { + let path = format!("{field_path}[{i}]"); + let value = materialize_plan_composite(element, &path, endian, buffer, offset)?; + arr.push(value); + } + Ok(Value::Array(arr)) } +/// Materialize a composite body used as a TypeRef (array element, +/// record value, union variant). +/// +/// Primitive/enum elements and values were wrapped at compile time in an +/// anonymous single-field struct plan (the wrap-leaf convention) so the +/// read loop has a uniform node; the materializer unwraps that wrapper +/// and emits the leaf value directly — the 0.2.0 materializer never +/// wrapped (real schema field names cannot be empty, so an empty name +/// uniquely identifies the wrapper). +fn materialize_plan_composite( + body: &CompositePlan, + field_path: &str, + endian: Endian, + buffer: &[u8], + offset: &mut usize, +) -> Result { + if let CompositePlan::Struct(plan) = body { + if let Some(only) = plan.fields().first() { + if plan.fields().len() == 1 + && only.name().is_empty() + && matches!(only.kind(), ReadKind::Primitive(_) | ReadKind::Enum) + { + return materialize_plan_field(plan, only, field_path, buffer, offset); + } + } + } + match body { + CompositePlan::Struct(plan) => materialize_plan_struct(plan, field_path, buffer, offset), + CompositePlan::Union { .. } => { + materialize_plan_union(body, field_path, endian, buffer, offset) + } + CompositePlan::Array { .. } => { + materialize_plan_array(body, field_path, endian, buffer, offset) + } + CompositePlan::Record { .. } => { + materialize_plan_record(body, field_path, endian, buffer, offset) + } + } +} + +fn materialize_plan_union( + body: &CompositePlan, + field_path: &str, + endian: Endian, + buffer: &[u8], + offset: &mut usize, +) -> Result { + let (disc, shared, variants) = match body { + CompositePlan::Union { + disc, + shared, + variants, + } => (disc, shared, variants), + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: union body at {field_path} is not CompositePlan::Union" + ))) + } + }; + match disc { + DiscriminatorPlan::Byte { + offset: disc_rel, + disc_type, + } => { + let disc_abs = checked_add_at(*offset, *disc_rel, field_path, "discriminator")?; + let disc_value = match disc_type { + AlkTypeKind::Uint8 => { + data_access::read_u8(buffer, disc_abs, field_path)? as u32 + } + AlkTypeKind::Uint16 => { + data_access::read_u16(buffer, disc_abs, field_path, endian)? as u32 + } + AlkTypeKind::Uint32 => { + data_access::read_u32(buffer, disc_abs, field_path, endian)? + } + other => { + return Err(AlkTypeError::Schema(format!( + "internal: union discriminator type {other} is not a supported byte \ + discriminator (parser should have rejected this)" + ))); + } + }; + let key = disc_value.to_string(); + let variant = variants + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("union discriminator value {key} not in mapping"), + })?; + let disc_size = disc_type.type_size().unwrap_or(1); + let variant_offset = + checked_add_at(disc_abs, disc_size, field_path, "variant offset")?; + *offset = variant_offset; + let variant_value = + materialize_plan_composite(variant, field_path, endian, buffer, offset)?; + Ok(tag_union_value_number(disc_value, variant_value)) + } + DiscriminatorPlan::Field { name, field_index } => { + let shared_plan = shared.as_ref().ok_or_else(|| AlkTypeError::Schema(format!( + "internal: field-disc union at {field_path} has no shared plan" + )))?; + let disc_field = shared_plan.fields().get(*field_index).ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: union {field_path} discriminator index {field_index} out of range" + )) + })?; + let _ = disc_field; + // Walk the whole shared plan; capture the discriminator + // field's value for the mapping key as we pass it. The + // result object preserves the 0.2.0 key order: + // `__discriminator` first, then the shared fields, then the + // variant's fields. + let mut disc_value: Option = None; + let mut shared_values: Vec<(String, Value)> = Vec::new(); + for field in shared_plan.fields() { + let path = format!("{field_path}.{}", field.name()); + let value = materialize_plan_field(shared_plan, field, &path, buffer, offset)?; + if field.name() == name.as_str() { + disc_value = Some(value); + } else { + shared_values.push((field.name().to_string(), value)); + } + } + let disc_value = disc_value.ok_or_else(|| AlkTypeError::Schema(format!( + "internal: union {field_path} discriminator field {name:?} not walked" + )))?; + let key = plan_value_discriminator_key(&disc_value, field_path)?; + let variant = variants + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("union discriminator value {key} not in mapping"), + })?; + let variant_value = + materialize_plan_composite(variant, field_path, endian, buffer, offset)?; + let mut obj = Map::new(); + obj.insert(DISCRIMINATOR_KEY.to_string(), Value::String(key)); + obj.insert((*name).to_string(), disc_value); + for (k, v) in shared_values { + obj.insert(k, v); + } + flatten_variant_into(&mut obj, variant_value); + Ok(Value::Object(obj)) + } + } +} + +/// Stringify a materialized discriminator field value for mapping +/// lookup. Mirrors the 0.2.0 `union_discriminator_key` for the Value form. +fn plan_value_discriminator_key(value: &Value, field_path: &str) -> Result { + match value { + Value::String(s) => Ok(s.clone()), + Value::Number(n) => Ok(n.to_string()), + _ => Err(AlkTypeError::Schema(format!( + "union {field_path} has unsupported discriminator value kind: {value}" + ))), + } +} + +fn materialize_plan_record( + body: &CompositePlan, + field_path: &str, + endian: Endian, + buffer: &[u8], + offset: &mut usize, +) -> Result { + let value_body = match body { + CompositePlan::Record { value } => value, + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: record body at {field_path} is not CompositePlan::Record" + ))) + } + }; + let count = data_access::read_u32(buffer, *offset, field_path, endian)? as usize; + *offset += U32_SIZE; + let mut obj = Map::new(); + for i in 0..count { + let key_path = format!("{field_path}[{i}].key"); + let key_str = data_access::read_string(buffer, *offset, &key_path, endian)?; + let key_len = U32_SIZE + .checked_add(key_str.len()) + .ok_or_else(|| AlkTypeError::Access { + field_path: key_path.clone(), + reason: "key size 4 + len overflows usize".to_string(), + })?; + *offset = checked_add_at(*offset, key_len, &key_path, "key")?; + let val_path = format!("{field_path}[{i}].value"); + let val = materialize_plan_composite(value_body, &val_path, endian, buffer, offset)?; + obj.insert(key_str.to_string(), val); + } + Ok(Value::Object(obj)) +} + + /// Materialize a `Value` tree from `buffer` by walking the BAST root type /// in aligned mode (offsets from `offset_map`). /// @@ -83,6 +478,7 @@ pub fn materialize_aligned( materialize_struct_aligned(doc, struct_node, "", offset_map, effective_endian, buffer) } + fn materialize_struct_packed( doc: &BastDoc<'_>, struct_node: &BastStruct<'_>, @@ -399,10 +795,7 @@ fn materialize_record_packed( Ok(Value::Object(obj)) } -/// Build the materialized `Value` for a byte-offset union: an object -/// `{ "__discriminator": , ...variant-fields }`. If the -/// variant materialized as a non-object (a leaf), it is nested under -/// `"__variant"`. + fn tag_union_value_number(disc_value: u32, variant_value: Value) -> Value { let mut obj = Map::new(); obj.insert(DISCRIMINATOR_KEY.to_string(), Value::from(disc_value)); @@ -672,10 +1065,12 @@ mod tests { } fn materialize_packed_strict( + root: &Value, doc: &BastDoc<'_>, buffer: &[u8], ) -> Result { - materialize_packed(doc, buffer) + let plan = ReadPlan::compile(root, doc.root_name())?; + materialize_packed(&plan, buffer) } #[test] @@ -694,7 +1089,7 @@ mod tests { buf[4] = 0x41; buf[5] = 0x42; buf[6] = 0xC3; - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["blob"], json!([65, 66, 195])); } @@ -715,7 +1110,7 @@ mod tests { buf[5] = 0xFE; buf[6] = 0x00; buf[7] = 0x80; - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["raw"], json!([255, 254, 0, 128])); } @@ -747,7 +1142,7 @@ mod tests { buf[off] = b'a'; off += 1; buf[off..off + 4].copy_from_slice(&20u32.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["counts"], json!({ "b": 10, "a": 20 })); } @@ -766,7 +1161,7 @@ mod tests { let doc = doc_from(&root, "S"); let mut buf = vec![0u8; 4]; buf[0..4].copy_from_slice(&0u32.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["counts"], json!({})); } @@ -784,7 +1179,7 @@ mod tests { }); let doc = doc_from(&root, "S"); let buf = [0u8; 2]; - let err = materialize_packed_strict(&doc, &buf).unwrap_err(); + let err = materialize_packed_strict(&root, &doc, &buf).unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); } @@ -818,7 +1213,7 @@ mod tests { let mut buf = vec![0u8; 5]; buf[0] = 5; buf[1..5].copy_from_slice(&42u32.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); let payload = &v["payload"]; assert_eq!(payload["__discriminator"], json!(5)); assert_eq!(payload["id"], json!(42)); @@ -856,7 +1251,7 @@ mod tests { buf[0..4].copy_from_slice(&4u32.to_le_bytes()); buf[4..8].copy_from_slice(b"read"); buf[8..12].copy_from_slice(&7u32.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); let payload = &v["payload"]; assert_eq!(payload["__discriminator"], json!("read")); assert_eq!(payload["type"], json!("read")); @@ -897,7 +1292,7 @@ mod tests { buf[4..8].copy_from_slice(b"read"); buf[8..12].copy_from_slice(&7u32.to_le_bytes()); buf[12..16].copy_from_slice(&99u32.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["trailer"], json!(99)); assert_eq!(v["payload"]["n"], json!(7)); } @@ -930,7 +1325,7 @@ mod tests { }); let doc = doc_from(&root, "S"); let buf = [99u8]; - let err = materialize_packed_strict(&doc, &buf).unwrap_err(); + let err = materialize_packed_strict(&root, &doc, &buf).unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); } @@ -964,7 +1359,7 @@ mod tests { let mut buf = vec![0u8; 5]; buf[0] = 5; buf[1..5].copy_from_slice(&42u32.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["payload"]["__discriminator"], json!(5)); assert_eq!(v["payload"]["id"], json!(42)); } @@ -1001,7 +1396,7 @@ mod tests { buf[2..4].copy_from_slice(&2u16.to_le_bytes()); buf[4..6].copy_from_slice(&3u16.to_le_bytes()); buf[6..8].copy_from_slice(&4u16.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["items"], json!([{ "x": 1, "y": 2 }, { "x": 3, "y": 4 }])); } @@ -1032,7 +1427,7 @@ mod tests { buf[0] = 1; buf[1] = 2; buf[2..6].copy_from_slice(&0x03030303u32.to_le_bytes()); - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["header"]["a"], json!(1)); assert_eq!(v["header"]["b"], json!(2)); assert_eq!(v["after"], json!(0x03030303u32)); @@ -1054,7 +1449,7 @@ mod tests { }); let doc = doc_from(&root, "S"); let buf = [0u8, 0u8, 0u8, 42u8, 0u8, 0u8, 0u8, 7u8]; - let v = materialize_packed_strict(&doc, &buf).expect("materialize"); + let v = materialize_packed_strict(&root, &doc, &buf).expect("materialize"); assert_eq!(v["channel_id"], json!(42)); assert_eq!(v["length"], json!(7)); } @@ -1198,7 +1593,7 @@ mod tests { }); let doc = doc_from(&root, "S"); let buf = f32::NAN.to_le_bytes(); - let err = materialize_packed_strict(&doc, &buf).unwrap_err(); + let err = materialize_packed_strict(&root, &doc, &buf).unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); } } \ No newline at end of file diff --git a/src/read_plan.rs b/src/read_plan.rs index 56c0b62..8c86248 100644 --- a/src/read_plan.rs +++ b/src/read_plan.rs @@ -44,6 +44,7 @@ use crate::error::AlkTypeError; use crate::schema::{AlkTypeKind, Endian, VariableEncoding}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; /// Maximum recursion depth for the compile walk. Bounds deeply-nested /// (or cyclically-referencing) adversarial schemas; pairs with the @@ -65,6 +66,7 @@ pub struct ReadPlan { endian: Endian, fields: Vec, by_name: BTreeMap, + schema: Arc, } /// One compiled struct field: the read dispatch kind, the effective @@ -171,7 +173,10 @@ impl ReadPlan { } }; let mut seen = BTreeSet::new(); - compile_struct(&doc, struct_node, struct_node.endian(), "", 0, &mut seen) + compile_struct(&doc, struct_node, struct_node.endian(), "", 0, &mut seen).map(|mut plan| { + plan.schema = Arc::new(bast_doc.clone()); + plan + }) } /// The root container default endianness. @@ -188,6 +193,13 @@ impl ReadPlan { pub fn field_index(&self, name: &str) -> Option { self.by_name.get(name).copied() } + + /// The BAST document this plan was compiled from (retained as + /// `Arc` — the reader's `schema()` accessor returns it, and + /// phase 6's `Hash` derive covers it via `serde_json::Value: Hash`). + pub fn schema(&self) -> &Value { + &self.schema + } } impl FieldPlan { @@ -264,6 +276,7 @@ fn compile_struct( endian: container_endian, fields, by_name, + schema: Arc::new(Value::Null), }) } @@ -428,6 +441,7 @@ fn compile_union( endian: container_endian, fields: plan, by_name, + schema: Arc::new(Value::Null), })) } }; @@ -568,6 +582,7 @@ fn wrap_leaf( endian: container_endian, fields: vec![field], by_name: BTreeMap::new(), + schema: Arc::new(Value::Null), })) } _ => Err(AlkTypeError::Schema(format!( diff --git a/src/sequential_reader.rs b/src/sequential_reader.rs index 1910467..acb5371 100644 --- a/src/sequential_reader.rs +++ b/src/sequential_reader.rs @@ -1,22 +1,32 @@ //! Packed sequential `SequentialReader` — Mode 1 read-side (ADR-002). //! -//! Walks a buffer field-by-field according to the BAST schema, reading -//! length prefixes to determine variable-length data positions. Used at -//! read time when the consumer is parsing an incoming frame. +//! Walks a buffer field-by-field according to the compiled +//! [`ReadPlan`], reading length prefixes to +//! determine variable-length data positions. Used at read time when the +//! consumer is parsing an incoming frame. //! //! The reader is sequential — it cannot jump to field N without reading //! fields 0..N-1 first. This is inherent to packed layouts where //! variable-length fields shift subsequent fields. [`SequentialReader`] //! uses the [`crate::data_access`] read functions for all typed reads -//! and applies the schema's endianness to every multi-byte value. +//! and applies the plan's per-node endianness to every multi-byte value. +//! +//! ## Compiled form (ADR-011) +//! +//! Since ADR-011 the reader walks a pre-resolved read plan rather than +//! re-parsing the BAST typed tree per field: every `$ref` is resolved, +//! every endianness is baked, every union variant is inlined at +//! [`ReadPlan::compile`](crate::read_plan::ReadPlan::compile) time. The +//! reader holds an `Arc` (shared with the engine, refcount +//! bump per reader) plus per-reader cursor state only. -use crate::bast::{ - BastArray, BastDefKind, BastDiscriminator, BastDoc, BastField, BastRecord, BastStruct, - BastType, BastUnion, -}; use crate::data_access; use crate::error::AlkTypeError; +use crate::read_plan::{ + CompositePlan, DiscriminatorPlan, FieldPlan, ReadKind, ReadPlan, +}; use crate::schema::{AlkTypeKind, Endian, U32_SIZE}; +use std::sync::Arc; use serde_json::Value; /// A value read from a field during sequential traversal. @@ -89,13 +99,21 @@ pub enum FieldValue<'a> { /// Byte distance between consecutive elements. `0` signals a /// variable-length element type — the consumer must walk each /// element sequentially. + /// + /// **Changed in 0.3.0 (deferred decision 4):** fixed-size struct + /// (and nested-array) elements now report their true stride + /// (e.g. `4` for `Point { x: u16, y: u16 }`); 0.2.0 reported `0` + /// for those because only leaf primitives were stride-recognized. + /// The `0` was behaviorally observable but semantically wrong — + /// the consumer could index directly with the true stride. element_stride: usize, }, } -/// Walks a buffer field-by-field according to a BAST schema, reading -/// length prefixes to determine variable-length data positions. Used at -/// read time when parsing incoming protocol frames. +/// Walks a buffer field-by-field according to a compiled +/// [`ReadPlan`], reading length prefixes to determine variable-length +/// data positions. Used at read time when parsing incoming protocol +/// frames. /// /// The reader is sequential — it cannot jump to field N without reading /// fields 0..N-1 first. This is inherent to packed layouts where @@ -108,50 +126,24 @@ pub enum FieldValue<'a> { /// (which walks all preceding fields to reach the target). #[derive(Debug)] pub struct SequentialReader { - doc_value: Value, - root_name: String, - endian: Endian, - fields: Vec<(String, Value)>, + plan: Arc, field_index: usize, position: usize, } impl SequentialReader { - /// Create a new `SequentialReader` from a BAST document. + /// Create a new `SequentialReader` from a compiled [`ReadPlan`]. /// - /// The root type must be a struct. Endianness is read from the root - /// struct's `endian` annotation (defaults to little-endian). - /// - /// # Errors - /// - /// Returns [`AlkTypeError::Schema`] if the document is malformed, the - /// root type is not a struct, or the root struct has no fields. - pub fn new(bast_doc: &Value, root_name: &str) -> Result { - let doc = BastDoc::new(bast_doc, root_name)?; - let root_def = doc.root_def(); - let struct_node = match root_def.kind() { - BastDefKind::Struct(s) => s, - other => { - return Err(AlkTypeError::Schema(format!( - "SequentialReader only supports a struct at the root, got {kind}", - kind = other.alk_kind() - ))); - } - }; - let endian = struct_node.endian(); - let fields: Vec<(String, Value)> = struct_node - .fields() - .iter() - .map(|f| (f.name().to_string(), f.source().clone())) - .collect(); - Ok(Self { - doc_value: bast_doc.clone(), - root_name: root_name.to_string(), - endian, - fields, + /// Infallible: the plan is already compiled (all `$ref`s resolved, + /// all endianness baked) — construction just stores the `Arc` and + /// zeroes the cursor. The fallible part of the old constructor moved + /// to [`ReadPlan::compile`] (ADR-011). + pub fn new(plan: Arc) -> Self { + Self { + plan, field_index: 0, position: 0, - }) + } } /// Read the next field from `buffer` at the current position. @@ -171,17 +163,14 @@ impl SequentialReader { &mut self, buffer: &'a [u8], ) -> Result)>, AlkTypeError> { - if self.field_index >= self.fields.len() { + if self.field_index >= self.plan.fields().len() { return Ok(None); } - let (name, value) = match self.read_field_at(buffer, self.field_index, self.position) { - Ok((value, new_position)) => { - self.position = new_position; - self.field_index += 1; - (self.fields[self.field_index - 1].0.clone(), value) - } - Err(e) => return Err(e), - }; + let name = self.plan.fields()[self.field_index].name().to_string(); + let (value, new_position) = + self.read_field_at(buffer, self.field_index, self.position)?; + self.position = new_position; + self.field_index += 1; Ok(Some((name, value))) } @@ -202,12 +191,9 @@ impl SequentialReader { ) -> Result, AlkTypeError> { self.reset(); let target_index = self - .fields - .iter() - .position(|(name, _)| name == field_path) - .ok_or_else(|| { - AlkTypeError::Schema(format!("field not found in struct: {field_path}")) - })?; + .plan + .field_index(field_path) + .ok_or_else(|| AlkTypeError::Schema(format!("field not found in struct: {field_path}")))?; let mut position = 0usize; let mut target_value: Option> = None; for index in 0..=target_index { @@ -238,14 +224,20 @@ impl SequentialReader { self.position } - /// The endianness used by this reader. + /// The endianness used by this reader (the plan's root container + /// default). pub fn endian(&self) -> Endian { - self.endian + self.plan.endian() } - /// The raw BAST document this reader was constructed from. + /// The BAST document the plan was compiled from. pub fn schema(&self) -> &Value { - &self.doc_value + self.plan.schema() + } + + /// The compiled read plan this reader walks (shared via `Arc`). + pub fn plan(&self) -> &Arc { + &self.plan } fn read_field_at<'a>( @@ -254,436 +246,80 @@ impl SequentialReader { index: usize, offset: usize, ) -> Result<(FieldValue<'a>, usize), AlkTypeError> { - let (_, field_schema) = self - .fields - .get(index) - .ok_or_else(|| AlkTypeError::Schema(format!("field index {index} out of range")))?; - let field_path = self.fields[index].0.as_str(); - let doc = BastDoc::new(&self.doc_value, &self.root_name)?; - let root_def = doc.root_def(); - let struct_node = match root_def.kind() { - BastDefKind::Struct(s) => s, - _ => unreachable!("checked in new"), - }; - let field_node = struct_node + let field = self + .plan .fields() .get(index) .ok_or_else(|| AlkTypeError::Schema(format!("field index {index} out of range")))?; - read_field_value( - buffer, - &doc, - field_node, - field_schema, - field_path, - offset, - self.endian, - ) + plan_read_field_at(field, buffer, offset, field.name()) } } -/// Read a single field value at `offset` and return the value plus the -/// position just past the field. Field paths are used for error -/// attribution only — this helper does not recurse into nested structs. -/// -/// `doc` is the BAST document used to resolve `$ref` pointers found in -/// nested union variants and array/record element types. -fn read_field_value<'a>( +/// Read one planned field at `offset`: dispatch on the field's +/// [`ReadKind`], reading leaves directly and recursing into the +/// compiled composite body. `field_path` is for error attribution. +fn plan_read_field_at<'a>( + field: &FieldPlan, buffer: &'a [u8], - doc: &BastDoc<'_>, - field: &BastField<'_>, - _field_schema: &Value, - field_path: &str, offset: usize, - endian: Endian, + field_path: &str, ) -> Result<(FieldValue<'a>, usize), AlkTypeError> { - let ty = field.ty(); - let resolved = doc.resolve_typeref(ty)?; - let endian = field.effective_endian(endian); - - match &resolved { - BastType::Primitive(AlkTypeKind::Int8) => { - let v = data_access::read_i8(buffer, offset, field_path)?; - Ok((FieldValue::I8(v), offset + 1)) + match field.kind() { + ReadKind::Primitive(kind) => { + plan_read_primitive(buffer, *kind, field.endian(), field_path, offset) } - BastType::Primitive(AlkTypeKind::Int16) => { - let v = data_access::read_i16(buffer, offset, field_path, endian)?; - Ok((FieldValue::I16(v), offset + 2)) - } - BastType::Primitive(AlkTypeKind::Int32) => { - let v = data_access::read_i32(buffer, offset, field_path, endian)?; - Ok((FieldValue::I32(v), offset + 4)) - } - BastType::Primitive(AlkTypeKind::Int64) => { - let v = data_access::read_i64(buffer, offset, field_path, endian)?; - Ok((FieldValue::I64(v), offset + 8)) - } - BastType::Primitive(AlkTypeKind::Uint8) => { - let v = data_access::read_u8(buffer, offset, field_path)?; - Ok((FieldValue::U8(v), offset + 1)) - } - BastType::Primitive(AlkTypeKind::Uint16) => { - let v = data_access::read_u16(buffer, offset, field_path, endian)?; - Ok((FieldValue::U16(v), offset + 2)) - } - BastType::Primitive(AlkTypeKind::Uint32) => { - let v = data_access::read_u32(buffer, offset, field_path, endian)?; - Ok((FieldValue::U32(v), offset + 4)) - } - BastType::Primitive(AlkTypeKind::Uint64) => { - let v = data_access::read_u64(buffer, offset, field_path, endian)?; - Ok((FieldValue::U64(v), offset + 8)) - } - BastType::Primitive(AlkTypeKind::Float32) => { - let v = data_access::read_f32(buffer, offset, field_path, endian)?; - Ok((FieldValue::F32(v), offset + 4)) - } - BastType::Primitive(AlkTypeKind::Float64) => { - let v = data_access::read_f64(buffer, offset, field_path, endian)?; - Ok((FieldValue::F64(v), offset + 8)) - } - BastType::Primitive(AlkTypeKind::Boolean) => { - let v = data_access::read_bool(buffer, offset, field_path)?; - Ok((FieldValue::Bool(v), offset + 1)) - } - BastType::Enum(_) => { - let v = data_access::read_enum(buffer, offset, field_path, endian)?; + ReadKind::Enum => { + let v = data_access::read_enum(buffer, offset, field_path, field.endian())?; Ok((FieldValue::Enum(v), offset + 4)) } - BastType::Primitive(AlkTypeKind::String) => { - let s = data_access::read_string(buffer, offset, field_path, endian)?; - let total = U32_SIZE + s.len(); - Ok((FieldValue::String(s), offset + total)) - } - BastType::Primitive(AlkTypeKind::Bytes) => { - let b = data_access::read_bytes(buffer, offset, field_path, endian)?; - let total = U32_SIZE + b.len(); - Ok((FieldValue::Bytes(b), offset + total)) - } - BastType::Struct(s) => { - let size = walk_struct_size(doc, s, buffer, offset, endian)?; - let end = offset - .checked_add(size) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("struct end {offset} + {size} overflows usize"), - })?; + ReadKind::Struct => { + let body = match field.body() { + Some(CompositePlan::Struct(p)) => p, + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: struct field {field_path} has no Struct body" + ))) + } + }; + let size = plan_walk_struct_size(body, buffer, offset, field_path)?; + let end = checked_end(offset, size, field_path, "struct")?; Ok((FieldValue::Struct { start: offset, end }, end)) } - BastType::Union(u) => read_union_value(buffer, doc, u, field_path, offset, endian), - BastType::Array(a) => read_array_value(buffer, doc, a, field_path, offset, endian), - BastType::Record(r) => read_record_value(buffer, doc, r, field_path, offset, endian), - BastType::Ref(_) => Err(AlkTypeError::Schema(format!( - "internal: unresolved $ref at {field_path}" - ))), - BastType::Primitive(k) => Err(AlkTypeError::Schema(format!( - "unsupported primitive kind {k} at {field_path}" - ))), - } -} - -/// Read a `union` field: read the discriminator, return the -/// variant start offset, and advance past the entire union payload. -/// -/// For byte-offset discriminators the union occupies -/// `discriminator_size + variant_size` bytes. Because the variant is a -/// struct (or a ref to one) whose size depends on variable-length fields, -/// the variant size is computed by walking the variant struct. The -/// variant schema is resolved from the `mapping` table using the -/// stringified discriminator value. -/// -/// For field-name discriminators the discriminator is itself a -/// length-prefixed string field. The variant begins immediately after -/// the discriminator field and is sized by walking the variant struct. -fn read_union_value<'a>( - buffer: &'a [u8], - doc: &BastDoc<'_>, - union_node: &BastUnion<'_>, - field_path: &str, - offset: usize, - endian: Endian, -) -> Result<(FieldValue<'a>, usize), AlkTypeError> { - let disc = union_node.discriminator(); - match disc { - BastDiscriminator::Byte { offset: disc_offset, disc_type } => { - let abs_offset = - offset - .checked_add(*disc_offset) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!( - "discriminator offset {offset} + {disc_offset} overflows usize" - ), - })?; - let (disc_value, disc_size) = - read_byte_discriminator(buffer, abs_offset, field_path, *disc_type, endian)?; - let key = disc_value.to_string(); - let variant_ty = union_node.variant_for(&key).ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("unknown union discriminator value: {key}"), + ReadKind::Union => { + let body = field.body().ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: union field {field_path} has no body" + )) })?; - let variant_start = - abs_offset - .checked_add(disc_size) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("variant start {abs_offset} + {disc_size} overflows usize"), - })?; - let variant_size = resolve_and_walk_variant( - doc, - variant_ty, - buffer, - variant_start, - endian, - field_path, - )?; - let end = - variant_start - .checked_add(variant_size) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!( - "union end {variant_start} + {variant_size} overflows usize" - ), - })?; - Ok(( - FieldValue::Union { - discriminator: key, - variant_start, - }, - end, - )) + plan_read_union(body, buffer, offset, field_path, field.endian()) } - BastDiscriminator::Field { name } => { - let fields = union_node.fields(); - let disc_field = fields - .iter() - .find(|f| f.name() == *name) - .ok_or_else(|| { - AlkTypeError::Schema(format!( - "union {field_path} has no discriminator field '{name}'" - )) - })?; - let disc_path = format!("{field_path}.{name}"); - let (disc_value, after_disc) = - read_field_value(buffer, doc, disc_field, disc_field.source(), &disc_path, offset, endian)?; - let key = discriminator_string_value(&disc_value, field_path)?; - let variant_ty = union_node.variant_for(&key).ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("unknown union discriminator value: {key}"), + ReadKind::Array => { + let body = field.body().ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: array field {field_path} has no body" + )) })?; - let variant_size = resolve_and_walk_variant( - doc, - variant_ty, - buffer, - after_disc, - endian, - field_path, - )?; - let end = after_disc - .checked_add(variant_size) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("union end {after_disc} + {variant_size} overflows usize"), - })?; - Ok(( - FieldValue::Union { - discriminator: key, - variant_start: after_disc, - }, - end, - )) + plan_read_array(body, buffer, offset, field_path, field.endian()) + } + ReadKind::Record => { + let body = field.body().ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: record field {field_path} has no body" + )) + })?; + plan_read_record(body, buffer, offset, field_path, field.endian()) } } } - -/// Read a byte-offset discriminator integer and return its value (as a -/// `u32`) plus its byte size. -fn read_byte_discriminator( - buffer: &[u8], - offset: usize, - field_path: &str, - disc_type: AlkTypeKind, - endian: Endian, -) -> Result<(u32, usize), AlkTypeError> { - match disc_type { - AlkTypeKind::Uint8 => { - let v = data_access::read_u8(buffer, offset, field_path)?; - Ok((u32::from(v), 1)) - } - AlkTypeKind::Uint16 => { - let v = data_access::read_u16(buffer, offset, field_path, endian)?; - Ok((u32::from(v), 2)) - } - AlkTypeKind::Uint32 => { - let v = data_access::read_u32(buffer, offset, field_path, endian)?; - Ok((v, 4)) - } - other => Err(AlkTypeError::Schema(format!( - "unsupported byte discriminator type: {other}" - ))), - } -} - -/// Stringify a field-name discriminator value. Only the common kinds -/// (String, Uint8/16/32, Enum) are supported — anything else is a schema -/// error. -fn discriminator_string_value( - value: &FieldValue<'_>, - field_path: &str, -) -> Result { - match value { - FieldValue::String(s) => Ok(s.to_string()), - FieldValue::U8(v) => Ok(v.to_string()), - FieldValue::U16(v) => Ok(v.to_string()), - FieldValue::U32(v) => Ok(v.to_string()), - FieldValue::Enum(v) => Ok(v.to_string()), - other => Err(AlkTypeError::Schema(format!( - "union {field_path} has unsupported field discriminator kind: {other:?}" - ))), - } -} - -/// Read an `array` field: the count is known from the BAST `count` -/// field. Fixed-size elements produce a non-zero stride so the consumer -/// can index directly; variable-length elements produce a stride of `0` -/// so the consumer must walk each element sequentially. -fn read_array_value<'a>( - buffer: &'a [u8], - doc: &BastDoc<'_>, - array: &BastArray<'_>, - field_path: &str, - offset: usize, - endian: Endian, -) -> Result<(FieldValue<'a>, usize), AlkTypeError> { - let element_ty = array.element(); - let resolved_elem = doc.resolve_typeref(element_ty)?; - let elem_kind = resolved_elem.alk_kind(); - let count = array.count(); - let count_u32 = u32::try_from(count).map_err(|_| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("array count {count} overflows u32"), - })?; - - let element_stride = if elem_kind.is_fixed_size() { - elem_kind.type_size().unwrap_or(0) - } else { - 0 - }; - - let total = if element_stride == 0 { - walk_variable_array_size( - doc, - &resolved_elem, - buffer, - offset, - count, - endian, - field_path, - )? - } else { - count - .checked_mul(element_stride) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("array size {count} × stride {element_stride} overflows usize"), - })? - }; - - let end = offset - .checked_add(total) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("array end {offset} + {total} overflows usize"), - })?; - - Ok(( - FieldValue::Array { - count: count_u32, - element_start: offset, - element_stride, - }, - end, - )) -} - -/// Walk `count` variable-length array elements starting at `offset` and -/// return the total byte size of the element data. -fn walk_variable_array_size( - doc: &BastDoc<'_>, - elem_ty: &BastType<'_>, - buffer: &[u8], - start: usize, - count: usize, - endian: Endian, - field_path: &str, -) -> Result { - let mut position = start; - for i in 0..count { - let element_path = format!("{field_path}[{i}]"); - let (_, new_position) = read_typeref_value( - buffer, - doc, - elem_ty, - &element_path, - position, - endian, - )?; - if new_position < position { - return Err(AlkTypeError::Access { - field_path: element_path, - reason: format!("array element walked backwards: {position} → {new_position}"), - }); - } - position = new_position; - } - Ok(position - start) -} - -/// Read a value for a resolved `BastType` (used for array elements and -/// record values, which are TypeRefs, not fields). Splits the -/// `field`-bearing path from the `BastType`-only path. -fn read_typeref_value<'a>( - buffer: &'a [u8], - doc: &BastDoc<'_>, - ty: &BastType<'_>, - field_path: &str, - offset: usize, - endian: Endian, -) -> Result<(FieldValue<'a>, usize), AlkTypeError> { - let resolved = doc.resolve_typeref(ty)?; - match &resolved { - BastType::Struct(s) => { - let size = walk_struct_size(doc, s, buffer, offset, endian)?; - let end = offset - .checked_add(size) - .ok_or_else(|| AlkTypeError::Access { - field_path: field_path.to_string(), - reason: format!("struct end {offset} + {size} overflows usize"), - })?; - Ok((FieldValue::Struct { start: offset, end }, end)) - } - BastType::Union(u) => read_union_value(buffer, doc, u, field_path, offset, endian), - BastType::Array(a) => read_array_value(buffer, doc, a, field_path, offset, endian), - BastType::Record(r) => read_record_value(buffer, doc, r, field_path, offset, endian), - BastType::Ref(_) => Err(AlkTypeError::Schema(format!( - "internal: unresolved $ref at {field_path}" - ))), - BastType::Enum(_) => { - let v = data_access::read_enum(buffer, offset, field_path, endian)?; - Ok((FieldValue::Enum(v), offset + 4)) - } - BastType::Primitive(k) => read_primitive_value(buffer, *k, field_path, offset, endian), - } -} - -/// Read a primitive value. Shared by `read_field_value` and -/// `read_typeref_value`. -fn read_primitive_value<'a>( +/// Read a primitive leaf value at `offset` and return the value plus +/// the position just past it. `endian` is the field's effective +/// endianness, baked into the plan at compile time. +fn plan_read_primitive<'a>( buffer: &'a [u8], kind: AlkTypeKind, + endian: Endian, field_path: &str, offset: usize, - endian: Endian, ) -> Result<(FieldValue<'a>, usize), AlkTypeError> { match kind { AlkTypeKind::Int8 => { @@ -732,110 +368,63 @@ fn read_primitive_value<'a>( } AlkTypeKind::String => { let s = data_access::read_string(buffer, offset, field_path, endian)?; - let total = U32_SIZE + s.len(); - Ok((FieldValue::String(s), offset + total)) + let total = U32_SIZE + .checked_add(s.len()) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: "string size 4 + len overflows usize".to_string(), + })?; + let end = checked_end(offset, total, field_path, "string")?; + Ok((FieldValue::String(s), end)) } AlkTypeKind::Bytes => { let b = data_access::read_bytes(buffer, offset, field_path, endian)?; - let total = U32_SIZE + b.len(); - Ok((FieldValue::Bytes(b), offset + total)) + let total = U32_SIZE + .checked_add(b.len()) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: "bytes size 4 + len overflows usize".to_string(), + })?; + let end = checked_end(offset, total, field_path, "bytes")?; + Ok((FieldValue::Bytes(b), end)) } other => Err(AlkTypeError::Schema(format!( - "{other} is not a primitive readable by read_primitive_value" + "unsupported primitive kind {other} at {field_path}" ))), } } -/// Read a `record` field: `[count: u32]` followed by `count` entries of -/// `[key_len: u32][key_bytes][value]`. Returns the total size consumed. -/// The reader does not decode the entries — the consumer recurses into -/// the record's value schema. -/// -/// Returns the record's byte range as [`FieldValue::Bytes`] (the one -/// composite kind that does not return a typed descriptor — see the -/// `FieldValue` enum doc for the known asymmetry). -fn read_record_value<'a>( - buffer: &'a [u8], - doc: &BastDoc<'_>, - record: &BastRecord<'_>, - field_path: &str, +/// `offset + size`, checked — the shared end-position computation for +/// every composite walk. +fn checked_end( offset: usize, - endian: Endian, -) -> Result<(FieldValue<'a>, usize), AlkTypeError> { - let count = data_access::read_u32(buffer, offset, field_path, endian)?; - let count_usize = count as usize; - let values_ty = record.values(); - let mut position = offset + U32_SIZE; - for i in 0..count_usize { - let entry_path = format!("{field_path}[{i}].key"); - let key = data_access::read_string(buffer, position, &entry_path, endian)?; - position += U32_SIZE + key.len(); - let value_path = format!("{field_path}[{i}].value"); - let (_, new_position) = read_typeref_value( - buffer, - doc, - values_ty, - &value_path, - position, - endian, - )?; - position = new_position; - } - Ok((FieldValue::Bytes(&buffer[offset..position]), position)) -} - -/// Resolve a variant type and walk its size starting at `variant_start`. -/// Used by [`read_union_value`]. -fn resolve_and_walk_variant( - doc: &BastDoc<'_>, - variant_ty: &BastType<'_>, - buffer: &[u8], - variant_start: usize, - endian: Endian, + size: usize, field_path: &str, + what: &str, ) -> Result { - let variant_def = doc.resolve_typeref_as_def(variant_ty, field_path)?; - match variant_def.kind() { - BastDefKind::Struct(s) => walk_struct_size(doc, s, buffer, variant_start, endian), - BastDefKind::Union(u) => { - let (_, end) = read_union_value( - buffer, - doc, - u, - field_path, - variant_start, - endian, - )?; - Ok(end - variant_start) - } - other => Err(AlkTypeError::Schema(format!( - "union {field_path} variant must be Struct or Union, got {kind}", - kind = other.alk_kind() - ))), - } + offset.checked_add(size).ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("{what} end {offset} + {size} overflows usize"), + }) } -/// Walk the fields of a struct sequentially, reading length prefixes for -/// variable-length fields, and return the total byte size of the struct -/// starting at `offset`. Does not return field values — only advances -/// the cursor to compute the struct's end position. -/// -/// `doc` is the BAST document used to resolve `$ref` pointers found in -/// nested union variants. -fn walk_struct_size( - doc: &BastDoc<'_>, - struct_node: &BastStruct<'_>, +/// Walk a struct plan's fields sequentially from `offset`, returning +/// the total byte size. Only advances the cursor (reads length +/// prefixes); does not collect values. +fn plan_walk_struct_size( + plan: &ReadPlan, buffer: &[u8], offset: usize, - endian: Endian, + field_path: &str, ) -> Result { let mut position = offset; - for field in struct_node.fields() { + for field in plan.fields() { + let sub_path = format!("{field_path}.{}", field.name()); let (_, new_position) = - read_field_value(buffer, doc, field, field.source(), field.name(), position, endian)?; + plan_read_kind(field, buffer, position, &sub_path)?; if new_position < position { return Err(AlkTypeError::Access { - field_path: field.name().to_string(), + field_path: sub_path, reason: format!("struct field walked backwards: {position} → {new_position}"), }); } @@ -844,6 +433,404 @@ fn walk_struct_size( Ok(position - offset) } +/// Read a value for a plan node used as a TypeRef position (array +/// element, record value, union variant body) — same dispatch as +/// `plan_read_field_at` but over the composite `body` directly. +fn plan_read_kind<'a>( + field: &FieldPlan, + buffer: &'a [u8], + offset: usize, + field_path: &str, +) -> Result<(FieldValue<'a>, usize), AlkTypeError> { + match field.kind() { + ReadKind::Primitive(kind) => { + plan_read_primitive(buffer, *kind, field.endian(), field_path, offset) + } + ReadKind::Enum => { + let v = data_access::read_enum(buffer, offset, field_path, field.endian())?; + Ok((FieldValue::Enum(v), offset + 4)) + } + ReadKind::Struct => { + let body = match field.body() { + Some(CompositePlan::Struct(p)) => p, + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: struct node {field_path} has no Struct body" + ))) + } + }; + let size = plan_walk_struct_size(body, buffer, offset, field_path)?; + let end = checked_end(offset, size, field_path, "struct")?; + Ok((FieldValue::Struct { start: offset, end }, end)) + } + ReadKind::Union => { + let body = field.body().ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: union node {field_path} has no body" + )) + })?; + plan_read_union(body, buffer, offset, field_path, field.endian()) + } + ReadKind::Array => { + let body = field.body().ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: array node {field_path} has no body" + )) + })?; + plan_read_array(body, buffer, offset, field_path, field.endian()) + } + ReadKind::Record => { + let body = field.body().ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: record node {field_path} has no body" + )) + })?; + plan_read_record(body, buffer, offset, field_path, field.endian()) + } + } +} + +/// Read a `union` composite body: read the discriminator, return the +/// variant start offset, and advance past the entire union payload. +/// +/// Byte-offset discriminators read the integer at `disc.offset` and +/// start the variant after the discriminator's bytes (`shared: None`). +/// Field-name discriminators walk the union's `shared` sub-plan (the +/// declared `fields`: the discriminator field + any shared fields), +/// read the discriminator field at its index within `shared`, and start +/// the variant after the shared fields. Nested unions (a variant body +/// that is itself `CompositePlan::Union`) recurse naturally. +fn plan_read_union<'a>( + body: &CompositePlan, + buffer: &'a [u8], + offset: usize, + field_path: &str, + endian: Endian, +) -> Result<(FieldValue<'a>, usize), AlkTypeError> { + let (disc, shared, variants) = match body { + CompositePlan::Union { + disc, + shared, + variants, + } => (disc, shared, variants), + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: union body at {field_path} is not CompositePlan::Union" + ))) + } + }; + match disc { + DiscriminatorPlan::Byte { + offset: disc_offset, + disc_type, + } => { + let abs_offset = checked_end(offset, *disc_offset, field_path, "discriminator")?; + let (disc_value, disc_size) = + plan_read_byte_discriminator(buffer, abs_offset, field_path, *disc_type, endian)?; + let key = disc_value.to_string(); + let variant = variants + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("unknown union discriminator value: {key}"), + })?; + let variant_start = checked_end(abs_offset, disc_size, field_path, "variant")?; + let variant_size = plan_walk_variant_size(variant, buffer, variant_start, field_path, endian)?; + let end = checked_end(variant_start, variant_size, field_path, "union")?; + Ok(( + FieldValue::Union { + discriminator: key, + variant_start, + }, + end, + )) + } + DiscriminatorPlan::Field { name, field_index } => { + let shared_plan = shared.as_ref().ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: field-disc union at {field_path} has no shared plan" + )) + })?; + let disc_field = shared_plan.fields().get(*field_index).ok_or_else(|| { + AlkTypeError::Schema(format!( + "internal: union {field_path} discriminator index {field_index} out of range" + )) + })?; + let disc_path = format!("{field_path}.{name}"); + let key = + plan_discriminator_string_value(disc_field, buffer, offset, &disc_path)?; + let variant = variants + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v) + .ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("unknown union discriminator value: {key}"), + })?; + // Walk the whole shared plan (discriminator field + any + // shared fields); the variant starts after all of it. + let mut position = offset; + for field in shared_plan.fields() { + let sub_path = format!("{field_path}.{}", field.name()); + let (_, new_position) = plan_read_kind(field, buffer, position, &sub_path)?; + if new_position < position { + return Err(AlkTypeError::Access { + field_path: sub_path, + reason: format!( + "union shared field walked backwards: {position} → {new_position}" + ), + }); + } + position = new_position; + } + let variant_size = + plan_walk_variant_size(variant, buffer, position, field_path, endian)?; + let end = checked_end(position, variant_size, field_path, "union")?; + Ok(( + FieldValue::Union { + discriminator: key, + variant_start: position, + }, + end, + )) + } + } +} + +/// Stringify a field-name discriminator value without re-reading: the +/// discriminator field's kind determines the buffer form. Mirrors the +/// 0.2.0 `discriminator_string_value` over the `FieldValue` the disc +/// read produced. +fn plan_discriminator_string_value( + disc_field: &FieldPlan, + buffer: &[u8], + offset: usize, + field_path: &str, +) -> Result { + match disc_field.kind() { + ReadKind::Primitive(AlkTypeKind::String) => { + let s = data_access::read_string(buffer, offset, field_path, disc_field.endian())?; + Ok(s.to_string()) + } + ReadKind::Primitive(AlkTypeKind::Uint8) => { + Ok(data_access::read_u8(buffer, offset, field_path)?.to_string()) + } + ReadKind::Primitive(AlkTypeKind::Uint16) => Ok(data_access::read_u16( + buffer, + offset, + field_path, + disc_field.endian(), + )? + .to_string()), + ReadKind::Primitive(AlkTypeKind::Uint32) => Ok(data_access::read_u32( + buffer, + offset, + field_path, + disc_field.endian(), + )? + .to_string()), + ReadKind::Enum => Ok(data_access::read_enum( + buffer, + offset, + field_path, + disc_field.endian(), + )? + .to_string()), + other => Err(AlkTypeError::Schema(format!( + "union {field_path} has unsupported field discriminator kind: {other:?}" + ))), + } +} + +/// Read a byte-offset discriminator integer and return its value (as a +/// `u32`) plus its byte size. +fn plan_read_byte_discriminator( + buffer: &[u8], + offset: usize, + field_path: &str, + disc_type: AlkTypeKind, + endian: Endian, +) -> Result<(u32, usize), AlkTypeError> { + match disc_type { + AlkTypeKind::Uint8 => { + let v = data_access::read_u8(buffer, offset, field_path)?; + Ok((u32::from(v), 1)) + } + AlkTypeKind::Uint16 => { + let v = data_access::read_u16(buffer, offset, field_path, endian)?; + Ok((u32::from(v), 2)) + } + AlkTypeKind::Uint32 => { + let v = data_access::read_u32(buffer, offset, field_path, endian)?; + Ok((v, 4)) + } + other => Err(AlkTypeError::Schema(format!( + "unsupported byte discriminator type: {other}" + ))), + } +} + +/// Resolve a compiled variant body to its byte size starting at +/// `variant_start`. Struct variants walk their field list; union +/// variants (nested unions) read their discriminator and advance. +fn plan_walk_variant_size( + variant: &CompositePlan, + buffer: &[u8], + variant_start: usize, + field_path: &str, + endian: Endian, +) -> Result { + match variant { + CompositePlan::Struct(p) => plan_walk_struct_size(p, buffer, variant_start, field_path), + CompositePlan::Union { .. } => { + let (_, end) = + plan_read_union(variant, buffer, variant_start, field_path, endian)?; + Ok(end - variant_start) + } + other => Err(AlkTypeError::Schema(format!( + "internal: union variant at {field_path} compiled to a non-struct/union body: {other:?}" + ))), + } +} + +/// Read an `array` composite body: fixed-size elements index by their +/// compiled stride; variable-length elements (stride `0`) are walked +/// sequentially. +fn plan_read_array<'a>( + body: &CompositePlan, + buffer: &'a [u8], + offset: usize, + field_path: &str, + endian: Endian, +) -> Result<(FieldValue<'a>, usize), AlkTypeError> { + let (element, count, element_stride) = match body { + CompositePlan::Array { + element, + count, + element_stride, + } => (element, *count, *element_stride), + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: array body at {field_path} is not CompositePlan::Array" + ))) + } + }; + let count_u32 = u32::try_from(count).map_err(|_| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("array count {count} overflows u32"), + })?; + + let total = if element_stride == 0 { + plan_walk_variable_array_size(element, buffer, offset, count, endian, field_path)? + } else { + count.checked_mul(element_stride).ok_or_else(|| AlkTypeError::Access { + field_path: field_path.to_string(), + reason: format!("array size {count} × stride {element_stride} overflows usize"), + })? + }; + + let end = checked_end(offset, total, field_path, "array")?; + + Ok(( + FieldValue::Array { + count: count_u32, + element_start: offset, + element_stride, + }, + end, + )) +} + +/// Walk `count` variable-length array elements starting at `offset` and +/// return the total byte size of the element data. +fn plan_walk_variable_array_size( + element: &CompositePlan, + buffer: &[u8], + start: usize, + count: usize, + endian: Endian, + field_path: &str, +) -> Result { + let mut position = start; + for i in 0..count { + let element_path = format!("{field_path}[{i}]"); + let (_, new_position) = + plan_read_composite(element, buffer, position, &element_path, endian)?; + if new_position < position { + return Err(AlkTypeError::Access { + field_path: element_path, + reason: format!("array element walked backwards: {position} → {new_position}"), + }); + } + position = new_position; + } + Ok(position - start) +} + +/// Read a composite body used as a TypeRef (array element, record +/// value). Structs walk their size; unions/arrays/records recurse. +fn plan_read_composite<'a>( + body: &CompositePlan, + buffer: &'a [u8], + offset: usize, + field_path: &str, + endian: Endian, +) -> Result<(FieldValue<'a>, usize), AlkTypeError> { + match body { + CompositePlan::Struct(plan) => { + let size = plan_walk_struct_size(plan, buffer, offset, field_path)?; + let end = checked_end(offset, size, field_path, "struct")?; + Ok((FieldValue::Struct { start: offset, end }, end)) + } + CompositePlan::Union { .. } => plan_read_union(body, buffer, offset, field_path, endian), + CompositePlan::Array { .. } => plan_read_array(body, buffer, offset, field_path, endian), + CompositePlan::Record { .. } => plan_read_record(body, buffer, offset, field_path, endian), + } +} + +/// Read a `record` composite body: `[count: u32]` followed by `count` +/// entries of `[key_len: u32][key_bytes][value]`. Returns the record's +/// byte range as `FieldValue::Bytes` (the one composite kind that does +/// not return a typed descriptor — see the `FieldValue` enum doc). +fn plan_read_record<'a>( + body: &CompositePlan, + buffer: &'a [u8], + offset: usize, + field_path: &str, + endian: Endian, +) -> Result<(FieldValue<'a>, usize), AlkTypeError> { + let value_body = match body { + CompositePlan::Record { value } => value, + _ => { + return Err(AlkTypeError::Schema(format!( + "internal: record body at {field_path} is not CompositePlan::Record" + ))) + } + }; + let count = data_access::read_u32(buffer, offset, field_path, endian)?; + let count_usize = count as usize; + let mut position = offset + U32_SIZE; + for i in 0..count_usize { + let key_path = format!("{field_path}[{i}].key"); + let key = data_access::read_string(buffer, position, &key_path, endian)?; + let key_len = U32_SIZE + .checked_add(key.len()) + .ok_or_else(|| AlkTypeError::Access { + field_path: key_path.clone(), + reason: "key size 4 + len overflows usize".to_string(), + })?; + position = checked_end(position, key_len, &key_path, "key")?; + let value_path = format!("{field_path}[{i}].value"); + let (_, new_position) = + plan_read_composite(value_body, buffer, position, &value_path, endian)?; + position = new_position; + } + Ok((FieldValue::Bytes(&buffer[offset..position]), position)) +} + + #[cfg(test)] mod tests { use super::*; @@ -869,7 +856,8 @@ mod tests { } fn reader(root: &Value, name: &str) -> SequentialReader { - SequentialReader::new(root, name).expect("reader") + let plan = ReadPlan::compile(root, name).expect("plan"); + SequentialReader::new(Arc::new(plan)) } #[test] @@ -1106,7 +1094,7 @@ mod tests { "U": { "kind": "uint32" } } }); - let err = SequentialReader::new(&root, "U").unwrap_err(); + let err = ReadPlan::compile(&root, "U").unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } @@ -1454,7 +1442,7 @@ mod tests { #[test] fn rejects_schema_with_missing_root() { let root = json!({ "$defs": { "Other": { "kind": "struct", "fields": [] } } }); - let err = SequentialReader::new(&root, "Missing").unwrap_err(); + let err = ReadPlan::compile(&root, "Missing").unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } } \ No newline at end of file diff --git a/tests/error_paths.rs b/tests/error_paths.rs index 3ffd614..effc7e3 100644 --- a/tests/error_paths.rs +++ b/tests/error_paths.rs @@ -376,7 +376,8 @@ fn sequential_reader_buffer_too_short_returns_access_error() { } }); let buffer = [0u8; 2]; - let mut reader = SequentialReader::new(&root, "S").unwrap(); + let plan = ReadPlan::compile(&root, "S").unwrap(); + let mut reader = SequentialReader::new(std::sync::Arc::new(plan)); let err = reader.read_next(&buffer).unwrap_err(); assert!(matches!(err, AlkTypeError::Access { .. }), "got {err:?}"); } @@ -392,7 +393,8 @@ fn sequential_reader_unknown_field_returns_schema_error() { } }); let buffer = [0u8; 4]; - let mut reader = SequentialReader::new(&root, "S").unwrap(); + let plan = ReadPlan::compile(&root, "S").unwrap(); + let mut reader = SequentialReader::new(std::sync::Arc::new(plan)); let err = reader.read_field(&buffer, "missing").unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } @@ -409,7 +411,7 @@ fn sequential_reader_new_non_struct_returns_schema_error() { "A": { "kind": "struct", "fields": [] } } }); - let err = SequentialReader::new(&root, "U").unwrap_err(); + let err = ReadPlan::compile(&root, "U").unwrap_err(); assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}"); } diff --git a/tests/poc_roundtrip.rs b/tests/poc_roundtrip.rs index 5f2be1c..aacdbe2 100644 --- a/tests/poc_roundtrip.rs +++ b/tests/poc_roundtrip.rs @@ -459,7 +459,8 @@ fn sequential_reader_round_trip_packed_buffer() -> Result<(), AlkTypeError> { let after = 1 + 4 + payload.len(); data_access::write_u8(&mut buffer, after, 99, "tail")?; - let mut reader = SequentialReader::new(&root, "S")?; + let plan = ReadPlan::compile(&root, "S")?; + let mut reader = SequentialReader::new(std::sync::Arc::new(plan)); assert_eq!(reader.endian(), Endian::Little); assert_eq!(reader.position(), 0); @@ -502,7 +503,8 @@ fn sequential_reader_read_field_walks_preceding_fields() -> Result<(), AlkTypeEr data_access::write_u32(&mut buffer, 1, 0xDEADBEEF, "b", Endian::Little)?; data_access::write_u8(&mut buffer, 5, 9, "c")?; - let mut reader = SequentialReader::new(&root, "S")?; + let plan = ReadPlan::compile(&root, "S")?; + let mut reader = SequentialReader::new(std::sync::Arc::new(plan)); let value = reader.read_field(&buffer, "c")?; assert_eq!(value, FieldValue::U8(9)); assert_eq!(reader.position(), 6);