From 21f21a043aca3e279cd801767723f8dd1c564883 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:02:03 +0000 Subject: [PATCH 1/3] feat(codegen): emit typed WIT list from Array data types (#345) An AADL `data` type with `Data_Representation => Array` previously degraded to an opaque `type x = list` in `spar codegen --format wit`, dropping the element type and dimension so an inter-core contract could only say `list`, not the real payload. Add a `DataShape::Array { element, len }` variant. `resolve_data_shape` now recognizes the Data Modeling Annex `Array` representation, resolves the `Base_Type` classifier to the element shape (recursively, reusing the scalar/record resolution) and reads the first `Dimension` as the length. Both the list-wrapped property syntax (`Base_Type => (classifier(T))`, `Dimension => (16)`) and the bare forms are accepted. Codegen emits a bounded fixed-size `list` when the dimension is known (no `cabi_realloc`, consistent with the no_alloc discipline) and an unbounded `list` otherwise. An Array whose `Base_Type` is absent or resolves to an unsized/unresolvable element is NOT fabricated: a top-level port gracefully keeps the legacy opaque `list`; an array-typed record field takes the record no_alloc hard-error path. The Rust port type mirrors wit-bindgen's lowering (`list` -> `[T; N]`, `list` -> `Vec`). Oracles (spar-codegen/src/wit_gen.rs): the #345 NavState case yields `type vehicle-state = list;`; unbounded -> `list`; no Base_Type and unsized-element both stay `list`; an array-typed record field -> `nav: list`. Each asserts the emitted WIT parses and resolves under wit-parser. REQ-CODEGEN-WIT-ARRAY-001. Co-Authored-By: Claude Opus 4.8 (1M context) --- artifacts/requirements.yaml | 52 +++++ crates/spar-codegen/src/rust_gen.rs | 20 +- crates/spar-codegen/src/wit_gen.rs | 322 ++++++++++++++++++++++++++++ crates/spar-hir-def/src/resolver.rs | 100 +++++++++ 4 files changed, 492 insertions(+), 2 deletions(-) diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index a9139db..9ce62cf 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -3055,6 +3055,58 @@ artifacts: - type: traces-to target: REQ-CODEGEN-WIT-STATE-001 + - id: REQ-CODEGEN-WIT-ARRAY-001 + type: requirement + title: "Structured WIT list from a Data Modeling Annex Array data type" + description: > + Codegen WIT emission shall map an AADL `data` type whose Data Modeling Annex + `Data_Representation` is `Array` to a typed WIT `list`, closing the #345 gap + where EVERY array data type degraded to the opaque `type x = list` blob + (element type, signedness/float, and dimension all dropped), so an inter-core + contract could only say `list`, not the real payload. Delivered and + oracle-verified: + (1) `resolve_data_shape` gains a `DataShape::Array { element, len }` arm + (spar-hir-def): a `data` type with `Data_Representation => Array` resolves its + `Base_Type` classifier to the element shape (recursively, reusing the same + scalar/record resolution as records) and reads the first `Dimension` value as + the length. Both the maintainer's list-wrapped property syntax + (`Base_Type => (classifier(Pkg::T))`, `Dimension => (16)`) and the bare forms + are accepted (typed_value: `List([ClassifierValue])`/`ClassifierValue`, + `List([Integer])`/`Integer`). + (2) A FIXED `Dimension` emits a BOUNDED fixed-size WIT `list` — no + `cabi_realloc`, consistent with the RECORDS-002 no_alloc discipline (only + unbounded `list`/`string` force dynamic allocation); an array with NO + `Dimension` emits an unbounded `list`; an `Array` with NO resolvable + `Base_Type` is NOT fabricated — it keeps the legacy opaque `list`. + (3) The element resolves through the shared `shape_wit_type` helper, so an + array-of-record and an array-typed record FIELD both work; the generated Rust + port type mirrors wit-bindgen's lowering (`list` -> `[T; N]`, + `list` -> `Vec`). + ORACLE (executed unit tests, spar-codegen/src/wit_gen.rs): the #345 NavState + case (`Data_Representation => Array` + `Base_Type => (classifier(Float32))` + + `Dimension => (16)`) generates `type vehicle-state = list;`; an + unbounded array -> `list`; a Base_Type-less array -> `list` + (negative control); an array-typed record field -> `nav: list`. Each + asserts the emitted WIT PARSES + RESOLVES under `wit_parser` (a real + fixed-size-list bind, not a string match). + SCOPE / NOT CLAIMED: element bit-width comes from the `Base_Type`'s own + `Data_Size`; when the `Base_Type` resolves to an element with no concrete + shape (an unsized `Base_Types::Float` with no `Data_Size`, or an + unresolvable classifier) `array_of` returns `None`, so the array type is + NOT fabricated — a top-level array port GRACEFULLY falls back to the legacy + opaque `type x = list` (no regression, no hard error), and an + array-typed record FIELD takes the record no_alloc hard-error path (as an + opaque field would). Multi-dimensional arrays use only the first + `Dimension`. `list`/`list` byte-layout-vs-canonical-ABI + equivalence is NOT proven here (that is #327's ordeal work), and the Rust + port type (`[T; N]`/`Vec`) mirrors wit-bindgen's documented lowering but + no compiled-crate oracle for array ports is asserted in this requirement. + status: implemented + tags: [codegen, wit, interop, no-alloc] + links: + - type: traces-to + target: REQ-CODEGEN-WIT-RECORDS-001 + - id: REQ-CODEGEN-VERIFY-WORKSPACE-001 type: requirement title: "spar codegen --verify test/build emits inert artifacts" diff --git a/crates/spar-codegen/src/rust_gen.rs b/crates/spar-codegen/src/rust_gen.rs index 4768705..481af3c 100644 --- a/crates/spar-codegen/src/rust_gen.rs +++ b/crates/spar-codegen/src/rust_gen.rs @@ -52,6 +52,16 @@ fn data_shape_to_rust_type(shape: &DataShape) -> String { DataShape::Record { type_name, .. } => { format!("crate::types::{}", to_pascal_case(type_name)) } + // wit-bindgen lowers a fixed-size `list` to a Rust `[T; N]` and an + // unbounded `list` to `Vec`; mirror that so the port field type + // matches the ABI lowering. REQ-CODEGEN-WIT-ARRAY-001 (#345). + DataShape::Array { element, len } => { + let elem = data_shape_to_rust_type(element); + match len { + Some(n) => format!("[{elem}; {n}]"), + None => format!("Vec<{elem}>"), + } + } DataShape::Opaque => "Vec".to_string(), } } @@ -833,11 +843,17 @@ fn flatten_record_rust( } let mut rows = Vec::with_capacity(fields.len()); for f in fields { - // Recurse into nested records first so they are defined before use. + // Recurse into nested records first so they are defined before use — + // including a record reached through array element(s) (#345), so an + // `array of record` field does not reference an undefined struct. + let mut inner = &f.shape; + while let DataShape::Array { element, .. } = inner { + inner = element; + } if let DataShape::Record { type_name: nt, fields: nf, - } = &f.shape + } = inner { flatten_record_rust(nt, nf, out, seen); } diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index c20d2d2..24fde43 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -49,6 +49,49 @@ fn scalar_wit(bytes: u64, kind: ScalarKind) -> Option<&'static str> { /// One emitted WIT record definition: `(wit-type-name, [(field, wit-type)])`. type RecordDef = (String, Vec<(String, String)>); +/// Resolve a [`DataShape`] to the WIT type string used to reference it (a scalar +/// keyword, a nested record name, or a `list`), emitting any nested +/// record definitions into `out` along the way. A non-encodable scalar/opaque +/// leaf is collected into `errors` (the no_alloc hard-error policy) and a `u8` +/// placeholder returned — the caller aborts on any error before emitting. +/// REQ-CODEGEN-WIT-ARRAY-001 (#345) extends this to array elements. +fn shape_wit_type( + shape: &DataShape, + out: &mut Vec, + seen: &mut std::collections::BTreeSet, + errors: &mut Vec, +) -> String { + match shape { + DataShape::Scalar { bytes, kind } => match scalar_wit(*bytes, *kind) { + Some(w) => w.to_string(), + None => { + errors.push(format!( + "{bytes}-byte {kind:?} has no no_alloc WIT scalar (int 1/2/4/8, float 4/8 only)" + )); + "u8".to_string() + } + }, + DataShape::Record { type_name, fields } => { + flatten_record(type_name, fields, out, seen, errors) + } + DataShape::Array { element, len } => { + let elem = shape_wit_type(element, out, seen, errors); + match len { + Some(n) => format!("list<{elem}, {n}>"), + None => format!("list<{elem}>"), + } + } + DataShape::Opaque => { + errors.push( + "array element type declares no Data_Size and has no fields — it cannot be \ + encoded without heap allocation" + .to_string(), + ); + "u8".to_string() + } + } +} + /// Recursively flatten a `Record` shape into WIT record definitions, appending /// each distinct record to `out` in NESTED-FIRST order (a record is pushed after /// the records it depends on) and returning the top record's WIT type name. @@ -85,6 +128,10 @@ fn flatten_record( let nested = flatten_record(nested_tn, nested_fields, out, seen, errors); rows.push((wit_ident(field), nested)); } + DataShape::Array { .. } => { + let wit_ty = shape_wit_type(&f.shape, out, seen, errors); + rows.push((wit_ident(field), wit_ty)); + } DataShape::Opaque => errors.push(format!( "record `{type_name}` field `{field}`: type declares no Data_Size and has no \ fields — it cannot be encoded without heap allocation" @@ -169,6 +216,13 @@ pub fn generate_wit( let wit_ty = scalar_wit(bytes, kind).unwrap_or(DEFAULT_WIT_TYPE); aliases.push((ty.clone(), wit_ty.to_string())); } + // An Array data type aliases to a `list` (fixed-size when the + // Dimension is known — bounded, no `cabi_realloc`). Any nested record + // element is emitted into `record_defs` first. #345. + shape @ DataShape::Array { .. } => { + let wit_ty = shape_wit_type(&shape, &mut record_defs, &mut seen, &mut errors); + aliases.push((ty.clone(), wit_ty)); + } DataShape::Opaque => aliases.push((ty.clone(), DEFAULT_WIT_TYPE.to_string())), } } @@ -1008,4 +1062,272 @@ end NestPkg; ) }); } + + /// Fixture for REQ-CODEGEN-WIT-ARRAY-001 (#345): a Data Modeling Annex + /// `Array` data type — `Data_Representation => Array`, a `Base_Type` + /// classifier, and a fixed `Dimension` — the real jess NavState case + /// (16 × f32). Also declares an UNBOUNDED array (no `Dimension`), an + /// array with the maintainer's exact `(classifier(...))` / `(16)` + /// list-wrapped property syntax, and a NEGATIVE control (Array with no + /// `Base_Type`, which must stay opaque). Referenced by process ports. + fn build_array_test_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package ArrPkg +public + data Float32 + properties + Data_Size => 4 Bytes; + Data_Representation => Float; + end Float32; + + -- Fixed 16 x f32 (the #345 NavState message). Maintainer's exact + -- list-wrapped Base_Type / Dimension syntax. + data VehicleState + properties + Data_Representation => Array; + Base_Type => (classifier(Float32)); + Dimension => (16); + end VehicleState; + + -- Unbounded array (no Dimension) -> list. + data Samples + properties + Data_Representation => Array; + Base_Type => (classifier(Float32)); + end Samples; + + -- Array with no Base_Type -> must stay opaque (list), not fabricated. + data Mystery + properties + Data_Representation => Array; + Dimension => (8); + end Mystery; + + -- Element with NO Data_Size (an unsized Float, like the standard + -- Base_Types::Float): the element does not resolve to a concrete WIT + -- type, so the array must NOT be fabricated -> stays opaque list. + data UnsizedF + properties + Data_Representation => Float; + end UnsizedF; + + data Blurry + properties + Data_Representation => Array; + Base_Type => (classifier(UnsizedF)); + Dimension => (4); + end Blurry; + + process Estimator + features + state_out: out data port VehicleState; + samples_out: out data port Samples; + mystery_out: out data port Mystery; + blurry_out: out data port Blurry; + end Estimator; + + process implementation Estimator.Impl + subcomponents + est_thread: thread EstThread.Impl; + end Estimator.Impl; + + thread EstThread + end EstThread; + + thread implementation EstThread.Impl + end EstThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + est: process Estimator.Impl; + end Top.Impl; +end ArrPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "arr.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + let inst = SystemInstance::instantiate( + &scope, + &Name::new("ArrPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// RED-before-green oracle for REQ-CODEGEN-WIT-ARRAY-001 (#345): an AADL + /// `data` type with `Data_Representation => Array` + `Base_Type` + a fixed + /// `Dimension` must generate a bounded WIT `list` (no `cabi_realloc`), + /// NOT the opaque `list` blob. An unbounded array (no `Dimension`) is a + /// plain `list`, and an Array with no `Base_Type` stays opaque. + #[test] + fn array_data_type_becomes_wit_list() { + let (scope, inst) = build_array_test_instance(); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("test model has a process"); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + let body: String = file + .content + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + // Fixed Dimension -> bounded fixed-size list of the element scalar. + assert!( + body.contains("type vehicle-state = list;"), + "fixed Array must map to `list`, not opaque:\n{body}" + ); + // Unbounded array -> plain list. + assert!( + body.contains("type samples = list;"), + "unbounded Array must map to `list`:\n{body}" + ); + // No Base_Type -> the element is unknown, so it must NOT be fabricated; + // it stays the legacy opaque byte buffer. + assert!( + body.contains("type mystery = list;"), + "Array with no Base_Type must stay opaque `list`:\n{body}" + ); + // Unsized element (Float with no Data_Size, like standard + // Base_Types::Float) -> element does not resolve, so the array must NOT + // be fabricated; it stays opaque `list` (no regression, no error). + assert!( + body.contains("type blurry = list;"), + "Array with an unsized element must stay opaque `list`:\n{body}" + ); + + // Authoritative: the fixed-size list must PARSE + RESOLVE under + // wit-parser (proves `list` is real, bindable WIT). + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("array.wit", &file.content) + .unwrap_or_else(|e| { + panic!( + "generated array WIT failed to parse/resolve: {e}\n---\n{}", + file.content + ) + }); + } + + /// Fixture: a `data implementation` record whose field is itself an Array + /// data type (`nav: VehicleState` where VehicleState is 16 x f32). + fn build_record_with_array_field_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package RecArrPkg +public + data Float32 + properties + Data_Size => 4 Bytes; + Data_Representation => Float; + end Float32; + + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data VehicleState + properties + Data_Representation => Array; + Base_Type => (classifier(Float32)); + Dimension => (16); + end VehicleState; + + data Frame + end Frame; + + data implementation Frame.Impl + subcomponents + seq: data Word32; + nav: data VehicleState; + end Frame.Impl; + + process Store + features + frame_in: in data port Frame.Impl; + end Store; + + process implementation Store.Impl + subcomponents + th: thread StoreThread.Impl; + end Store.Impl; + + thread StoreThread + end SinkThread; + + thread implementation StoreThread.Impl + end StoreThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process Store.Impl; + end Top.Impl; +end RecArrPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "recarr.aadl".to_string(), aadl.to_string()); + let tree = spar_hir_def::file_item_tree(&db, sf); + let scope = GlobalScope::from_trees(vec![tree]); + let inst = SystemInstance::instantiate( + &scope, + &Name::new("RecArrPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// Oracle for an Array-typed record FIELD (#345): a record subcomponent whose + /// type is an Array data type becomes a `field: list` inside the WIT + /// record — the element resolves recursively through `shape_wit_type`. + #[test] + fn record_field_of_array_type_becomes_list() { + let (scope, inst) = build_record_with_array_field_instance(); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("test model has a process"); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + let body: String = file + .content + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + assert!( + body.contains("record frame-impl {"), + "expected record `frame-impl`:\n{body}" + ); + assert!( + body.contains("seq: u32"), + "expected scalar field `seq: u32`:\n{body}" + ); + assert!( + body.contains("nav: list"), + "Array-typed record field must be `list`:\n{body}" + ); + + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("recarr.wit", &file.content) + .unwrap_or_else(|e| { + panic!( + "record-with-array WIT failed to parse/resolve: {e}\n---\n{}", + file.content + ) + }); + } } diff --git a/crates/spar-hir-def/src/resolver.rs b/crates/spar-hir-def/src/resolver.rs index 1126f6a..c6e6bf1 100644 --- a/crates/spar-hir-def/src/resolver.rs +++ b/crates/spar-hir-def/src/resolver.rs @@ -45,6 +45,15 @@ pub enum DataShape { type_name: String, fields: Vec, }, + /// A `data` classifier whose Data Modeling Annex `Data_Representation` is + /// `Array`: a homogeneous sequence of `element`, with `len` copies when the + /// `Dimension` property fixes the size (→ a bounded WIT `list`) or + /// `None` for an unbounded array (→ `list`). REQ-CODEGEN-WIT-ARRAY-001 + /// (GitHub #345). + Array { + element: Box, + len: Option, + }, /// A `data` classifier with neither a `Data_Size` nor subcomponents, or one /// that does not resolve — opaque (codegen keeps its legacy handling). Opaque, @@ -400,9 +409,100 @@ impl GlobalScope { if let Some((bytes, kind)) = self.scalar_of(from_package, classifier) { return DataShape::Scalar { bytes, kind }; } + // A data type declaring an Array Data_Representation = a WIT list of its + // Base_Type element (fixed-size when Dimension is set). + if let Some(array) = self.array_of(from_package, classifier) { + return array; + } DataShape::Opaque } + /// Resolve a `data` type whose Data Modeling Annex `Data_Representation` is + /// `Array` into a [`DataShape::Array`]: the element shape comes from the + /// `Base_Type` classifier (resolved recursively) and the length from the + /// first `Dimension` value (absent → unbounded). Returns `None` when the + /// classifier is not an `Array` `data` type or has no resolvable + /// `Base_Type`. REQ-CODEGEN-WIT-ARRAY-001 (GitHub #345). + fn array_of(&self, from_package: &Name, classifier: &ClassifierRef) -> Option { + use crate::item_tree::PropertyExpr; + let resolved = self.resolve_classifier(from_package, classifier); + let (loc, decl_package) = match &resolved { + ResolvedClassifier::ComponentType { loc, package } => (*loc, package.clone()), + _ => return None, + }; + let ct = self.get_component_type(loc)?; + if ct.category != crate::item_tree::ComponentCategory::Data { + return None; + } + let tree = self.tree(loc.tree)?; + // Last path segment of an enum literal, so a qualified value like + // `Data_Model::Array` still matches `Array`. + fn leaf(v: &str) -> &str { + v.rsplit("::").next().unwrap_or(v).trim() + } + // A `Base_Type => (classifier(Pkg::T))` value lowers to a `List` holding + // one `ClassifierValue`; a bare `classifier(Pkg::T)` lowers to the + // `ClassifierValue` directly. Accept both shapes. + fn first_classifier(expr: &PropertyExpr) -> Option<&ClassifierRef> { + match expr { + PropertyExpr::ClassifierValue(cr) => Some(cr), + PropertyExpr::List(items) => items.iter().find_map(first_classifier), + _ => None, + } + } + // A `Dimension => (16)` value lowers to a `List` holding one `Integer`; + // a bare `16` lowers to the `Integer` directly. + fn first_int(expr: &PropertyExpr) -> Option { + match expr { + PropertyExpr::Integer(n, _) => Some(*n), + PropertyExpr::List(items) => items.iter().find_map(first_int), + _ => None, + } + } + let mut is_array = false; + let mut base_type: Option = None; + let mut dim: Option = None; + for &pa_idx in &ct.property_associations { + let pa = &tree.property_associations[pa_idx]; + let pn = pa.name.property_name.as_str(); + if pn.eq_ignore_ascii_case("Data_Representation") { + is_array = match &pa.typed_value { + Some(PropertyExpr::Enum(n)) => n.as_str().eq_ignore_ascii_case("Array"), + _ => leaf(&pa.value).eq_ignore_ascii_case("Array"), + }; + } else if pn.eq_ignore_ascii_case("Base_Type") { + base_type = pa.typed_value.as_ref().and_then(first_classifier).cloned(); + } else if pn.eq_ignore_ascii_case("Dimension") { + dim = pa + .typed_value + .as_ref() + .and_then(first_int) + .filter(|&n| n >= 0) + .map(|n| n as u64); + } + } + if !is_array { + return None; + } + // Without a Base_Type the element is unknown — leave it Opaque so codegen + // keeps its legacy handling rather than fabricating an element type. + let base = base_type?; + let element = self.resolve_data_shape(&decl_package, &base); + // If the element itself does not resolve to a concrete shape (an unsized + // or unresolvable Base_Type — e.g. a `Base_Types::Float` with no + // `Data_Size`), do NOT fabricate a typed list. Fall back to Opaque so a + // top-level array port keeps its legacy `list` (no regression) and an + // array-typed record field hits the record no_alloc hard-error path, + // rather than emitting a `list`. + if matches!(element, DataShape::Opaque) { + return None; + } + Some(DataShape::Array { + element: Box::new(element), + len: dim, + }) + } + /// Resolve a `data` classifier's declared `Data_Size`, in bytes. Mirrors the /// instance builder's private resolver so codegen can size record fields /// without instantiating them. REQ-CODEGEN-WIT-RECORDS-001. From 1b7d4bf391e961590511b88f64fe3e7501338283 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 12:28:36 +0000 Subject: [PATCH 2/3] ci: re-trigger checks after self-hosted runner disk recovery No code change. The prior run's Proptest/Fuzz/Bench-compile jobs failed only because the self-hosted runners were at 100% disk (ENOSPC / linker SIGBUS on unrelated crates); those jobs are terminal on the old commit and cannot re-run via the API here. The runner fleet has since recovered (the heavy Test + Mutation jobs are building the workspace again), so this empty commit re-triggers a clean CI run. Co-Authored-By: Claude Opus 4.8 (1M context) From e47183fccfc77e7e3d9eb28560530a2ba80d3820 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 13:14:08 +0000 Subject: [PATCH 3/3] test(codegen): cover the Array Rust type mapping (#345) Close the rust_gen coverage gap codecov flagged on the array change: add unit tests for `data_shape_to_rust_type` on `DataShape::Array` (fixed `[T; N]`, unbounded `Vec`, array-of-record `[crate::types::X; N]`, and nested `[[f32; 3]; 2]`), and for the array-element recursion in `flatten_record_rust` (a record field of array-of-record still emits the inner struct, inner-first). No behavior change. REQ-CODEGEN-WIT-ARRAY-001. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spar-codegen/src/rust_gen.rs | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/spar-codegen/src/rust_gen.rs b/crates/spar-codegen/src/rust_gen.rs index 481af3c..d0d47ac 100644 --- a/crates/spar-codegen/src/rust_gen.rs +++ b/crates/spar-codegen/src/rust_gen.rs @@ -935,4 +935,89 @@ mod tests { "Vec" ); } + + /// REQ-CODEGEN-WIT-ARRAY-001 (#345): an `Array` shape mirrors wit-bindgen's + /// lowering — a fixed `Dimension` → a Rust fixed array `[T; N]`, an unbounded + /// array → `Vec`, recursively over the element (scalar, record, or a + /// nested array). + #[test] + fn array_shape_maps_to_rust_types() { + let f32_scalar = || DataShape::Scalar { + bytes: 4, + kind: ScalarKind::Float, + }; + // Fixed 16 × f32 (the #345 NavState case) → `[f32; 16]`. + assert_eq!( + data_shape_to_rust_type(&DataShape::Array { + element: Box::new(f32_scalar()), + len: Some(16) + }), + "[f32; 16]" + ); + // Unbounded (no Dimension) → `Vec`. + assert_eq!( + data_shape_to_rust_type(&DataShape::Array { + element: Box::new(f32_scalar()), + len: None + }), + "Vec" + ); + // Array of a record → `[crate::types::Nav; 4]`. + assert_eq!( + data_shape_to_rust_type(&DataShape::Array { + element: Box::new(DataShape::Record { + type_name: "Nav".to_string(), + fields: vec![] + }), + len: Some(4) + }), + "[crate::types::Nav; 4]" + ); + // Nested array (array of arrays) recurses on both levels. + assert_eq!( + data_shape_to_rust_type(&DataShape::Array { + element: Box::new(DataShape::Array { + element: Box::new(f32_scalar()), + len: Some(3) + }), + len: Some(2) + }), + "[[f32; 3]; 2]" + ); + } + + /// REQ-CODEGEN-WIT-ARRAY-001 (#345): a record FIELD whose type is an array + /// of a nested record still emits the nested record's struct (the array + /// recursion in `flatten_record_rust`), so `crate::types::Inner` resolves. + #[test] + fn record_field_of_array_of_record_defines_the_inner_struct() { + let inner = DataField { + name: spar_hir_def::name::Name::new("hi"), + shape: DataShape::Scalar { + bytes: 2, + kind: ScalarKind::Unsigned, + }, + }; + let outer_fields = vec![DataField { + name: spar_hir_def::name::Name::new("items"), + shape: DataShape::Array { + element: Box::new(DataShape::Record { + type_name: "Inner".to_string(), + fields: vec![inner], + }), + len: Some(8), + }, + }]; + let mut structs = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + flatten_record_rust("Outer", &outer_fields, &mut structs, &mut seen); + + // Both the outer and the array-nested inner struct are emitted, inner-first. + let names: Vec<&str> = structs.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, vec!["Inner", "Outer"], "structs: {names:?}"); + // The outer field references the array-of-record type. + let outer = &structs.iter().find(|(n, _)| n == "Outer").unwrap().1; + assert_eq!(outer[0].0, "items"); + assert_eq!(outer[0].1, "[crate::types::Inner; 8]"); + } }