diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6a2a587..d15e404d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,25 @@ jobs: path: target/nextest/ci/junit.xml if-no-files-found: ignore + # ── Codegen compile oracle (the #[ignore] gates) ──────────────────── + # `cargo nextest`/`cargo test` above do NOT run #[ignore] tests, so the + # compiler-enforced codegen oracles (emitted_crate_cargo_checks, + # emitted_crate_builds_to_wasm32_wasip2) would otherwise never gate in CI. + # They shell out to cargo to build a generated crate — including to + # wasm32-wasip2 — so this job installs that target and runs the ignored set. + # REQ-CODEGEN-WIT-RECORDS-001/002 (#319). + codegen-oracle: + name: Codegen compile oracle + runs-on: [self-hosted, linux, x64, rust-cpu] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + targets: wasm32-wasip2 + - uses: Swatinem/rust-cache@v2 + - name: Run generated-crate compile oracles (ignored tests) + run: cargo test -p spar-codegen --test lifecycle_bindings -- --ignored + # ── Bench compile smoke (fast regression gate) ────────────────────── bench-smoke: name: Bench compile smoke diff --git a/Cargo.lock b/Cargo.lock index 381985c0..a5fadabe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,9 +333,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index e5dfebd6..a73d929d 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -2649,6 +2649,120 @@ artifacts: - type: traces-to target: REQ-CODEGEN-WIT + - id: REQ-CODEGEN-WIT-RECORDS-001 + type: requirement + title: "Structured WIT records + wit-bindgen-wired Rust from AADL" + description: > + Codegen shall emit no_alloc-representable structured interfaces from real + AADL components, closing the #319 gaps where a `data implementation` with + subcomponents degraded to an opaque `type x = list` (forcing + cabi_realloc) and the Rust skeleton had no wit-bindgen wiring. Delivered + and oracle-verified in this requirement: + (1) a `data implementation` with scalar subcomponents becomes a WIT + `record { field: type }`, recursively for nested data implementations, + emitted dependency-first (#319 items 1); + (2) scalar fields map to precise WIT scalars honoring signedness/float + (Data_Representation => Float -> f32/f64; Number_Representation => Signed + -> s8..s64; else u8..u64), and a field that cannot be a no_alloc scalar + (non-power-of-two size, sub-32-bit float, opaque type) is a HARD ERROR + naming the field — codegen refuses rather than emit an allocating fallback + (user-chosen policy); + (3) `wit_ident` breaks PascalCase word boundaries (EepromSnapshot -> + eeprom-snapshot, #319 item 6); + (4) each thread's WIT world exports the lifecycle its `Dispatch_Protocol` + implies — Periodic -> a single `{instance}-compute`; other protocols -> + `{instance}-initialize/compute/finalize` — replacing the old opaque single + `{thread}: func()` (#319 item 7); and + (5) a per-process binding crate root (`crates/{proc}/src/lib.rs`) wires the + world to Rust: `wit_bindgen::generate!` + `impl Guest` (methods matching the + world exports) + `export!`, and the per-thread `{Struct}Component` trait is + lifecycle-derived from the same single source (#319 item 4). All five share + `crate::Lifecycle`/`lifecycle_for`/`dispatch_protocol` so the WIT world and + the Rust Guest cannot drift — and because wit-bindgen derives the `Guest` + trait from the world, a `cargo check` of the emitted crate is a + compiler-enforced alignment oracle, not a string match. + OUT OF SCOPE (successor REQ-CODEGEN-WIT-RECORDS-002): defining the Rust + `*Impl` record structs + typed ports and module-wiring the per-thread files + so the WHOLE crate (not just the binding lib.rs) compiles, and the + wasm-component zero-`cabi_realloc` symbol check. Reported as GitHub #319. + status: implemented + tags: [codegen, wit, rust, wit-bindgen, interop] + fields: + release: v0.24.0 + links: + - type: traces-to + target: REQ-CODEGEN-WIT-TYPES + + - id: REQ-CODEGEN-WIT-RECORDS-002 + type: requirement + title: "Whole generated component crate compiles + no unbounded ABI types" + description: > + Successor to REQ-CODEGEN-WIT-RECORDS-001: make the ENTIRE generated + component crate compile, not just the WIT binding lib.rs, and give the + no_alloc guarantee a sound oracle. Three parts: + (a) resolve each classifier-typed port to a real Rust type via the SAME + `DataShape` `wit_gen` uses (scalar -> u8..u64/i8..i64/f32/f64; record -> + a generated `#[derive(Default)]` struct in a shared per-crate `types` + module, nested-first and de-duplicated; opaque -> `Vec`), so the Rust + struct and the WIT record cannot drift — today `feature_to_rust_type` names + PascalCase structs that are never defined (#319 item 3); + (b) relocate the per-thread files UNDER the crate (`crates/{proc}/src/`) and + module-wire them from the binding lib.rs, with the process `Guest` impl + delegating each export to the per-thread `{Struct}Component` (construct-on- + call) instead of `todo!()`; + (c) NO-ALLOC ORACLE at the WIT level, not the wasm symbol level. A probe + (2026-07-08) showed `cabi_realloc` is UNCONDITIONAL wit-bindgen-rt glue — + present and called in both a scalar-record component and a `list` one — + so an `nm`/symbol "zero cabi_realloc" check cannot discriminate and is + unsound. The sound, by-construction signal is that `list`/`string` are the + only unbounded canonical-ABI types (the ones forcing dynamic `cabi_realloc`); + everything bounded uses a static return area. So the oracle asserts the + generated WIT for an all-scalar/record model contains no `list<`/`string`, + with an opaque-port model as the negative control (it deliberately keeps the + P2a `list` fallback and MUST contain it — proving the check can fire). + SCOPE: the no-alloc guarantee holds only for models whose ports are all + scalar/record; an opaque top-level port is out of the guarantee by design. + Deferred to a further requirement (data plane): populating ports from the + imported WIT functions and persistent component state (Guest methods are + free fns). Optional stronger evidence, not a completion gate: the crate also + builds to `wasm32-wasip2` (toolchain present locally). + status: implemented + tags: [codegen, wit, rust, wit-bindgen, no-alloc] + fields: + release: v0.24.0 + 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" + description: > + Bug / honesty gap surfaced during REQ-CODEGEN-WIT-RECORDS-002: the + `--verify test` and `--verify build` (hence `--verify all`) outputs are + structurally inert in the generated multi-crate workspace and therefore + self-certify nothing. (1) test_gen emits `tests//_test.rs` + with `use super::::*;` at the WORKSPACE ROOT — outside every crate + member, and `super::` is meaningless for a top-level file — so the harnesses + are never compiled or run. (2) build_gen emits `build.rs` + `aadl-contract.toml` + at the workspace ROOT, but workspace_gen makes a VIRTUAL workspace (a + `[workspace]`-only root with no `[package]`), and cargo never runs a build + script for a virtual manifest — so the timing-contract verifier never fires. + Both predate REQ-002 (they assume a flat single-crate layout that + workspace_gen does not produce). Fix: relocate the test harnesses under each + process crate (`crates//tests/` or inline `#[cfg(test)]` modules using + the real crate paths, not `super::`), and move the contract build.rs + + manifest into each process crate so the build script actually runs. Until + then, `spar codegen --verify test/build/all` must not be read as producing a + runnable test/verification harness. Related to #319. + status: proposed + tags: [codegen, verify, workspace, bug] + fields: + release: v0.24.0 + links: + - type: traces-to + target: REQ-CODEGEN-WIT-RECORDS-002 + - id: REQ-PROOF-SCHED-001 type: requirement title: "Lean scheduling proofs (EDF, RMBound, RTA) wired into CI" diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index 9aae7584..9cfe3e73 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -3900,6 +3900,95 @@ artifacts: - type: satisfies target: REQ-CODEGEN-WIT-TYPES + - id: TEST-CODEGEN-WIT-RECORDS + type: feature + title: AADL data implementations become no_alloc WIT records + description: > + wit_gen.rs unit oracles for REQ-CODEGEN-WIT-RECORDS-001 records path: + data_implementation_becomes_wit_record (EepromSnapshot.Impl = u32+u32+u8 + -> `record eeprom-snapshot-impl { addr: u32, value: u32, flags: u8 }`, NOT + list), non_encodable_record_field_is_a_hard_error (3-byte + opaque + fields both reported, representable field not flagged), + record_fields_honor_signedness_and_float (temp: f32, offset: s32, + count: u16), and nested_record_emits_inner_record_and_references_it + (Header.Impl emitted dependency-first, referenced by name from Packet.Impl). + Each also feeds the output through wit-parser's Resolve::push_str, so the + records are valid bindable WIT, not just string-present. + fields: + method: automated-test + steps: + - run: cargo test -p spar-codegen --lib -- wit_gen + status: passing + tags: [codegen, wit, rust, interop, v0240] + links: + - type: satisfies + target: REQ-CODEGEN-WIT-RECORDS-001 + + - id: TEST-CODEGEN-WIT-BINDINGS + type: feature + title: WIT world <-> Rust Guest lifecycle wiring (compiler-enforced) + description: > + tests/lifecycle_bindings.rs for REQ-CODEGEN-WIT-RECORDS-001 items 4 & 7. + world_exports_dispatch_derived_lifecycle asserts the WIT world exports the + Dispatch_Protocol-derived lifecycle per subcomponent instance (Periodic + `pw` -> compute only; Sporadic `sw` -> initialize/compute/finalize), + confirming Dispatch_Protocol resolves per-instance and the old opaque + single-func export is gone. bindings_wire_generate_guest_and_export asserts + the emitted crate root has `wit_bindgen::generate!` + `impl Guest for + Component` (methods matching the world) + `export!(Component)`. + The COMPILER-ENFORCED gate emitted_crate_cargo_checks (#[ignore], run with + `-- --ignored`) writes the full generated workspace to a tempdir and runs + `cargo check`: wit-bindgen derives the Guest trait from the world, so a + name-mangling/keyword/collision mismatch in the emitter is a compile error + (verified GREEN: `Checking ctrl v0.1.0`; a deliberately renamed Guest + method fails with E0046/E0407). This closes the string-match gap that a + pure assertion oracle would leave. The `#[ignore]` gate runs in CI via the + `codegen-oracle` job (ci.yml) — `cargo nextest`/`cargo test` do NOT run + ignored tests, so without that job this oracle would not gate. + fields: + method: automated-test + steps: + - run: cargo test -p spar-codegen --test lifecycle_bindings + - run: cargo test -p spar-codegen --test lifecycle_bindings -- --ignored + status: passing + tags: [codegen, wit, rust, wit-bindgen, interop, v0240] + links: + - type: satisfies + target: REQ-CODEGEN-WIT-RECORDS-001 + + - id: TEST-CODEGEN-WIT-WHOLECRATE + type: feature + title: Whole generated crate compiles + no unbounded ABI types + description: > + tests/lifecycle_bindings.rs for REQ-CODEGEN-WIT-RECORDS-002. The NO-ALLOC + oracle is at the WIT level (a probe proved the wasm `cabi_realloc` symbol is + unconditional wit-bindgen-rt glue and cannot discriminate): + no_alloc_all_scalar_record_model_has_no_unbounded_wit_types asserts a + record + multi-process model emits no `list<`/`string`, and the negative + control no_alloc_opaque_port_model_does_contain_list_fallback asserts an + opaque top-level port DOES emit `list` (so the check can fire; opaque + ports are out of the no-alloc guarantee by design). The compiler-enforced + gate emitted_crate_cargo_checks (#[ignore]) now `cargo check`s the WHOLE + crate — the relocated per-thread modules (crates/{proc}/src/{thread}.rs), + the crate-local `types` record structs (derived from the same DataShape as + the WIT records), and the process `Guest` delegating to each per-thread + component — verified GREEN. emitted_crate_builds_to_wasm32_wasip2 (#[ignore], + stronger evidence, not a completion gate) builds the record + multi-process + crate to wasm32-wasip2 warning-clean. Both `#[ignore]` oracles run in CI via + the `codegen-oracle` job (ci.yml), which installs wasm32-wasip2 and runs + `-- --ignored` — plain `cargo nextest`/`cargo test` skip ignored tests, so + that job is what makes these compile oracles actually gate. + fields: + method: automated-test + steps: + - run: cargo test -p spar-codegen --test lifecycle_bindings + - run: cargo test -p spar-codegen --test lifecycle_bindings -- --ignored + status: passing + tags: [codegen, wit, rust, wit-bindgen, no-alloc, v0240] + links: + - type: satisfies + target: REQ-CODEGEN-WIT-RECORDS-002 + - id: TEST-NC-TFA type: feature title: FP-TFA / TFA++ total-flow bounds, golden + soundness diff --git a/crates/spar-cli/src/main.rs b/crates/spar-cli/src/main.rs index 3e32ee41..c4e852ec 100644 --- a/crates/spar-cli/src/main.rs +++ b/crates/spar-cli/src/main.rs @@ -2074,7 +2074,10 @@ fn cmd_codegen(args: &[String]) { dry_run, }; - let result = spar_codegen::generate(&inst, &config); + let result = spar_codegen::generate(&inst, &scope, &config).unwrap_or_else(|e| { + eprint!("{e}"); + process::exit(1); + }); if dry_run { eprintln!( diff --git a/crates/spar-codegen/benches/codegen_benchmarks.rs b/crates/spar-codegen/benches/codegen_benchmarks.rs index 939d7645..8a07341c 100644 --- a/crates/spar-codegen/benches/codegen_benchmarks.rs +++ b/crates/spar-codegen/benches/codegen_benchmarks.rs @@ -84,17 +84,18 @@ fn synth_aadl(n_threads: usize) -> String { } /// Build a `SystemInstance` from an AADL source string. -fn build_instance(aadl: &str) -> SystemInstance { +fn build_instance(aadl: &str) -> (GlobalScope, SystemInstance) { let db = HirDefDatabase::default(); let sf = SourceFile::new(&db, "bench.aadl".to_string(), aadl.to_string()); let tree = file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate( + let inst = SystemInstance::instantiate( &scope, &Name::new("BenchSystem"), &Name::new("BenchRoot"), &Name::new("impl"), - ) + ); + (scope, inst) } fn bench_codegen_emit(c: &mut Criterion) { @@ -105,7 +106,7 @@ fn bench_codegen_emit(c: &mut Criterion) { let n_threads = 64; let aadl = synth_aadl(n_threads); - let instance = build_instance(&aadl); + let (scope, instance) = build_instance(&aadl); let config = CodegenConfig { root_name: "bench_root".into(), output_dir: "output".into(), @@ -121,7 +122,8 @@ fn bench_codegen_emit(c: &mut Criterion) { // workspace, concatenated into in-memory strings. group.bench_function("generate_full_64", |b| { b.iter(|| { - let output = generate(black_box(&instance), black_box(&config)); + let output = generate(black_box(&instance), black_box(&scope), black_box(&config)) + .expect("bench codegen"); black_box(output); }); }); @@ -136,7 +138,12 @@ fn bench_codegen_emit(c: &mut Criterion) { }; group.bench_function("generate_rust_only_64", |b| { b.iter(|| { - let output = generate(black_box(&instance), black_box(&rust_only_config)); + let output = generate( + black_box(&instance), + black_box(&scope), + black_box(&rust_only_config), + ) + .expect("bench codegen"); black_box(output); }); }); diff --git a/crates/spar-codegen/src/build_gen.rs b/crates/spar-codegen/src/build_gen.rs index 1dde7bbc..02c72ec4 100644 --- a/crates/spar-codegen/src/build_gen.rs +++ b/crates/spar-codegen/src/build_gen.rs @@ -73,7 +73,7 @@ pub fn generate_build_verifier() -> GeneratedFile { // constants. Divergence => panic! => compilation failure. let script = r#"//! AADL build-time contract verification — generated by spar codegen. //! DO NOT EDIT. Fails the build when a generated timing constant in -//! src//.rs diverges from aadl-contract.toml. +//! crates//src/.rs diverges from aadl-contract.toml. use std::collections::BTreeMap; use std::fs; @@ -81,7 +81,7 @@ use std::path::Path; fn main() { println!("cargo:rerun-if-changed=aadl-contract.toml"); - println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=crates"); let manifest = match fs::read_to_string("aadl-contract.toml") { Ok(m) => m, @@ -110,7 +110,13 @@ fn main() { } for (thread, expected) in &contract { - let src = Path::new("src").join(format!("{thread}.rs")); + // Manifest keys are "/"; the source lives under that + // process crate at crates//src/.rs. + let (proc_dir, th_name) = thread.split_once('/').unwrap_or(("", thread.as_str())); + let src = Path::new("crates") + .join(proc_dir) + .join("src") + .join(format!("{th_name}.rs")); let code = match fs::read_to_string(&src) { Ok(c) => c, Err(_) => panic!( diff --git a/crates/spar-codegen/src/lib.rs b/crates/spar-codegen/src/lib.rs index 8b9bb9bc..f939e934 100644 --- a/crates/spar-codegen/src/lib.rs +++ b/crates/spar-codegen/src/lib.rs @@ -73,6 +73,91 @@ pub struct CodegenOutput { pub files: Vec, } +/// A hard code-generation failure: the model asks for something codegen refuses +/// to emit — chiefly a data type that cannot be encoded without heap allocation +/// (violating the no_alloc guarantee). Codegen fails rather than silently +/// emitting an allocating `list`. REQ-CODEGEN-WIT-RECORDS-001 (#319). +#[derive(Debug, Clone)] +pub struct CodegenError { + /// One message per offending field/type; all are reported, not just the first. + pub errors: Vec, +} + +impl std::fmt::Display for CodegenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "codegen refused to generate ({} error(s)):", + self.errors.len() + )?; + for e in &self.errors { + writeln!(f, " - {e}")?; + } + Ok(()) + } +} + +impl std::error::Error for CodegenError {} + +/// The lifecycle entry points a generated component exposes, derived from a +/// thread's AADL `Dispatch_Protocol`. +/// +/// Per the REQ-CODEGEN-WIT-RECORDS-001 design decision (#319): a **Periodic** +/// thread runs a single `compute` every period, so it exposes only that entry +/// point; every other protocol (Sporadic / Aperiodic / Timed / Hybrid / +/// Background) gets the full `initialize` / `compute` / `finalize` lifecycle. +/// +/// This is the single source of truth shared by `wit_gen` (which emits these as +/// `world` exports) and `rust_gen` (which emits them as the component trait and +/// the `wit-bindgen` `Guest` impl) so the two artifacts cannot drift. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifecycle { + /// Periodic dispatch: a single `compute` entry point. + ComputeOnly, + /// Non-periodic dispatch: `initialize`, `compute`, `finalize`. + Full, +} + +impl Lifecycle { + /// The lifecycle entry-point method names, in call order. These are the + /// exact identifiers emitted as WIT world exports (`{thread}-{method}`) and + /// as Rust trait / `Guest` methods (`{method}`), so both sides stay aligned. + pub fn methods(self) -> &'static [&'static str] { + match self { + Lifecycle::ComputeOnly => &["compute"], + Lifecycle::Full => &["initialize", "compute", "finalize"], + } + } +} + +/// Map an AADL `Dispatch_Protocol` value to the lifecycle a generated component +/// exposes. Comparison is case-insensitive; an unset or unrecognized protocol +/// is treated as non-periodic (the conservative full lifecycle). See +/// [`Lifecycle`]. +pub fn lifecycle_for(dispatch: &str) -> Lifecycle { + if dispatch.trim().eq_ignore_ascii_case("Periodic") { + Lifecycle::ComputeOnly + } else { + Lifecycle::Full + } +} + +/// Read a thread instance's `Dispatch_Protocol`, trying the property namespaces +/// AADL models use in practice (canonical `Thread_Properties`, plus the +/// `Timing_Properties` / `Deployment_Properties` / unqualified forms real models +/// and our benches emit). Defaults to `"Periodic"` when unset — the AS5506 +/// default dispatch protocol. +pub fn dispatch_protocol(inst: &SystemInstance, idx: ComponentInstanceIdx) -> String { + let props = inst.properties_for(idx); + props + .get("Thread_Properties", "Dispatch_Protocol") + .or_else(|| props.get("Timing_Properties", "Dispatch_Protocol")) + .or_else(|| props.get("Deployment_Properties", "Dispatch_Protocol")) + .or_else(|| props.get("", "Dispatch_Protocol")) + .unwrap_or("Periodic") + .to_string() +} + /// Extract timing properties from a component instance's property map. /// /// Returns (period_ps, deadline_ps, wcet_ps) in picoseconds, or None for each @@ -319,7 +404,15 @@ fn threads_for_processor( } /// Generate all code artifacts from an AADL instance model. -pub fn generate(inst: &SystemInstance, config: &CodegenConfig) -> CodegenOutput { +/// +/// `scope` is the global scope the instance was built from; codegen resolves +/// data-type classifiers through it (e.g. to decompose a `data implementation` +/// into a WIT record — REQ-CODEGEN-WIT-RECORDS-001). +pub fn generate( + inst: &SystemInstance, + scope: &spar_hir_def::resolver::GlobalScope, + config: &CodegenConfig, +) -> Result { let mut files = Vec::new(); // Collect processes and threads @@ -341,14 +434,25 @@ pub fn generate(inst: &SystemInstance, config: &CodegenConfig) -> CodegenOutput // Generate WIT files if config.format == OutputFormat::Wit || config.format == OutputFormat::Both { for &(idx, _comp) in &processes { - files.push(wit_gen::generate_wit(inst, idx)); + files.push(wit_gen::generate_wit(inst, scope, idx)?); } } // Generate Rust files if config.format == OutputFormat::Rust || config.format == OutputFormat::Both { for &(idx, _comp) in &threads { - files.push(rust_gen::generate_rust_component(inst, idx)); + files.push(rust_gen::generate_rust_component(inst, scope, idx)); + } + // Per process: the crate-local record `types` module (REQ-CODEGEN-WIT- + // RECORDS-002, #319 item 3) and the WIT-binding crate root + // (`crates/{proc}/src/lib.rs`) — the `wit_bindgen::generate!` + `impl + // Guest` wiring that connects the WIT world to Rust and mod-wires the + // per-thread component modules (REQ-CODEGEN-WIT-RECORDS-001/002, #319 + // items 3, 4 & 7). This replaces the placeholder lib.rs workspace_gen + // used to emit. + for &(idx, _comp) in &processes { + files.push(rust_gen::generate_types_module(inst, scope, idx)); + files.push(rust_gen::generate_process_bindings(inst, idx)); } } @@ -427,7 +531,7 @@ pub fn generate(inst: &SystemInstance, config: &CodegenConfig) -> CodegenOutput )); } - CodegenOutput { files } + Ok(CodegenOutput { files }) } #[cfg(test)] diff --git a/crates/spar-codegen/src/rust_gen.rs b/crates/spar-codegen/src/rust_gen.rs index 42c2735e..6036757d 100644 --- a/crates/spar-codegen/src/rust_gen.rs +++ b/crates/spar-codegen/src/rust_gen.rs @@ -4,18 +4,74 @@ //! the appropriate port struct, dispatch loop, and WASI bindings. use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance}; -use spar_hir_def::item_tree::{Direction, FeatureKind}; +use spar_hir_def::item_tree::{ComponentCategory, Direction, FeatureKind}; +use spar_hir_def::name::ClassifierRef; +use spar_hir_def::resolver::{DataField, DataShape, GlobalScope, ScalarKind}; use crate::{GeneratedFile, extract_timing, format_time_ps, sanitize_ident, to_pascal_case}; +/// The Rust scalar for a power-of-two `Data_Size` (bytes) + numeric +/// representation, mirroring `wit_gen::scalar_wit` so the Rust port type and the +/// WIT scalar stay in lock-step (REQ-CODEGEN-WIT-RECORDS-002, #319 item 3). +/// `None` for a size WIT/Rust cannot represent no_alloc (odd size, sub-32-bit +/// float) — the caller falls back to `Vec`. +fn rust_scalar(bytes: u64, kind: ScalarKind) -> Option<&'static str> { + match kind { + ScalarKind::Float => match bytes { + 4 => Some("f32"), + 8 => Some("f64"), + _ => None, + }, + ScalarKind::Signed => match bytes { + 1 => Some("i8"), + 2 => Some("i16"), + 4 => Some("i32"), + 8 => Some("i64"), + _ => None, + }, + ScalarKind::Unsigned => match bytes { + 1 => Some("u8"), + 2 => Some("u16"), + 4 => Some("u32"), + 8 => Some("u64"), + _ => None, + }, + } +} + +/// The Rust type a resolved [`DataShape`] maps to for a port field: a scalar maps +/// to a Rust primitive, a record to a struct in the crate-local `types` module +/// (generated by [`generate_types_module`]), and anything unrepresentable to +/// `Vec`. Record structs are named `to_pascal_case(type_name)` — the same +/// key `flatten_record_rust` emits — so references resolve. +fn data_shape_to_rust_type(shape: &DataShape) -> String { + match shape { + DataShape::Scalar { bytes, kind } => { + rust_scalar(*bytes, *kind).map_or_else(|| "Vec".to_string(), |s| s.to_string()) + } + DataShape::Record { type_name, .. } => { + format!("crate::types::{}", to_pascal_case(type_name)) + } + DataShape::Opaque => "Vec".to_string(), + } +} + /// Generate a Rust component skeleton for a thread instance. +/// +/// `scope` resolves each port's data classifier to a concrete Rust type (scalar, +/// a `crate::types` record struct, or `Vec`) so the port struct actually +/// compiles — REQ-CODEGEN-WIT-RECORDS-002 (#319 item 3). The file is emitted +/// UNDER the process crate (`crates/{proc}/src/{thread}.rs`) so the binding +/// lib.rs can `mod`-wire it. pub fn generate_rust_component( inst: &SystemInstance, + scope: &GlobalScope, thread_idx: ComponentInstanceIdx, ) -> GeneratedFile { let comp = inst.component(thread_idx); let name = sanitize_ident(comp.name.as_str()); let struct_name = to_pascal_case(comp.name.as_str()); + let pkg = &comp.package; let (period, deadline, wcet) = extract_timing(inst, thread_idx); @@ -62,7 +118,7 @@ pub fn generate_rust_component( for &fi in &comp.features { let feat = &inst.features[fi]; let feat_name = sanitize_ident(feat.name.as_str()); - let rust_type = feature_to_rust_type(feat.kind, &feat.classifier); + let rust_type = feature_to_rust_type(feat.kind, &feat.classifier, scope, pkg); let dir_comment = match feat.direction { Some(Direction::In) => "in", @@ -80,23 +136,26 @@ pub fn generate_rust_component( code.push_str("}\n\n"); - // Component trait + // Component trait. The lifecycle methods are derived from the thread's + // `Dispatch_Protocol` (REQ-CODEGEN-WIT-RECORDS-001, #319 item 7): a Periodic + // thread exposes only `compute`; every other protocol gets the full + // `initialize` / `compute` / `finalize`. This matches the WIT world exports + // and the generated `Guest` impl — all three derive from `crate::Lifecycle`. + let methods = crate::lifecycle_for(dispatch).methods(); + code.push_str(&format!( "/// Dispatch trait for the {name} thread ({dispatch}).\n" )); code.push_str(&format!("pub trait {struct_name}Component {{\n")); - code.push_str(" /// Called once at initialization.\n"); - code.push_str(&format!( - " fn initialize(&mut self, ports: &mut {struct_name}Ports);\n\n" - )); - code.push_str(&format!(" /// Called on each dispatch ({dispatch}).\n")); - code.push_str(&format!( - " fn compute(&mut self, ports: &mut {struct_name}Ports);\n\n" - )); - code.push_str(" /// Called on finalization.\n"); - code.push_str(&format!( - " fn finalize(&mut self, ports: &mut {struct_name}Ports);\n" - )); + for (i, method) in methods.iter().enumerate() { + code.push_str(&format!(" /// {}\n", lifecycle_doc(method, dispatch))); + code.push_str(&format!( + " fn {method}(&mut self, ports: &mut {struct_name}Ports);\n" + )); + if i + 1 < methods.len() { + code.push('\n'); + } + } code.push_str("}\n\n"); // Skeleton implementation @@ -105,43 +164,156 @@ pub fn generate_rust_component( code.push_str(&format!( "impl {struct_name}Component for {struct_name}Default {{\n" )); - code.push_str(&format!( - " fn initialize(&mut self, _ports: &mut {struct_name}Ports) {{\n" - )); - code.push_str(" // TODO: initialization logic\n"); - code.push_str(" }\n\n"); - code.push_str(&format!( - " fn compute(&mut self, _ports: &mut {struct_name}Ports) {{\n" - )); - code.push_str(" // TODO: periodic compute logic\n"); - code.push_str(" }\n\n"); - code.push_str(&format!( - " fn finalize(&mut self, _ports: &mut {struct_name}Ports) {{\n" - )); - code.push_str(" // TODO: finalization logic\n"); - code.push_str(" }\n"); + for (i, method) in methods.iter().enumerate() { + code.push_str(&format!( + " fn {method}(&mut self, _ports: &mut {struct_name}Ports) {{\n" + )); + code.push_str(&format!(" // TODO: {method} logic\n")); + code.push_str(" }\n"); + if i + 1 < methods.len() { + code.push('\n'); + } + } code.push_str("}\n"); - // Determine process parent name for path + // Determine process parent name; the thread module lives under that crate. let parent_name = comp .parent .map(|p| sanitize_ident(inst.component(p).name.as_str())) .unwrap_or_else(|| "unknown".to_string()); GeneratedFile { - path: format!("src/{parent_name}/{name}.rs"), + path: format!("crates/{parent_name}/src/{name}.rs"), content: code, } } -/// Convert a feature kind + optional classifier to a Rust type. +/// Doc-comment text for a lifecycle trait method. +fn lifecycle_doc(method: &str, dispatch: &str) -> String { + match method { + "initialize" => "Called once at initialization.".to_string(), + "compute" => format!("Called on each dispatch ({dispatch})."), + "finalize" => "Called on finalization.".to_string(), + other => format!("Lifecycle entry point: {other}."), + } +} + +/// Generate the per-process WIT-binding crate root (`crates/{proc}/src/lib.rs`) +/// that wires the AADL process's WIT `world` to Rust via `wit-bindgen` +/// (REQ-CODEGEN-WIT-RECORDS-001, #319 items 4 & 7). +/// +/// Emits the `wit_bindgen::generate!` for the process world, a `Component` +/// struct, and an `impl Guest` whose methods are the lifecycle entry points every +/// child thread's `Dispatch_Protocol` implies — the SAME set [`crate::wit_gen`] +/// exports into the world. Because the `Guest` trait `generate!` produces is +/// derived from that world, the Rust compiler (when the crate is built) *enforces* +/// world⟷Guest alignment: a missing or misnamed method is a compile error, not a +/// silent drift (the item-7 failure mode). +/// +/// Method bodies are `todo!()` stubs. This unit proves the *interface* wiring +/// compiles and matches the model; the behavior is supplied by the per-thread +/// `{Struct}Component` implementations ([`generate_rust_component`]). +pub fn generate_process_bindings( + inst: &SystemInstance, + proc_idx: ComponentInstanceIdx, +) -> GeneratedFile { + let comp = inst.component(proc_idx); + // Crate directory matches workspace_gen's `crates/{sanitize_ident(name)}`. + let crate_dir = sanitize_ident(comp.name.as_str()); + // World name + wit file name match wit_gen exactly (kebab-case). + let world_name = crate::wit_gen::wit_ident(comp.name.as_str()); + + let child_threads: Vec<_> = comp + .children + .iter() + .filter(|&&child_idx| { + inst.component(child_idx).category == spar_hir_def::item_tree::ComponentCategory::Thread + }) + .collect(); + + let mut code = String::new(); + code.push_str(&format!( + "//! Generated WIT bindings for AADL process: {}::{}\n", + comp.package, comp.name + )); + code.push_str("//! DO NOT EDIT — regenerate with `spar codegen`.\n\n"); + + // The `wit_bindgen::generate!` + `export!` glue calls its own unsafe `*_cabi` + // shims; on the edition-2024 generated crate that trips the + // `unsafe_op_in_unsafe_fn` future-compat lint inside macro-expanded code the + // user cannot edit. Allow it crate-wide so the generated crate is warning-clean. + code.push_str("#![allow(unsafe_op_in_unsafe_fn)]\n\n"); + + // The crate-local record types and the per-thread component modules, wired in. + code.push_str("mod types;\n"); + for &&child_idx in &child_threads { + let child_mod = sanitize_ident(inst.component(child_idx).name.as_str()); + code.push_str(&format!("mod {child_mod};\n")); + } + code.push('\n'); + + // `generate!` loads the single world file emitted by wit_gen. The path is + // relative to this crate root (`crates/{proc}/`) reaching the top-level + // `wit/` directory. An unqualified world name resolves because the file + // declares exactly one package. + code.push_str("wit_bindgen::generate!({\n"); + code.push_str(&format!(" world: \"{world_name}-world\",\n")); + code.push_str(&format!(" path: \"../../wit/{world_name}.wit\",\n")); + code.push_str("});\n\n"); + + code.push_str("/// Component entry point, exported to the WIT world.\n"); + code.push_str("struct Component;\n\n"); + code.push_str("impl Guest for Component {\n"); + for &&child_idx in &child_threads { + let child = inst.component(child_idx); + let thread_kebab = crate::wit_gen::wit_ident(child.name.as_str()); + let child_mod = sanitize_ident(child.name.as_str()); + let child_struct = to_pascal_case(child.name.as_str()); + let dispatch = crate::dispatch_protocol(inst, child_idx); + for method in crate::lifecycle_for(&dispatch).methods() { + // wit-bindgen mangles the kebab WIT export `{thread}-{method}` to the + // snake Rust method `{thread}_{method}`. Deriving both names from the + // same `wit_ident` keeps them consistent; the compiler is the final + // arbiter (see doc comment). The body delegates to the per-thread + // component (construct-on-call); populating ports from the imported + // WIT functions + persistent state is the deferred data plane. + let rust_method = format!("{thread_kebab}-{method}").replace('-', "_"); + code.push_str(&format!(" fn {rust_method}() {{\n")); + code.push_str(&format!( + " use crate::{child_mod}::{child_struct}Component;\n" + )); + code.push_str(&format!( + " let mut component = crate::{child_mod}::{child_struct}Default;\n" + )); + code.push_str(&format!( + " let mut ports = crate::{child_mod}::{child_struct}Ports::default();\n" + )); + code.push_str(&format!(" component.{method}(&mut ports);\n")); + code.push_str(" }\n"); + } + } + code.push_str("}\n\n"); + code.push_str("export!(Component);\n"); + + GeneratedFile { + path: format!("crates/{crate_dir}/src/lib.rs"), + content: code, + } +} + +/// Convert a feature kind + optional classifier to a Rust type. The classifier +/// is resolved through `scope` to its [`DataShape`] so the port type is a real, +/// defined type — a scalar, a `crate::types` record struct, or `Vec` — rather +/// than the undefined PascalCase name the old mapping produced (#319 item 3). fn feature_to_rust_type( kind: FeatureKind, - classifier: &Option, + classifier: &Option, + scope: &GlobalScope, + package: &spar_hir_def::name::Name, ) -> String { let base_type = classifier .as_ref() - .map(|c| to_pascal_case(&c.to_string())) + .map(|c| data_shape_to_rust_type(&scope.resolve_data_shape(package, c))) .unwrap_or_else(|| "Vec".to_string()); match kind { @@ -152,6 +324,94 @@ fn feature_to_rust_type( } } +/// Generate the crate-local `types` module (`crates/{proc}/src/types.rs`) holding +/// the Rust record structs the per-thread port structs reference. Each struct is +/// derived from the SAME [`DataShape`] `wit_gen` emits as a WIT record, so the +/// Rust struct and the WIT record cannot drift (REQ-CODEGEN-WIT-RECORDS-002, +/// #319 item 3). Structs are emitted nested-first and de-duplicated; the module +/// is (validly) empty when the process has no record-typed ports. +pub fn generate_types_module( + inst: &SystemInstance, + scope: &GlobalScope, + proc_idx: ComponentInstanceIdx, +) -> GeneratedFile { + let comp = inst.component(proc_idx); + let crate_dir = sanitize_ident(comp.name.as_str()); + + // Collect record shapes from every child thread's classifier-typed ports. + let mut structs: Vec<(String, Vec<(String, String)>)> = Vec::new(); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for &child_idx in &comp.children { + let child = inst.component(child_idx); + if child.category != ComponentCategory::Thread { + continue; + } + for &fi in &child.features { + let feat = &inst.features[fi]; + if let Some(cref) = feat.classifier.as_ref() + && let DataShape::Record { type_name, fields } = + scope.resolve_data_shape(&child.package, cref) + { + flatten_record_rust(&type_name, &fields, &mut structs, &mut seen); + } + } + } + + let mut code = String::new(); + code.push_str(&format!( + "//! Generated record types for AADL process: {}::{}\n", + comp.package, comp.name + )); + code.push_str("//! DO NOT EDIT — regenerate with `spar codegen`.\n\n"); + if structs.is_empty() { + code.push_str("// (no record-typed ports in this process)\n"); + } + for (sname, fields) in &structs { + code.push_str("#[derive(Clone, Debug, Default, PartialEq)]\n"); + code.push_str(&format!("pub struct {sname} {{\n")); + for (fname, fty) in fields { + code.push_str(&format!(" pub {fname}: {fty},\n")); + } + code.push_str("}\n\n"); + } + + GeneratedFile { + path: format!("crates/{crate_dir}/src/types.rs"), + content: code, + } +} + +/// Recursively collect Rust struct definitions for a record shape, nested-first +/// and de-duplicated (the Rust analog of `wit_gen::flatten_record`). A nested +/// record field becomes `crate::types::{Name}` via [`data_shape_to_rust_type`]. +fn flatten_record_rust( + type_name: &str, + fields: &[DataField], + out: &mut Vec<(String, Vec<(String, String)>)>, + seen: &mut std::collections::BTreeSet, +) { + let sname = to_pascal_case(type_name); + if !seen.insert(sname.clone()) { + return; // already collected (shared nested type) + } + let mut rows = Vec::with_capacity(fields.len()); + for f in fields { + // Recurse into nested records first so they are defined before use. + if let DataShape::Record { + type_name: nt, + fields: nf, + } = &f.shape + { + flatten_record_rust(nt, nf, out, seen); + } + rows.push(( + sanitize_ident(f.name.as_str()), + data_shape_to_rust_type(&f.shape), + )); + } + out.push((sname, rows)); +} + #[cfg(test)] mod tests { use super::*; @@ -165,14 +425,61 @@ mod tests { #[test] fn feature_rust_type_mapping() { - assert_eq!(feature_to_rust_type(FeatureKind::EventPort, &None), "bool"); + // No classifier → scope is not consulted; an empty scope is fine. + let scope = GlobalScope::from_trees(vec![]); + let pkg = spar_hir_def::name::Name::new("Pkg"); assert_eq!( - feature_to_rust_type(FeatureKind::DataPort, &None), + feature_to_rust_type(FeatureKind::EventPort, &None, &scope, &pkg), + "bool" + ); + assert_eq!( + feature_to_rust_type(FeatureKind::DataPort, &None, &scope, &pkg), "Vec" ); assert_eq!( - feature_to_rust_type(FeatureKind::EventDataPort, &None), + feature_to_rust_type(FeatureKind::EventDataPort, &None, &scope, &pkg), "Option>" ); } + + #[test] + fn data_shape_maps_to_rust_types() { + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 4, + kind: ScalarKind::Unsigned + }), + "u32" + ); + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 4, + kind: ScalarKind::Signed + }), + "i32" + ); + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 8, + kind: ScalarKind::Float + }), + "f64" + ); + assert_eq!( + data_shape_to_rust_type(&DataShape::Record { + type_name: "Packet.Impl".to_string(), + fields: vec![] + }), + "crate::types::PacketImpl" + ); + assert_eq!(data_shape_to_rust_type(&DataShape::Opaque), "Vec"); + // Non-power-of-two scalar has no no_alloc Rust primitive → Vec. + assert_eq!( + data_shape_to_rust_type(&DataShape::Scalar { + bytes: 3, + kind: ScalarKind::Unsigned + }), + "Vec" + ); + } } diff --git a/crates/spar-codegen/src/test_gen.rs b/crates/spar-codegen/src/test_gen.rs index 82d0ac9e..24de9c12 100644 --- a/crates/spar-codegen/src/test_gen.rs +++ b/crates/spar-codegen/src/test_gen.rs @@ -29,37 +29,54 @@ pub fn generate_test_harness( code.push_str(&format!("use super::{name}::*;\n\n")); - // Test: component initializes without panic - code.push_str("#[test]\n"); - code.push_str(&format!("fn {name}_initializes() {{\n")); - code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); - code.push_str(&format!( - " let mut ports = {struct_name}Ports::default();\n" - )); - code.push_str(" comp.initialize(&mut ports);\n"); - code.push_str("}\n\n"); + // The harness may only call lifecycle methods the thread's `Dispatch_Protocol` + // actually exposes on the trait — a Periodic thread has `compute` only, so + // emitting `initialize`/`finalize` calls would reference methods that + // rust_gen no longer generates (REQ-CODEGEN-WIT-RECORDS-001 #319 item 7). + let dispatch = crate::dispatch_protocol(inst, thread_idx); + let methods = crate::lifecycle_for(&dispatch).methods(); + let has_initialize = methods.contains(&"initialize"); + let has_finalize = methods.contains(&"finalize"); - // Test: compute dispatch executes + // Test: component initializes without panic (only if it has an initialize). + if has_initialize { + code.push_str("#[test]\n"); + code.push_str(&format!("fn {name}_initializes() {{\n")); + code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); + code.push_str(&format!( + " let mut ports = {struct_name}Ports::default();\n" + )); + code.push_str(" comp.initialize(&mut ports);\n"); + code.push_str("}\n\n"); + } + + // Test: compute dispatch executes (all threads have `compute`). code.push_str("#[test]\n"); code.push_str(&format!("fn {name}_compute_dispatches() {{\n")); code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); code.push_str(&format!( " let mut ports = {struct_name}Ports::default();\n" )); - code.push_str(" comp.initialize(&mut ports);\n"); + if has_initialize { + code.push_str(" comp.initialize(&mut ports);\n"); + } code.push_str(" comp.compute(&mut ports);\n"); code.push_str("}\n\n"); - // Test: finalize executes - code.push_str("#[test]\n"); - code.push_str(&format!("fn {name}_finalizes() {{\n")); - code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); - code.push_str(&format!( - " let mut ports = {struct_name}Ports::default();\n" - )); - code.push_str(" comp.initialize(&mut ports);\n"); - code.push_str(" comp.finalize(&mut ports);\n"); - code.push_str("}\n\n"); + // Test: finalize executes (only if it has a finalize). + if has_finalize { + code.push_str("#[test]\n"); + code.push_str(&format!("fn {name}_finalizes() {{\n")); + code.push_str(&format!(" let mut comp = {struct_name}Default;\n")); + code.push_str(&format!( + " let mut ports = {struct_name}Ports::default();\n" + )); + if has_initialize { + code.push_str(" comp.initialize(&mut ports);\n"); + } + code.push_str(" comp.finalize(&mut ports);\n"); + code.push_str("}\n\n"); + } // Test: timing constants are consistent if period.is_some() || deadline.is_some() || wcet.is_some() { @@ -120,6 +137,8 @@ mod tests { package TestPkg public thread CtrlThread + properties + Thread_Properties::Dispatch_Protocol => Sporadic; end CtrlThread; thread implementation CtrlThread.Impl @@ -164,11 +183,81 @@ end TestPkg; .map(|(idx, _)| idx); if let Some(idx) = thread_idx { + // CtrlThread is Sporadic → full initialize/compute/finalize lifecycle. let file = generate_test_harness(&inst, idx); assert!(file.path.contains("_test.rs")); assert!(file.content.contains("#[test]")); assert!(file.content.contains("_initializes")); assert!(file.content.contains("_compute_dispatches")); + assert!(file.content.contains("_finalizes")); } } + + /// A Periodic thread exposes only `compute`, so its harness must NOT call + /// (or define tests for) initialize/finalize — those methods no longer exist + /// on the trait (REQ-CODEGEN-WIT-RECORDS-001 #319 item 7). Guards against the + /// test_gen/rust_gen desync the lifecycle trimming could introduce. + #[test] + fn periodic_harness_omits_initialize_and_finalize() { + let aadl = r#" +package PerPkg +public + thread PWorker + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end PWorker; + + thread implementation PWorker.Impl + end PWorker.Impl; + + process Proc + end Proc; + + process implementation Proc.Impl + subcomponents + w: thread PWorker.Impl; + end Proc.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + p: process Proc.Impl; + end Top.Impl; +end PerPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "per.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("PerPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Thread) + .map(|(idx, _)| idx) + .expect("model has a thread"); + let file = generate_test_harness(&inst, idx); + + assert!( + file.content.contains("_compute_dispatches"), + "periodic harness must still test compute:\n{}", + file.content + ); + assert!( + !file.content.contains("comp.initialize"), + "periodic harness must not call initialize:\n{}", + file.content + ); + assert!( + !file.content.contains("comp.finalize"), + "periodic harness must not call finalize:\n{}", + file.content + ); + } } diff --git a/crates/spar-codegen/src/wit_gen.rs b/crates/spar-codegen/src/wit_gen.rs index 8b0f7d63..c20d2d23 100644 --- a/crates/spar-codegen/src/wit_gen.rs +++ b/crates/spar-codegen/src/wit_gen.rs @@ -8,6 +8,8 @@ use std::collections::BTreeMap; use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance}; use spar_hir_def::item_tree::{ComponentCategory, Direction, FeatureKind}; +use spar_hir_def::name::ClassifierRef; +use spar_hir_def::resolver::{DataField, DataShape, GlobalScope, ScalarKind}; use crate::GeneratedFile; @@ -15,13 +17,97 @@ use crate::GeneratedFile; /// A byte buffer is always valid and bindable; `bytes` is NOT a WIT primitive. const DEFAULT_WIT_TYPE: &str = "list"; +/// The WIT scalar for a power-of-two `Data_Size` in bytes and its numeric +/// representation, or `None` if not directly representable — which would force a +/// heap-allocating `list` at the ABI boundary (a no_alloc violation). WIT +/// has no f8/f16, so sub-32-bit floats are unrepresentable. +/// REQ-CODEGEN-WIT-RECORDS-001 (#319). +fn scalar_wit(bytes: u64, kind: ScalarKind) -> Option<&'static str> { + match kind { + ScalarKind::Float => match bytes { + 4 => Some("f32"), + 8 => Some("f64"), + _ => None, + }, + ScalarKind::Signed => match bytes { + 1 => Some("s8"), + 2 => Some("s16"), + 4 => Some("s32"), + 8 => Some("s64"), + _ => None, + }, + ScalarKind::Unsigned => match bytes { + 1 => Some("u8"), + 2 => Some("u16"), + 4 => Some("u32"), + 8 => Some("u64"), + _ => None, + }, + } +} + +/// One emitted WIT record definition: `(wit-type-name, [(field, wit-type)])`. +type RecordDef = (String, Vec<(String, 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. +/// A nested-record field becomes a reference to that record's type; a genuinely +/// non-encodable field (non-power-of-two `Data_Size`, sub-32-bit float, or an +/// opaque type) is collected into `errors` — the caller refuses to generate +/// (the user-chosen no_alloc hard-error policy). REQ-CODEGEN-WIT-RECORDS-001 (#319). +fn flatten_record( + type_name: &str, + fields: &[DataField], + out: &mut Vec, + seen: &mut std::collections::BTreeSet, + errors: &mut Vec, +) -> String { + let wit_name = wit_ident(type_name); + if !seen.insert(wit_name.clone()) { + return wit_name; // already flattened (shared nested type) + } + let mut rows = Vec::with_capacity(fields.len()); + for f in fields { + let field = f.name.as_str(); + match &f.shape { + DataShape::Scalar { bytes, kind } => match scalar_wit(*bytes, *kind) { + Some(w) => rows.push((wit_ident(field), w.to_string())), + None => errors.push(format!( + "record `{type_name}` field `{field}`: {bytes}-byte {kind:?} has no \ + no_alloc WIT scalar (int 1/2/4/8, float 4/8 only)" + )), + }, + DataShape::Record { + type_name: nested_tn, + fields: nested_fields, + } => { + let nested = flatten_record(nested_tn, nested_fields, out, seen, errors); + rows.push((wit_ident(field), nested)); + } + 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" + )), + } + } + // Pushed AFTER its nested records (post-order), so definitions are emitted + // dependency-first. + out.push((wit_name.clone(), rows)); + wit_name +} + /// Generate a WIT file for a process instance. /// /// Identifiers are emitted in WIT kebab-case (see [`wit_ident`]) — NOT the /// Rust `snake_case` used elsewhere in codegen — and every named data type a /// port references is emitted as a `type` alias so the interface resolves /// under `wasm-tools` / `wit-bindgen` (see issue #254). -pub fn generate_wit(inst: &SystemInstance, proc_idx: ComponentInstanceIdx) -> GeneratedFile { +pub fn generate_wit( + inst: &SystemInstance, + scope: &GlobalScope, + proc_idx: ComponentInstanceIdx, +) -> Result { let comp = inst.component(proc_idx); let name = wit_ident(comp.name.as_str()); let pkg_name = wit_ident(comp.package.as_str()); @@ -40,47 +126,68 @@ pub fn generate_wit(inst: &SystemInstance, proc_idx: ComponentInstanceIdx) -> Ge .filter(|&&child_idx| inst.component(child_idx).category == ComponentCategory::Thread) .collect(); - // First pass: collect every named data type referenced by a port so we can - // emit `type` definitions. WIT rejects references to undefined types, so an - // interface that names `mattermessage` without defining it will not bind. - // The instance model carries each feature's resolved Data_Size (bytes), so - // scalar-sized data types map to precise WIT scalars instead of a byte - // buffer (REQ-CODEGEN-WIT-TYPES): 1 -> u8, 2 -> u16, 4 -> u32, 8 -> u64; - // anything else (or undeclared) stays list. If two features reference - // the same type name with conflicting sizes, fall back to list rather - // than guess. - let mut referenced_types: BTreeMap> = BTreeMap::new(); + // First pass: collect every named data type a port references, keyed by its + // WIT identifier, remembering the classifier so we can resolve its shape. + // WIT rejects references to undefined types, so every referenced type must + // be emitted (as a scalar alias, a record, or a byte-buffer fallback). + let mut referenced: BTreeMap = BTreeMap::new(); for &fi in &comp.features { let feat = &inst.features[fi]; - if let Some(c) = feat.classifier.as_ref() { - let ty = wit_ident(&c.to_string()); - match referenced_types.entry(ty) { - std::collections::btree_map::Entry::Vacant(e) => { - e.insert(feat.data_size_bytes); - } - std::collections::btree_map::Entry::Occupied(mut e) => { - if *e.get() != feat.data_size_bytes { - e.insert(None); // conflicting sizes: stay list - } - } - } + if let Some(cref) = feat.classifier.as_ref() { + referenced + .entry(wit_ident(&cref.to_string())) + .or_insert_with(|| cref.clone()); } } - // Generate an interface for the process's own ports + // Generate an interface for the process's own ports. wit.push_str(&format!("interface {name}-ports {{\n")); - for (ty, size) in &referenced_types { - let wit_ty = match size { - Some(1) => "u8", - Some(2) => "u16", - Some(4) => "u32", - Some(8) => "u64", - _ => DEFAULT_WIT_TYPE, - }; - wit.push_str(&format!(" type {ty} = {wit_ty};\n")); + // Resolve each referenced data type to its shape and emit it. A + // `data implementation` with scalar subcomponents becomes a WIT `record` + // (REQ-CODEGEN-WIT-RECORDS-001, #319) instead of an opaque `list` blob + // that would force `cabi_realloc` at the ABI boundary. Scalar-sized data + // types map to precise WIT integers (1 -> u8 ... 8 -> u64). + let mut record_defs: Vec = Vec::new(); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut aliases: Vec<(String, String)> = Vec::new(); + let mut errors: Vec = Vec::new(); + for (ty, cref) in &referenced { + match scope.resolve_data_shape(&comp.package, cref) { + // A record (and its nested records, transitively) — refuse on any + // non-encodable field. + DataShape::Record { type_name, fields } => { + flatten_record( + &type_name, + &fields, + &mut record_defs, + &mut seen, + &mut errors, + ); + } + DataShape::Scalar { bytes, kind } => { + let wit_ty = scalar_wit(bytes, kind).unwrap_or(DEFAULT_WIT_TYPE); + aliases.push((ty.clone(), wit_ty.to_string())); + } + DataShape::Opaque => aliases.push((ty.clone(), DEFAULT_WIT_TYPE.to_string())), + } + } + if !errors.is_empty() { + return Err(crate::CodegenError { errors }); } - if !referenced_types.is_empty() { + // Emit record definitions dependency-first (nested before outer), then the + // scalar / byte-buffer aliases. + for (rec_name, rows) in &record_defs { + wit.push_str(&format!(" record {rec_name} {{\n")); + for (fname, fty) in rows { + wit.push_str(&format!(" {fname}: {fty},\n")); + } + wit.push_str(" }\n"); + } + for (alias, wit_ty) in &aliases { + wit.push_str(&format!(" type {alias} = {wit_ty};\n")); + } + if !referenced.is_empty() { wit.push('\n'); } @@ -139,22 +246,31 @@ pub fn generate_wit(inst: &SystemInstance, proc_idx: ComponentInstanceIdx) -> Ge wit.push_str("}\n\n"); - // Generate world + // Generate world. Each child thread exports the lifecycle entry points its + // `Dispatch_Protocol` implies (REQ-CODEGEN-WIT-RECORDS-001, #319 item 7): a + // Periodic thread exports a single `{thread}-compute`; every other protocol + // exports `{thread}-initialize` / `{thread}-compute` / `{thread}-finalize`. + // This replaces the old opaque single `{thread}: func()` export so the world + // and the generated Rust `Guest` impl expose the *same* lifecycle — the + // shared `crate::Lifecycle` helper is the single source both derive from. wit.push_str(&format!("world {name}-world {{\n")); wit.push_str(&format!(" import {name}-ports;\n")); for &&child_idx in &child_threads { let child = inst.component(child_idx); let child_name = wit_ident(child.name.as_str()); - wit.push_str(&format!(" export {child_name}: func();\n")); + let dispatch = crate::dispatch_protocol(inst, child_idx); + for method in crate::lifecycle_for(&dispatch).methods() { + wit.push_str(&format!(" export {child_name}-{method}: func();\n")); + } } wit.push_str("}\n"); - GeneratedFile { + Ok(GeneratedFile { path: format!("wit/{name}.wit"), content: wit, - } + }) } /// WIT reserved words that must be `%`-escaped when used as identifiers. @@ -207,13 +323,28 @@ const WIT_KEYWORDS: &[&str] = &[ /// [`crate::sanitize_ident`]. Underscores and other separators become hyphens, /// words that would start with a digit are letter-prefixed, and collisions with /// WIT keywords are `%`-escaped. See issue #254. -fn wit_ident(name: &str) -> String { +/// +/// `pub(crate)` so `rust_gen` can derive world-export / `Guest`-method names that +/// exactly match the WIT this module emits (REQ-CODEGEN-WIT-RECORDS-001 #319 item 7). +pub(crate) fn wit_ident(name: &str) -> String { // Split into lowercase alphanumeric words on any run of separators // (`_`, `.`, `-`, whitespace, etc.). let mut words: Vec = Vec::new(); let mut cur = String::new(); for ch in name.chars() { if ch.is_ascii_alphanumeric() { + // Break camelCase / PascalCase boundaries: an uppercase letter that + // follows a lowercase letter or digit starts a new word, so + // `EepromSnapshot` -> `eeprom-snapshot` (issue #319 item 6) instead + // of the collapsed `eepromsnapshot`. + if ch.is_ascii_uppercase() + && cur + .chars() + .last() + .is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit()) + { + words.push(std::mem::take(&mut cur)); + } cur.push(ch.to_ascii_lowercase()); } else if !cur.is_empty() { words.push(std::mem::take(&mut cur)); @@ -250,7 +381,7 @@ mod tests { use spar_hir_def::name::Name; use spar_hir_def::resolver::GlobalScope; - fn build_test_instance() -> SystemInstance { + fn build_test_instance() -> (GlobalScope, SystemInstance) { let aadl = r#" package TestPkg public @@ -302,17 +433,18 @@ end TestPkg; let sf = spar_base_db::SourceFile::new(&db, "test.aadl".to_string(), aadl.to_string()); let tree = spar_hir_def::file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate( + let inst = SystemInstance::instantiate( &scope, &Name::new("TestPkg"), &Name::new("Top"), &Name::new("Impl"), - ) + ); + (scope, inst) } #[test] fn wit_gen_produces_output() { - let inst = build_test_instance(); + let (scope, inst) = build_test_instance(); // Find the process instance let proc_idx = inst .all_components() @@ -320,7 +452,7 @@ end TestPkg; .map(|(idx, _)| idx); if let Some(idx) = proc_idx { - let file = generate_wit(&inst, idx); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); assert!(file.path.ends_with(".wit")); assert!(file.content.contains("package")); assert!(file.content.contains("world")); @@ -333,13 +465,13 @@ end TestPkg; /// files that only fail later in `wasm-tools` / `wit-bindgen`. #[test] fn generated_wit_is_valid_and_bindable() { - let inst = build_test_instance(); + let (scope, inst) = build_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, idx); + let file = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); // No stray underscores in the emitted WIT (kebab-case only). The header // comment echoes the AADL names, so check the body after it. @@ -404,4 +536,476 @@ end TestPkg; assert_eq!(wit_ident("___"), "unnamed"); assert_eq!(wit_ident(""), "unnamed"); } + + /// Fixture for REQ-CODEGEN-WIT-RECORDS-001 (#319): a `data implementation` + /// with scalar subcomponents (the real-world `EepromSnapshot.Impl` = + /// u32 + u32 + u8 pattern), referenced by a process port. + fn build_record_test_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package RecPkg +public + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Byte8 + properties + Data_Size => 1 Bytes; + end Byte8; + + data EepromSnapshot + end EepromSnapshot; + + data implementation EepromSnapshot.Impl + subcomponents + addr: data Word32; + value: data Word32; + flags: data Byte8; + end EepromSnapshot.Impl; + + process Store + features + snapshot_in: in data port EepromSnapshot.Impl; + end Store; + + process implementation Store.Impl + subcomponents + st_thread: thread StoreThread.Impl; + end Store.Impl; + + thread StoreThread + end StoreThread; + + thread implementation StoreThread.Impl + end StoreThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process Store.Impl; + end Top.Impl; +end RecPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "rec.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("RecPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// RED-before-green oracle for REQ-CODEGEN-WIT-RECORDS-001 (#319 items 1,3,6): + /// an AADL `data implementation` with scalar subcomponents must generate a + /// WIT `record` with one typed field per subcomponent — NOT an opaque + /// `list` blob (which would need `cabi_realloc` at the ABI boundary, + /// defeating no_alloc). Currently RED: wit_gen emits `type ... = list`. + #[test] + fn data_implementation_becomes_wit_record() { + let (scope, inst) = build_record_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"); + + // Must NOT degrade the structured type to an opaque byte blob. + assert!( + !body.contains("= list"), + "data implementation must NOT degrade to list:\n{body}" + ); + // Must emit a typed record (name kebab-cased with PascalCase boundaries + // preserved: EepromSnapshot.Impl -> eeprom-snapshot-impl). + assert!( + body.contains("record eeprom-snapshot-impl {"), + "expected a WIT record for EepromSnapshot.Impl:\n{body}" + ); + // One typed field per scalar subcomponent (4B->u32, 4B->u32, 1B->u8). + for field in ["addr: u32", "value: u32", "flags: u8"] { + assert!( + body.contains(field), + "expected record field `{field}`:\n{body}" + ); + } + + // The record must be VALID, bindable WIT — not just string-present. + // wit-parser rejects malformed records / undefined type references. + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("record.wit", &file.content) + .unwrap_or_else(|e| { + panic!( + "generated record WIT failed to parse/resolve: {e}\n---\n{}", + file.content + ) + }); + } + + /// Fixture for the hard-error path: a record with fields that CANNOT be + /// encoded without heap allocation — a 3-byte scalar (no WIT integer) and an + /// opaque type (no Data_Size, no fields). + fn build_bad_record_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package BadPkg +public + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Odd3 + properties + Data_Size => 3 Bytes; + end Odd3; + + data Blob + end Blob; + + data BadRecord + end BadRecord; + + data implementation BadRecord.Impl + subcomponents + good: data Word32; + odd: data Odd3; + blob: data Blob; + end BadRecord.Impl; + + process BadStore + features + in_port: in data port BadRecord.Impl; + end BadStore; + + process implementation BadStore.Impl + subcomponents + th: thread BadThread.Impl; + end BadStore.Impl; + + thread BadThread + end BadThread; + + thread implementation BadThread.Impl + end BadThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process BadStore.Impl; + end Top.Impl; +end BadPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "bad.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("BadPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// Oracle for the user-chosen hard-error policy (REQ-CODEGEN-WIT-RECORDS-001, + /// #319): a record field that cannot be a no_alloc scalar makes codegen + /// REFUSE (return an error) rather than silently emit an allocating + /// `list`. Both offending fields must be reported; the good field must + /// not appear as an error. + #[test] + fn non_encodable_record_field_is_a_hard_error() { + let (scope, inst) = build_bad_record_instance(); + let idx = inst + .all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("test model has a process"); + + let err = generate_wit(&inst, &scope, idx) + .expect_err("codegen must refuse a record with non-encodable fields"); + + // The 3-byte field and the opaque field are both reported… + assert!( + err.errors.iter().any(|e| e.contains("`odd`")), + "expected a hard error naming the 3-byte field `odd`: {:?}", + err.errors + ); + assert!( + err.errors.iter().any(|e| e.contains("`blob`")), + "expected a hard error naming the opaque field `blob`: {:?}", + err.errors + ); + // …and the representable `good` field is NOT flagged. + assert!( + !err.errors.iter().any(|e| e.contains("`good`")), + "the representable u32 field `good` must not be an error: {:?}", + err.errors + ); + } + + /// Fixture with signed / float / unsigned scalar fields (Data Modeling Annex + /// `Number_Representation` / `Data_Representation` properties). + fn build_signed_float_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package NumPkg +public + data FloatWord + properties + Data_Size => 4 Bytes; + Data_Representation => Float; + end FloatWord; + + data SignedWord + properties + Data_Size => 4 Bytes; + Number_Representation => Signed; + end SignedWord; + + data Ushort + properties + Data_Size => 2 Bytes; + end Ushort; + + data Reading + end Reading; + + data implementation Reading.Impl + subcomponents + temp: data FloatWord; + offset: data SignedWord; + count: data Ushort; + end Reading.Impl; + + process Store + features + snapshot_in: in data port Reading.Impl; + end Store; + + process implementation Store.Impl + subcomponents + st_thread: thread StoreThread.Impl; + end Store.Impl; + + thread StoreThread + end StoreThread; + + thread implementation StoreThread.Impl + end StoreThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process Store.Impl; + end Top.Impl; +end NumPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "num.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("NumPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// Oracle for signed/float mapping (REQ-CODEGEN-WIT-RECORDS-001, #319 P2c): + /// `Data_Representation => Float` maps to `f32`/`f64`, `Number_Representation + /// => Signed` to `s8..s64`, and unspecified stays unsigned `u*`. + #[test] + fn record_fields_honor_signedness_and_float() { + let (scope, inst) = build_signed_float_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"); + + for field in ["temp: f32", "offset: s32", "count: u16"] { + assert!( + body.contains(field), + "expected record field `{field}` (signed/float mapping):\n{body}" + ); + } + + // Still valid, bindable WIT. + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("num.wit", &file.content) + .unwrap_or_else(|e| { + panic!("generated WIT failed to parse: {e}\n---\n{}", file.content) + }); + } + + /// Fixture with a NESTED record: `Packet.Impl` has a field whose type is + /// itself a record (`Header.Impl`). + fn build_nested_record_instance() -> (GlobalScope, SystemInstance) { + let aadl = r#" +package NestPkg +public + data Word16 + properties + Data_Size => 2 Bytes; + end Word16; + + data Byte + properties + Data_Size => 1 Bytes; + end Byte; + + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Header + end Header; + + data implementation Header.Impl + subcomponents + hi: data Word16; + lo: data Byte; + end Header.Impl; + + data Packet + end Packet; + + data implementation Packet.Impl + subcomponents + head: data Header.Impl; + body: data Word32; + end Packet.Impl; + + process Store + features + snapshot_in: in data port Packet.Impl; + end Store; + + process implementation Store.Impl + subcomponents + st_thread: thread StoreThread.Impl; + end Store.Impl; + + thread StoreThread + end StoreThread; + + thread implementation StoreThread.Impl + end StoreThread.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + store: process Store.Impl; + end Top.Impl; +end NestPkg; +"#; + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "nest.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("NestPkg"), + &Name::new("Top"), + &Name::new("Impl"), + ); + (scope, inst) + } + + /// Oracle for nested records (REQ-CODEGEN-WIT-RECORDS-001, #319 P2b): a record + /// field whose type is another `data implementation` becomes a nested WIT + /// record, emitted dependency-first and referenced by name. + #[test] + fn nested_record_emits_inner_record_and_references_it() { + let (scope, inst) = build_nested_record_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"); + + // Inner record with its scalar fields… + assert!( + body.contains("record header-impl {"), + "expected nested record `header-impl`:\n{body}" + ); + for field in ["hi: u16", "lo: u8"] { + assert!( + body.contains(field), + "expected inner field `{field}`:\n{body}" + ); + } + // …and the outer record referencing it by type name (not list). + assert!( + body.contains("record packet-impl {"), + "expected outer record `packet-impl`:\n{body}" + ); + assert!( + body.contains("head: header-impl"), + "outer record must reference the nested record by name:\n{body}" + ); + assert!( + body.contains("body: u32"), + "expected outer scalar field `body: u32`:\n{body}" + ); + assert!( + !body.contains("= list"), + "nested record must NOT fall back to list:\n{body}" + ); + + // The nested record must be declared BEFORE the outer that references it, + // and the whole thing must bind under wit-parser. + let inner_at = body.find("record header-impl").unwrap(); + let outer_at = body.find("record packet-impl").unwrap(); + assert!( + inner_at < outer_at, + "nested record must be emitted dependency-first:\n{body}" + ); + let mut resolve = wit_parser::Resolve::new(); + resolve + .push_str("nest.wit", &file.content) + .unwrap_or_else(|e| { + panic!( + "nested-record WIT failed to parse: {e}\n---\n{}", + file.content + ) + }); + } } diff --git a/crates/spar-codegen/src/workspace_gen.rs b/crates/spar-codegen/src/workspace_gen.rs index 801dd826..04cb4226 100644 --- a/crates/spar-codegen/src/workspace_gen.rs +++ b/crates/spar-codegen/src/workspace_gen.rs @@ -41,11 +41,12 @@ pub fn generate_workspace(root_name: &str, process_names: &[String]) -> Vec GeneratedFile { } } -fn generate_crate_lib_rs(name: &str) -> GeneratedFile { - let safe = sanitize_path_component(name); - let mut code = String::new(); - - code.push_str(&format!( - "//! Generated component crate for AADL process: {safe}\n" - )); - code.push_str("//! DO NOT EDIT -- regenerate with `spar codegen`.\n\n"); - code.push_str("// Component modules will be generated here by spar codegen.\n"); - code.push_str("// Each thread becomes a submodule with port types and dispatch trait.\n"); - - GeneratedFile { - path: format!("crates/{safe}/src/lib.rs"), - content: code, - } -} - #[cfg(test)] mod tests { use super::*; @@ -340,16 +324,15 @@ mod tests { } #[test] - fn crate_lib_rs_generated() { + fn crate_lib_rs_not_emitted_by_workspace_gen() { + // The crate root is now the wit-bindgen binding crate emitted from + // generate() (rust_gen::generate_process_bindings), NOT workspace_gen. let files = generate_workspace("test_system", &["controller".to_string()]); - - let lib_rs = files - .iter() - .find(|f| f.path == "crates/controller/src/lib.rs") - .unwrap(); assert!( - lib_rs.content.contains("Generated component crate"), - "Must have generated header" + !files + .iter() + .any(|f| f.path == "crates/controller/src/lib.rs"), + "workspace_gen must not emit the crate lib.rs (bindings own it)" ); } diff --git a/crates/spar-codegen/tests/golden_test.rs b/crates/spar-codegen/tests/golden_test.rs index e05e4d12..fc8aaa47 100644 --- a/crates/spar-codegen/tests/golden_test.rs +++ b/crates/spar-codegen/tests/golden_test.rs @@ -11,16 +11,18 @@ use spar_hir_def::name::Name; use spar_hir_def::resolver::GlobalScope; /// Build a SystemInstance from inline AADL text. -fn build_instance(aadl: &str, pkg: &str, typ: &str, imp: &str) -> SystemInstance { +fn build_instance(aadl: &str, pkg: &str, typ: &str, imp: &str) -> (GlobalScope, SystemInstance) { let db = spar_hir_def::HirDefDatabase::default(); let sf = spar_base_db::SourceFile::new(&db, "test.aadl".to_string(), aadl.to_string()); let tree = spar_hir_def::file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate(&scope, &Name::new(pkg), &Name::new(typ), &Name::new(imp)) + let inst = + SystemInstance::instantiate(&scope, &Name::new(pkg), &Name::new(typ), &Name::new(imp)); + (scope, inst) } /// Load the golden AADL model from test-data. -fn golden_instance() -> SystemInstance { +fn golden_instance() -> (GlobalScope, SystemInstance) { let aadl = include_str!("../../../test-data/codegen/building_control.aadl"); build_instance(aadl, "BuildingControl", "BuildingSystem", "impl") } @@ -29,7 +31,7 @@ fn golden_instance() -> SystemInstance { #[test] fn golden_instance_has_expected_hierarchy() { - let inst = golden_instance(); + let (_scope, inst) = golden_instance(); // Root is a system let root = inst.component(inst.root); @@ -60,7 +62,7 @@ fn golden_instance_has_expected_hierarchy() { // ── Full generation: all modules produce output ──────────────────── fn generate_all() -> CodegenOutput { - let inst = golden_instance(); + let (scope, inst) = golden_instance(); let config = CodegenConfig { root_name: "building_system".into(), output_dir: "output".into(), @@ -69,7 +71,7 @@ fn generate_all() -> CodegenOutput { rivet: true, dry_run: true, }; - generate(&inst, &config) + generate(&inst, &scope, &config).expect("codegen should succeed on the golden model") } #[test] @@ -115,7 +117,7 @@ fn golden_model_generates_wit() { /// output as a predictably-shaped tree artifact. #[test] fn wit_format_emits_only_wit_files() { - let inst = golden_instance(); + let (scope, inst) = golden_instance(); let config = CodegenConfig { root_name: "building_system".into(), output_dir: "output".into(), @@ -124,7 +126,8 @@ fn wit_format_emits_only_wit_files() { rivet: false, dry_run: true, }; - let output = generate(&inst, &config); + let output = + generate(&inst, &scope, &config).expect("codegen should succeed on the golden model"); assert!( !output.files.is_empty(), @@ -147,7 +150,7 @@ fn wit_format_emits_only_wit_files() { /// rust/both paths. #[test] fn rust_format_still_emits_workspace() { - let inst = golden_instance(); + let (scope, inst) = golden_instance(); let config = CodegenConfig { root_name: "building_system".into(), output_dir: "output".into(), @@ -156,7 +159,8 @@ fn rust_format_still_emits_workspace() { rivet: false, dry_run: true, }; - let output = generate(&inst, &config); + let output = + generate(&inst, &scope, &config).expect("codegen should succeed on the golden model"); assert!( output.files.iter().any(|f| f.path == "Cargo.toml"), @@ -180,7 +184,13 @@ fn golden_model_generates_rust_component() { let rust_files: Vec<_> = output .files .iter() - .filter(|f| f.path.starts_with("src/") && f.path.ends_with(".rs")) + .filter(|f| { + f.path.starts_with("crates/") + && f.path.contains("/src/") + && f.path.ends_with(".rs") + && !f.path.ends_with("/lib.rs") + && !f.path.ends_with("/types.rs") + }) .collect(); assert!( !rust_files.is_empty(), @@ -246,14 +256,18 @@ fn golden_model_generates_tests() { test.content.contains("#[test]"), "Test harness should contain #[test] attributes" ); - assert!( - test.content.contains("_initializes"), - "Test harness should have initialization test" - ); + // The golden BuildingControl threads are Periodic, so the harness exposes the + // compute lifecycle only — no initialize/finalize tests (they'd call trait + // methods rust_gen no longer generates for a Periodic thread). + // REQ-CODEGEN-WIT-RECORDS-001 (#319 item 7). assert!( test.content.contains("_compute_dispatches"), "Test harness should have dispatch test" ); + assert!( + !test.content.contains("_initializes"), + "Periodic thread harness must not have an initialization test" + ); } // ── Proof generation ─────────────────────────────────────────────── @@ -441,7 +455,10 @@ fn golden_model_generates_build_contract() { for line in manifest.content.lines() { if let Some(rest) = line.trim().strip_prefix("[thread.\"") { let thread = rest.trim_end_matches("\"]"); - let src = format!("src/{thread}.rs"); + // Manifest key is "/"; the source now lives under + // that process crate (crates//src/.rs). + let (proc_dir, th_name) = thread.split_once('/').unwrap_or(("", thread)); + let src = format!("crates/{proc_dir}/src/{th_name}.rs"); assert!( output.files.iter().any(|f| f.path == src), "manifest names {thread} but {src} was not generated" @@ -465,7 +482,13 @@ fn golden_model_all_modules_produce_output() { let rust_count = output .files .iter() - .filter(|f| f.path.starts_with("src/") && f.path.ends_with(".rs")) + .filter(|f| { + f.path.starts_with("crates/") + && f.path.contains("/src/") + && f.path.ends_with(".rs") + && !f.path.ends_with("/lib.rs") + && !f.path.ends_with("/types.rs") + }) .count(); let config_count = output .files @@ -562,7 +585,13 @@ fn golden_model_timing_properties_in_rust() { let rust_files: Vec<_> = output .files .iter() - .filter(|f| f.path.starts_with("src/") && f.path.ends_with(".rs")) + .filter(|f| { + f.path.starts_with("crates/") + && f.path.contains("/src/") + && f.path.ends_with(".rs") + && !f.path.ends_with("/lib.rs") + && !f.path.ends_with("/types.rs") + }) .collect(); let has_period = rust_files.iter().any(|f| f.content.contains("PERIOD_PS")); diff --git a/crates/spar-codegen/tests/lifecycle_bindings.rs b/crates/spar-codegen/tests/lifecycle_bindings.rs new file mode 100644 index 00000000..ed752831 --- /dev/null +++ b/crates/spar-codegen/tests/lifecycle_bindings.rs @@ -0,0 +1,432 @@ +//! Integration oracle for REQ-CODEGEN-WIT-RECORDS-001 (#319 items 4 & 7): +//! `Dispatch_Protocol`-derived lifecycle + WIT world ⟷ Rust `Guest` alignment. +//! +//! Two levels of oracle: +//! 1. **String alignment** (fast, always runs): the WIT world exports the exact +//! lifecycle a thread's `Dispatch_Protocol` implies, and the generated +//! bindings `impl Guest` names the matching methods. +//! 2. **Compiler-enforced** (`#[ignore]`, opt-in): emit the whole crate and run +//! `cargo check`. wit-bindgen's `generate!` derives the `Guest` trait from +//! the world, so a mangling / keyword / collision bug in the emitter is a +//! compile error — the string test cannot catch a mismatch it replicates. +//! Ignored because it shells out to `cargo` (network for the first build, +//! disk for the target dir). Run with: +//! `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` + +use spar_codegen::rust_gen::generate_process_bindings; +use spar_codegen::wit_gen::generate_wit; +use spar_codegen::{CodegenConfig, OutputFormat, generate}; +use spar_hir_def::instance::SystemInstance; +use spar_hir_def::item_tree::ComponentCategory; +use spar_hir_def::name::Name; +use spar_hir_def::resolver::GlobalScope; + +/// A process `Ctrl` with a Periodic thread and a Sporadic thread, so the two +/// lifecycle shapes are exercised in one model. +const LIFECYCLE_AADL: &str = r#" +package LifePkg +public + thread PeriodicWorker + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end PeriodicWorker; + + thread implementation PeriodicWorker.Impl + end PeriodicWorker.Impl; + + thread SporadicWorker + properties + Thread_Properties::Dispatch_Protocol => Sporadic; + end SporadicWorker; + + thread implementation SporadicWorker.Impl + end SporadicWorker.Impl; + + process Ctrl + end Ctrl; + + process implementation Ctrl.Impl + subcomponents + pw: thread PeriodicWorker.Impl; + sw: thread SporadicWorker.Impl; + end Ctrl.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + ctrl: process Ctrl.Impl; + end Top.Impl; +end LifePkg; +"#; + +fn build(aadl: &str, pkg: &str, typ: &str, imp: &str) -> (GlobalScope, SystemInstance) { + let db = spar_hir_def::HirDefDatabase::default(); + let sf = spar_base_db::SourceFile::new(&db, "life.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(pkg), &Name::new(typ), &Name::new(imp)); + (scope, inst) +} + +fn process_idx(inst: &SystemInstance) -> spar_hir_def::instance::ComponentInstanceIdx { + inst.all_components() + .find(|(_, c)| c.category == ComponentCategory::Process) + .map(|(idx, _)| idx) + .expect("model has a process") +} + +/// Item 7: the WIT world exports the lifecycle each thread's `Dispatch_Protocol` +/// implies — Periodic → `compute` only; Sporadic → initialize/compute/finalize — +/// NOT the old opaque single `{thread}: func()`. +#[test] +fn world_exports_dispatch_derived_lifecycle() { + let (scope, inst) = build(LIFECYCLE_AADL, "LifePkg", "Top", "Impl"); + let idx = process_idx(&inst); + let wit = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + let body = &wit.content; + + // Exports are keyed by the *subcomponent instance* name (`pw`, `sw`) — unique + // within the process — not the thread type name. + // + // Periodic instance `pw`: compute only. + assert!( + body.contains("export pw-compute: func();"), + "periodic instance must export compute:\n{body}" + ); + assert!( + !body.contains("export pw-initialize"), + "periodic instance must NOT export initialize:\n{body}" + ); + assert!( + !body.contains("export pw-finalize"), + "periodic instance must NOT export finalize:\n{body}" + ); + + // Sporadic instance `sw`: full lifecycle. If this fails with only `-compute`, + // the Dispatch_Protocol did not resolve and defaulted to Periodic — a real + // bug, not a fixture typo. + for m in ["initialize", "compute", "finalize"] { + assert!( + body.contains(&format!("export sw-{m}: func();")), + "sporadic instance must export {m} (Dispatch_Protocol must resolve):\n{body}" + ); + } + + // The old opaque single-func export must be gone. + assert!( + !body.contains("export pw: func();"), + "opaque per-thread export must be replaced by lifecycle exports:\n{body}" + ); +} + +/// Items 4 & 7: the generated bindings crate root wires the world to Rust — +/// `generate!` + `impl Guest` (matching the world's lifecycle exports) + `export!`. +#[test] +fn bindings_wire_generate_guest_and_export() { + let (_scope, inst) = build(LIFECYCLE_AADL, "LifePkg", "Top", "Impl"); + let idx = process_idx(&inst); + let file = generate_process_bindings(&inst, idx); + let src = &file.content; + + assert_eq!( + file.path, "crates/ctrl/src/lib.rs", + "bindings land at the process crate root" + ); + assert!( + src.contains("wit_bindgen::generate!({"), + "must invoke wit_bindgen::generate!:\n{src}" + ); + assert!( + src.contains("world: \"ctrl-world\","), + "generate! must target the process world:\n{src}" + ); + assert!( + src.contains("impl Guest for Component {"), + "must impl the world's Guest trait:\n{src}" + ); + assert!( + src.contains("export!(Component);"), + "must export the component:\n{src}" + ); + + // Guest methods match the world exports (instance name; kebab `-` → snake `_`). + assert!( + src.contains("fn pw_compute()"), + "periodic instance → single compute method:\n{src}" + ); + for m in ["initialize", "compute", "finalize"] { + assert!( + src.contains(&format!("fn sw_{m}()")), + "sporadic instance → {m} method:\n{src}" + ); + } + assert!( + !src.contains("fn pw_initialize()"), + "periodic instance must not get initialize:\n{src}" + ); +} + +/// A RECORD-bearing, MULTI-PROCESS, mixed-dispatch model: `ProcA` has a +/// record-typed port (`Snapshot.Impl` = u32 + u8) and a Periodic thread; `ProcB` +/// has a scalar port and a Sporadic thread. This is what the compile oracle must +/// exercise — the requirement's subject is record interfaces, and two processes +/// surface wit-package / `../../wit/*.wit` path collisions a single-process +/// fixture never would. +const RECORDS_MP_AADL: &str = r#" +package MpkgRec +public + data Word32 + properties + Data_Size => 4 Bytes; + end Word32; + + data Byte8 + properties + Data_Size => 1 Bytes; + end Byte8; + + data Snapshot + end Snapshot; + + data implementation Snapshot.Impl + subcomponents + addr: data Word32; + flags: data Byte8; + end Snapshot.Impl; + + thread WorkerA + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end WorkerA; + + thread implementation WorkerA.Impl + end WorkerA.Impl; + + thread WorkerB + properties + Thread_Properties::Dispatch_Protocol => Sporadic; + end WorkerB; + + thread implementation WorkerB.Impl + end WorkerB.Impl; + + process ProcA + features + snap_in: in data port Snapshot.Impl; + end ProcA; + + process implementation ProcA.Impl + subcomponents + wa: thread WorkerA.Impl; + end ProcA.Impl; + + process ProcB + features + count_in: in data port Word32; + end ProcB; + + process implementation ProcB.Impl + subcomponents + wb: thread WorkerB.Impl; + end ProcB.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + pa: process ProcA.Impl; + pb: process ProcB.Impl; + end Top.Impl; +end MpkgRec; +"#; + +/// A model with an OPAQUE top-level port (a `data` type with no Data_Size and no +/// subcomponents). This is the negative control for the no_alloc oracle: wit_gen +/// deliberately keeps the `list` fallback here (P2a), so the generated WIT +/// MUST contain `list` — proving the no-alloc check can actually fire. +const OPAQUE_PORT_AADL: &str = r#" +package OpaquePkg +public + data Blob + end Blob; + + thread Worker + properties + Thread_Properties::Dispatch_Protocol => Periodic; + end Worker; + + thread implementation Worker.Impl + end Worker.Impl; + + process Proc + features + blob_in: in data port Blob; + end Proc; + + process implementation Proc.Impl + subcomponents + w: thread Worker.Impl; + end Proc.Impl; + + system Top + end Top; + + system implementation Top.Impl + subcomponents + p: process Proc.Impl; + end Top.Impl; +end OpaquePkg; +"#; + +/// NO-ALLOC ORACLE (REQ-CODEGEN-WIT-RECORDS-002 item 3c). The sound signal is at +/// the WIT level: `list`/`string` are the only unbounded canonical-ABI types (the +/// ones forcing dynamic `cabi_realloc`); everything scalar/record is bounded. A +/// probe (2026-07-08) showed the wasm `cabi_realloc` SYMBOL is unconditional +/// wit-bindgen-rt glue — present in both alloc and no-alloc components — so a +/// symbol check cannot discriminate. This checks the WIT instead. +#[test] +fn no_alloc_all_scalar_record_model_has_no_unbounded_wit_types() { + let (scope, inst) = build(RECORDS_MP_AADL, "MpkgRec", "Top", "Impl"); + // Check every process's generated WIT. + let mut checked = 0; + for (idx, comp) in inst.all_components() { + if comp.category != ComponentCategory::Process { + continue; + } + let wit = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + assert!( + !wit.content.contains("list<") && !wit.content.contains("string"), + "all-scalar/record model must emit no unbounded (list/string) WIT \ + types:\n{}", + wit.content + ); + checked += 1; + } + assert_eq!(checked, 2, "fixture has two processes"); +} + +/// Negative control: the no_alloc oracle can fire. An opaque top-level port keeps +/// the `list` fallback, so its WIT MUST contain `list` — confirming the +/// assertion above is not vacuous, and marking the scope boundary (opaque ports +/// are out of the no-alloc guarantee). +#[test] +fn no_alloc_opaque_port_model_does_contain_list_fallback() { + let (scope, inst) = build(OPAQUE_PORT_AADL, "OpaquePkg", "Top", "Impl"); + let idx = process_idx(&inst); + let wit = generate_wit(&inst, &scope, idx).expect("wit generation should succeed"); + assert!( + wit.content.contains("list"), + "opaque port must fall back to list (negative control):\n{}", + wit.content + ); +} + +/// COMPILER-ENFORCED oracle (opt-in): emit the full crate and `cargo check` it. +/// This is the real item-4/7 gate — it runs wit-bindgen's `generate!`, so the +/// `impl Guest` is type-checked against the world. A name-mangling or keyword +/// bug in the emitter fails here even when the string test above is green. +/// +/// Uses the RECORD-bearing, MULTI-PROCESS fixture so the check covers a bindings +/// crate whose world imports a WIT `record` interface, and two member crates with +/// distinct wit packages — not just the portless single-process alignment case. +/// +/// `#[ignore]` because it shells out to cargo. Run: +/// `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` +#[test] +#[ignore = "shells out to cargo check (network/disk); run with --ignored"] +fn emitted_crate_cargo_checks() { + use std::process::Command; + + let (scope, inst) = build(RECORDS_MP_AADL, "MpkgRec", "Top", "Impl"); + let config = CodegenConfig { + root_name: "life".into(), + output_dir: "out".into(), + format: OutputFormat::Both, + verify: None, + rivet: false, + dry_run: true, + }; + let out = generate(&inst, &scope, &config).expect("codegen should succeed"); + + // Materialize the emitted workspace into a temp dir. + let root = std::env::temp_dir().join("spar-p3-bindings-oracle"); + let _ = std::fs::remove_dir_all(&root); + for f in &out.files { + let path = root.join(&f.path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &f.content).unwrap(); + } + + // A dedicated target dir on the internal disk avoids the global 512GB + // redirect (which may be full) and file-lock contention. + let target_dir = std::env::temp_dir().join("spar-p3-bindings-target"); + let status = Command::new("cargo") + .arg("check") + .current_dir(&root) + .env("CARGO_INCREMENTAL", "0") + .env("CARGO_TARGET_DIR", &target_dir) + .status() + .expect("failed to spawn cargo check"); + + assert!( + status.success(), + "generated crate failed to `cargo check` (world⟷Guest wiring is broken); \ + inspect {}", + root.display() + ); +} + +/// STRONGER EVIDENCE (opt-in, not a completion gate): the emitted crate builds all +/// the way to the real deployment target `wasm32-wasip2` — proving the records + +/// bindings + delegation survive componentization, not just a host type-check. +/// Per REQ-CODEGEN-WIT-RECORDS-002 this is nice-to-have; the required no-alloc +/// oracle is the WIT-level check above (a wasm `cabi_realloc` symbol is +/// unconditional wit-bindgen-rt glue and cannot discriminate). +/// +/// `#[ignore]` — needs the wasm32-wasip2 target + shells out to cargo. Run: +/// `cargo test -p spar-codegen --test lifecycle_bindings -- --ignored` +#[test] +#[ignore = "needs wasm32-wasip2 target + shells out to cargo; run with --ignored"] +fn emitted_crate_builds_to_wasm32_wasip2() { + use std::process::Command; + + let (scope, inst) = build(RECORDS_MP_AADL, "MpkgRec", "Top", "Impl"); + let config = CodegenConfig { + root_name: "life".into(), + output_dir: "out".into(), + format: OutputFormat::Both, + verify: None, + rivet: false, + dry_run: true, + }; + let out = generate(&inst, &scope, &config).expect("codegen should succeed"); + + let root = std::env::temp_dir().join("spar-p3-wasm-oracle"); + let _ = std::fs::remove_dir_all(&root); + for f in &out.files { + let path = root.join(&f.path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &f.content).unwrap(); + } + + let target_dir = std::env::temp_dir().join("spar-p3-wasm-target"); + let status = Command::new("cargo") + .arg("build") + .arg("--target") + .arg("wasm32-wasip2") + .current_dir(&root) + .env("CARGO_INCREMENTAL", "0") + .env("CARGO_TARGET_DIR", &target_dir) + .status() + .expect("failed to spawn cargo build"); + + assert!( + status.success(), + "generated crate failed to build to wasm32-wasip2; inspect {}", + root.display() + ); +} diff --git a/crates/spar-hir-def/src/resolver.rs b/crates/spar-hir-def/src/resolver.rs index 127e0205..1126f6a3 100644 --- a/crates/spar-hir-def/src/resolver.rs +++ b/crates/spar-hir-def/src/resolver.rs @@ -28,6 +28,45 @@ pub enum ResolvedClassifier { Unresolved, } +/// The WIT-relevant shape of a `data` classifier, resolved from the item-tree +/// for code generation (REQ-CODEGEN-WIT-RECORDS-001, GitHub #319). Lets +/// spar-codegen decide between a scalar type alias, a WIT record, or a hard +/// error without re-implementing classifier / `Data_Size` resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DataShape { + /// A scalar `data` type with a resolved `Data_Size`, in bytes, and its + /// numeric representation (signed / unsigned / float). + Scalar { bytes: u64, kind: ScalarKind }, + /// A `data implementation` decomposed into named fields (→ WIT record). + /// `type_name` is the source classifier string (e.g. `Packet.Impl`) so + /// codegen can name a nested record consistently; `fields` are in + /// declaration order, each shape resolved recursively. + Record { + type_name: String, + fields: Vec, + }, + /// A `data` classifier with neither a `Data_Size` nor subcomponents, or one + /// that does not resolve — opaque (codegen keeps its legacy handling). + Opaque, +} + +/// Numeric representation of a scalar `data` type, from the Data Modeling Annex +/// `Data_Representation` / `Number_Representation` properties. Defaults to +/// `Unsigned` when unspecified (conservative). REQ-CODEGEN-WIT-RECORDS-001. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScalarKind { + Unsigned, + Signed, + Float, +} + +/// One field of a [`DataShape::Record`] — a subcomponent of a data impl. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataField { + pub name: Name, + pub shape: DataShape, +} + /// Result of resolving a property reference. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedProperty { @@ -325,6 +364,103 @@ impl GlobalScope { Some(&tree.features[feat_idx]) } + /// Resolve a `data` classifier to its WIT-relevant [`DataShape`]: a + /// `data implementation` with subcomponents becomes a `Record` (fields + /// resolved recursively), a `data` type carrying `Data_Size` becomes a + /// `Scalar`, and anything else is `Opaque`. REQ-CODEGEN-WIT-RECORDS-001 + /// (GitHub #319) — codegen consumes this to emit typed records instead of + /// opaque `list` blobs. + pub fn resolve_data_shape(&self, from_package: &Name, classifier: &ClassifierRef) -> DataShape { + let resolved = self.resolve_classifier(from_package, classifier); + // An implementation with subcomponents = a record of fields. + if let ResolvedClassifier::ComponentImpl { loc, package } = &resolved + && let Some(ci) = self.get_component_impl(*loc) + && ci.category == crate::item_tree::ComponentCategory::Data + && !ci.subcomponents.is_empty() + && let Some(tree) = self.tree(loc.tree) + { + let mut fields = Vec::new(); + for &sub_idx in &ci.subcomponents { + let sub = &tree.subcomponents[sub_idx]; + let shape = match &sub.classifier { + Some(cref) => self.resolve_data_shape(package, cref), + None => DataShape::Opaque, + }; + fields.push(DataField { + name: sub.name.clone(), + shape, + }); + } + return DataShape::Record { + type_name: classifier.to_string(), + fields, + }; + } + // A data type carrying Data_Size = a scalar. + if let Some((bytes, kind)) = self.scalar_of(from_package, classifier) { + return DataShape::Scalar { bytes, kind }; + } + DataShape::Opaque + } + + /// 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. + pub fn data_size_bytes(&self, from_package: &Name, classifier: &ClassifierRef) -> Option { + self.scalar_of(from_package, classifier) + .map(|(bytes, _)| bytes) + } + + /// Resolve a scalar `data` type to its (`Data_Size` bytes, representation). + /// Reads the Data Modeling Annex `Data_Representation` (Integer vs Float) and + /// `Number_Representation` (Signed vs Unsigned); both default to + /// integer/unsigned when unspecified. Returns `None` when the classifier is + /// not a `data` type or declares no `Data_Size`. REQ-CODEGEN-WIT-RECORDS-001. + fn scalar_of( + &self, + from_package: &Name, + classifier: &ClassifierRef, + ) -> Option<(u64, ScalarKind)> { + let resolved = self.resolve_classifier(from_package, classifier); + let loc = match &resolved { + ResolvedClassifier::ComponentType { loc, .. } => *loc, + _ => 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::Float` still matches `Float`. + fn leaf(v: &str) -> &str { + v.rsplit("::").next().unwrap_or(v).trim() + } + let mut bytes = None; + let mut is_float = false; + let mut is_signed = false; + 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_Size") || pn.eq_ignore_ascii_case("Source_Data_Size") { + // parse_size_value returns BITS ("8 Bytes" -> 64). + bytes = crate::property_value::parse_size_value(&pa.value).map(|bits| bits / 8); + } else if pn.eq_ignore_ascii_case("Data_Representation") { + is_float = leaf(&pa.value).eq_ignore_ascii_case("Float"); + } else if pn.eq_ignore_ascii_case("Number_Representation") { + is_signed = leaf(&pa.value).eq_ignore_ascii_case("Signed"); + } + } + let kind = if is_float { + ScalarKind::Float + } else if is_signed { + ScalarKind::Signed + } else { + ScalarKind::Unsigned + }; + Some((bytes?, kind)) + } + /// Resolve a classifier reference from within a package context. pub fn resolve_classifier( &self, diff --git a/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs b/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs index 814385ab..c6513426 100644 --- a/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs +++ b/fuzz/fuzz_targets/fuzz_codegen_roundtrip.rs @@ -30,7 +30,7 @@ struct Knobs { dry_run: bool, } -fn build_seed_instance() -> SystemInstance { +fn build_seed_instance() -> (GlobalScope, SystemInstance) { let db = spar_hir_def::HirDefDatabase::default(); let sf = spar_base_db::SourceFile::new( &db, @@ -39,16 +39,17 @@ fn build_seed_instance() -> SystemInstance { ); let tree = spar_hir_def::file_item_tree(&db, sf); let scope = GlobalScope::from_trees(vec![tree]); - SystemInstance::instantiate( + let inst = SystemInstance::instantiate( &scope, &Name::new("BuildingControl"), &Name::new("BuildingSystem"), &Name::new("impl"), - ) + ); + (scope, inst) } fuzz_target!(|knobs: Knobs| { - let inst = build_seed_instance(); + let (scope, inst) = build_seed_instance(); let format = match knobs.format_pick % 3 { 0 => OutputFormat::Rust, @@ -74,11 +75,14 @@ fuzz_target!(|knobs: Knobs| { }; // Contract: no panic on any config combination, even though inputs are - // identical for the instance model. - let out = generate(&inst, &config); - // Touch every file path + content length so a latent panic in formatting - // code would fire here rather than being dead-code-eliminated. - for f in &out.files { - std::hint::black_box((f.path.len(), f.content.len())); + // identical for the instance model. `generate` is fallible (it refuses, + // rather than panics, on non-encodable record fields — REQ-CODEGEN-WIT- + // RECORDS-001); a returned `Err` is a legitimate outcome, not a crash. + if let Ok(out) = generate(&inst, &scope, &config) { + // Touch every file path + content length so a latent panic in + // formatting code would fire here rather than being DCE'd. + for f in &out.files { + std::hint::black_box((f.path.len(), f.content.len())); + } } }); diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 1915f603..d5b6bbb5 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -157,7 +157,7 @@ version = "0.8.6" criteria = "safe-to-deploy" [[exemptions.crossbeam-epoch]] -version = "0.9.18" +version = "0.9.20" criteria = "safe-to-deploy" [[exemptions.crossbeam-queue]] @@ -831,4 +831,3 @@ criteria = "safe-to-run" [[exemptions.zmij]] version = "1.0.21" criteria = "safe-to-deploy" -