From dab3aacce86e8aa7e0c1d80c44464f981e5bf208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B6Rei?= <212411920+arcjet-rei@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:06:47 -0700 Subject: [PATCH 1/3] test: parse unit-test fixtures from WIT text The unit tests in exports.rs and imports.rs built wit_parser values (Function, TypeDef, World, Interface, Package) as struct literals, so every wit-parser release that added a field broke them: the bump to wit-parser 0.258 needed 25 additions across 22 literals. They could also describe states the parser never produces, such as a function named take_wide under the world key take-wide. A test-only Fixture parses a WIT document with Resolve::push_source, fills the type sizes, and looks up exported functions, interface functions and types by name. Each test now states its fixture as WIT and keeps its assertions; the two export assertions that named the function with underscores now use the kebab-case name gravity emits for real WIT. test_record_type_generation keeps its checks and drops its debug printing, asserting the self-referential alias it described instead of printing about it. Co-Authored-By: Claude Opus 5.5 --- cmd/gravity/src/codegen/exports.rs | 325 ++---------- cmd/gravity/src/codegen/imports.rs | 779 +++++----------------------- cmd/gravity/src/codegen/mod.rs | 2 + cmd/gravity/src/codegen/test_wit.rs | 68 +++ 4 files changed, 241 insertions(+), 933 deletions(-) create mode 100644 cmd/gravity/src/codegen/test_wit.rs diff --git a/cmd/gravity/src/codegen/exports.rs b/cmd/gravity/src/codegen/exports.rs index 05756140..c40a20e8 100644 --- a/cmd/gravity/src/codegen/exports.rs +++ b/cmd/gravity/src/codegen/exports.rs @@ -96,65 +96,35 @@ impl FormatInto for ExportGenerator<'_> { #[cfg(test)] mod tests { use genco::prelude::*; - use wit_bindgen_core::wit_parser::{ - Function, FunctionKind, Param, Resolve, SizeAlign, Type, World, WorldItem, WorldKey, - }; - use crate::go::GoIdentifier; + use crate::{codegen::test_wit::Fixture, go::GoIdentifier}; use super::{ExportConfig, ExportGenerator}; - #[test] - fn test_generate_function_simple_u32_param() { - let func = Function { - name: "add_number".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "value".to_string(), - ty: Type::U32, - span: Default::default(), - }], - result: Some(Type::U32), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - let world = World { - name: "test-world".to_string(), - imports: [].into(), - exports: [( - WorldKey::Name("add-number".to_string()), - WorldItem::Function(func.clone()), - )] - .into(), - docs: Default::default(), - stability: Default::default(), - includes: Default::default(), - span: Default::default(), - package: None, - }; - - let resolve = Resolve::new(); - let mut sizes = SizeAlign::default(); - sizes.fill(&resolve).expect("sizes should fill"); + /// Generates the Go method for the export `name` in `fixture`'s world. + fn generate(fixture: &Fixture, name: &str) -> String { let instance = GoIdentifier::public("TestInstance"); - let config = ExportConfig { instance: &instance, - world: &world, - resolve: &resolve, - sizes: &sizes, + world: fixture.world(), + resolve: &fixture.resolve, + sizes: &fixture.sizes, }; - let generator = ExportGenerator::new(config); let mut tokens = Tokens::new(); + generator.generate_function(fixture.export(name), &mut tokens); + tokens.to_string().unwrap() + } - // Call the actual generate_function method - generator.generate_function(&func, &mut tokens); - - let generated = tokens.to_string().unwrap(); + #[test] + fn test_generate_function_simple_u32_param() { + let fixture = Fixture::parse( + "package test:fixture; + world test-world { + export add-number: func(value: u32) -> u32; + }", + ); + let generated = generate(&fixture, "add-number"); println!("Generated: {}", generated); // Verify basic function structure @@ -166,7 +136,7 @@ mod tests { // Verify function body assert!(generated.contains("arg0 := value")); assert!(generated - .contains("i.module.ExportedFunction(\"add_number\").Call(ctx, uint64(result0))")); + .contains("i.module.ExportedFunction(\"add-number\").Call(ctx, uint64(result0))")); assert!(generated.contains("if err1 != nil {")); assert!(generated.contains("panic(err1)")); assert!(generated.contains("results1 := raw1[0]")); @@ -193,85 +163,14 @@ mod tests { /// causing a Go compile error: cannot use uint64 as uint32. #[test] fn test_export_variant_u32_no_encode_u32() { - use wit_bindgen_core::wit_parser::{ - Case, TypeDef, TypeDefKind, TypeOwner, Variant, - }; - - let mut resolve = Resolve::new(); - - // variant u32-option { some-val(u32), none-val } - let variant_def = TypeDef { - name: Some("u32-option".to_string()), - kind: TypeDefKind::Variant(Variant { - cases: vec![ - Case { - name: "some-val".to_string(), - ty: Some(Type::U32), - docs: Default::default(), - span: Default::default(), - }, - Case { - name: "none-val".to_string(), - ty: None, - docs: Default::default(), - span: Default::default(), - }, - ], - }), - owner: TypeOwner::None, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - let variant_id = resolve.types.alloc(variant_def); - - let func = Function { - name: "process_u32_option".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "opt".to_string(), - ty: Type::Id(variant_id), - span: Default::default(), - }], - result: Some(Type::U32), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - let world = World { - name: "test-world".to_string(), - imports: [].into(), - exports: [( - WorldKey::Name("process-u32-option".to_string()), - WorldItem::Function(func.clone()), - )] - .into(), - docs: Default::default(), - stability: Default::default(), - includes: Default::default(), - span: Default::default(), - package: None, - }; - - let mut sizes = SizeAlign::default(); - sizes.fill(&resolve).expect("sizes should fill"); - let instance = GoIdentifier::public("TestInstance"); - - let config = ExportConfig { - instance: &instance, - world: &world, - resolve: &resolve, - sizes: &sizes, - }; - - let generator = ExportGenerator::new(config); - let mut tokens = Tokens::new(); - generator.generate_function(&func, &mut tokens); - - let generated = tokens.to_string().unwrap(); + let fixture = Fixture::parse( + "package test:fixture; + world test-world { + variant u32-option { some-val(u32), none-val } + export process-u32-option: func(opt: u32-option) -> u32; + }", + ); + let generated = generate(&fixture, "process-u32-option"); println!("Generated u32-option function:\n{}", generated); // VariantLower declares `var variant_1 uint32` for the I32 payload slot. @@ -290,85 +189,14 @@ mod tests { /// Go compile error: cannot use int64 as uint64. #[test] fn test_export_variant_u64_no_int64_cast() { - use wit_bindgen_core::wit_parser::{ - Case, TypeDef, TypeDefKind, TypeOwner, Variant, - }; - - let mut resolve = Resolve::new(); - - // variant u64-option { some-val(u64), none-val } - let variant_def = TypeDef { - name: Some("u64-option".to_string()), - kind: TypeDefKind::Variant(Variant { - cases: vec![ - Case { - name: "some-val".to_string(), - ty: Some(Type::U64), - docs: Default::default(), - span: Default::default(), - }, - Case { - name: "none-val".to_string(), - ty: None, - docs: Default::default(), - span: Default::default(), - }, - ], - }), - owner: TypeOwner::None, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - let variant_id = resolve.types.alloc(variant_def); - - let func = Function { - name: "process_u64_option".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "opt".to_string(), - ty: Type::Id(variant_id), - span: Default::default(), - }], - result: Some(Type::U64), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - let world = World { - name: "test-world".to_string(), - imports: [].into(), - exports: [( - WorldKey::Name("process-u64-option".to_string()), - WorldItem::Function(func.clone()), - )] - .into(), - docs: Default::default(), - stability: Default::default(), - includes: Default::default(), - span: Default::default(), - package: None, - }; - - let mut sizes = SizeAlign::default(); - sizes.fill(&resolve).expect("sizes should fill"); - let instance = GoIdentifier::public("TestInstance"); - - let config = ExportConfig { - instance: &instance, - world: &world, - resolve: &resolve, - sizes: &sizes, - }; - - let generator = ExportGenerator::new(config); - let mut tokens = Tokens::new(); - generator.generate_function(&func, &mut tokens); - - let generated = tokens.to_string().unwrap(); + let fixture = Fixture::parse( + "package test:fixture; + world test-world { + variant u64-option { some-val(u64), none-val } + export process-u64-option: func(opt: u64-option) -> u64; + }", + ); + let generated = generate(&fixture, "process-u64-option"); println!("Generated u64-option function:\n{}", generated); // VariantLower declares `var variant_1 uint64` for the I64 payload slot. @@ -383,81 +211,20 @@ mod tests { /// Regression test: an export whose parameters flatten to more than the /// canonical ABI's 16 flat params is lowered *indirectly* - the host must /// `cabi_realloc` an area, store each field into it and pass a single - /// pointer. That path emits `Instruction::Malloc`, which used to `todo!()`. + /// pointer. #[test] fn test_export_indirect_params_allocates_with_realloc() { - use wit_bindgen_core::wit_parser::{Field, Record, TypeDef, TypeDefKind, TypeOwner}; - - let mut resolve = Resolve::new(); - // A record with 17 u32 fields flattens to 17 core params, one over the // limit of 16, which forces indirect parameter lowering. - let record_def = TypeDef { - name: Some("wide".to_string()), - kind: TypeDefKind::Record(Record { - fields: (0..17) - .map(|i| Field { - name: format!("field{i}"), - ty: Type::U32, - docs: Default::default(), - span: Default::default(), - }) - .collect(), - }), - owner: TypeOwner::None, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - let record_id = resolve.types.alloc(record_def); - - let func = Function { - name: "take_wide".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "wide".to_string(), - ty: Type::Id(record_id), - span: Default::default(), - }], - result: Some(Type::U32), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - let world = World { - name: "test-world".to_string(), - imports: [].into(), - exports: [( - WorldKey::Name("take-wide".to_string()), - WorldItem::Function(func.clone()), - )] - .into(), - docs: Default::default(), - stability: Default::default(), - includes: Default::default(), - span: Default::default(), - package: None, - }; - - let mut sizes = SizeAlign::default(); - sizes.fill(&resolve).expect("sizes should fill"); - let instance = GoIdentifier::public("TestInstance"); - - let config = ExportConfig { - instance: &instance, - world: &world, - resolve: &resolve, - sizes: &sizes, - }; - - let generator = ExportGenerator::new(config); - let mut tokens = Tokens::new(); - generator.generate_function(&func, &mut tokens); - - let generated = tokens.to_string().unwrap(); + let fields: String = (0..17).map(|i| format!("field{i}: u32, ")).collect(); + let fixture = Fixture::parse(&format!( + "package test:fixture; + world test-world {{ + record wide {{ {fields} }} + export take-wide: func(wide: wide) -> u32; + }}" + )); + let generated = generate(&fixture, "take-wide"); println!("Generated wide-record function:\n{}", generated); // The param area is allocated through the guest's `cabi_realloc` with @@ -473,7 +240,7 @@ mod tests { "indirect params must be stored into the allocated area, got:\n{generated}" ); assert!( - generated.contains("ExportedFunction(\"take_wide\").Call(ctx, uint64(ptr"), + generated.contains("ExportedFunction(\"take-wide\").Call(ctx, uint64(ptr"), "the wasm export must be called with the param area pointer, got:\n{generated}" ); } diff --git a/cmd/gravity/src/codegen/imports.rs b/cmd/gravity/src/codegen/imports.rs index 4f41f875..6c63d6a0 100644 --- a/cmd/gravity/src/codegen/imports.rs +++ b/cmd/gravity/src/codegen/imports.rs @@ -503,53 +503,59 @@ impl<'a> ImportCodeGenerator<'a> { #[cfg(test)] mod tests { use genco::prelude::*; - use wit_bindgen_core::wit_parser::{ - Enum, EnumCase, Function, FunctionKind, Interface, Package, PackageName, Param, Resolve, - SizeAlign, Type, TypeDef, TypeDefKind, TypeOwner, World, WorldId, WorldItem, WorldKey, - }; + use wit_bindgen_core::wit_parser::Type; use crate::{ codegen::{ imports::{ImportAnalyzer, ImportCodeGenerator}, - ir::{AnalyzedImports, InterfaceMethod, Parameter, WitReturn}, + ir::{AnalyzedImports, InterfaceMethod, Parameter, TypeDefinition, WitReturn}, + test_wit::Fixture, }, go::{GoIdentifier, GoType}, }; - #[test] - fn test_wit_type_driven_generation() { - // Create a mock function with string parameter and string return - let func = Function { - name: "test_function".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "input".to_string(), - ty: Type::String, - span: Default::default(), - }], - result: Some(Type::String), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - let resolve = Resolve::new(); - let sizes = SizeAlign::default(); + /// A fixture whose `host` interface declares `functions`, imported by the + /// world `test-world`. + fn host_fixture(functions: &str) -> Fixture { + Fixture::parse(&format!( + "package test:fixture; + interface host {{ + {functions} + }} + world test-world {{ + import host; + }}" + )) + } - // Mock data - let analyzed = AnalyzedImports { + fn empty_analysis() -> AnalyzedImports { + AnalyzedImports { instance_name: GoIdentifier::public("TestInstance"), interfaces: vec![], standalone_functions: vec![], standalone_types: vec![], factory_name: GoIdentifier::public("TestFactory"), constructor_name: GoIdentifier::public("NewTestFactory"), - }; + } + } + + /// Generates the host function builder for `method` with the + /// fixture's resolve and sizes. + fn host_function(fixture: &Fixture, method: &InterfaceMethod) -> String { + let analyzed = empty_analysis(); + let generator = ImportCodeGenerator::new(&fixture.resolve, &analyzed, &fixture.sizes); + let param_name = GoIdentifier::private("handler"); + generator + .generate_host_function_builder(method, ¶m_name) + .to_string() + .unwrap() + } - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); + #[test] + fn test_wit_type_driven_generation() { + let fixture = host_fixture("test-function: func(input: string) -> string;"); let method = InterfaceMethod { - name: "test_function".to_string(), + name: "test-function".to_string(), go_method_name: GoIdentifier::public("TestFunction"), parameters: vec![Parameter { name: GoIdentifier::private("input"), @@ -560,14 +566,11 @@ mod tests { go_type: GoType::String, wit_type: Type::String, }), - wit_function: func, + wit_function: fixture.function("host", "test-function").clone(), }; - let param_name = GoIdentifier::private("handler"); - let result = generator.generate_host_function_builder(&method, ¶m_name); - // The result should contain the WIT type-driven generation - let code_str = result.to_string().unwrap(); + let code_str = host_function(&fixture, &method); assert!(code_str.contains("NewFunctionBuilder")); assert!(code_str.contains("mod.Memory().Read")); assert!(code_str.contains("writeString")); @@ -578,22 +581,9 @@ mod tests { #[test] fn test_different_wit_types() { // Test that different WIT types generate different parameter handling - let analyzed = AnalyzedImports { - instance_name: GoIdentifier::public("TestInstance"), - interfaces: vec![], - standalone_functions: vec![], - standalone_types: vec![], - factory_name: GoIdentifier::public("TestFactory"), - constructor_name: GoIdentifier::public("NewTestFactory"), - }; - let resolve = Resolve::new(); - let sizes = SizeAlign::default(); - - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); - - // Test U32 parameter + let fixture = host_fixture("test-u32: func(value: u32);"); let u32_method = InterfaceMethod { - name: "test_u32".to_string(), + name: "test-u32".to_string(), go_method_name: GoIdentifier::public("TestU32"), parameters: vec![Parameter { name: GoIdentifier::private("value"), @@ -601,27 +591,11 @@ mod tests { wit_type: Type::U32, }], return_type: None, - wit_function: Function { - name: "test_u32".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "value".to_string(), - ty: Type::U32, - span: Default::default(), - }], - result: None, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }, + wit_function: fixture.function("host", "test-u32").clone(), }; - let param_name = GoIdentifier::private("handler"); - let result = generator.generate_host_function_builder(&u32_method, ¶m_name); - // Should have only one uint32 parameter (plus ctx and mod) - let code_str = result.to_string().unwrap(); + let code_str = host_function(&fixture, &u32_method); assert!(code_str.contains("arg0 uint32")); assert!(!code_str.contains("arg1 uint32")); assert!(!code_str.contains("mod.Memory().Read")); // No string reading @@ -635,22 +609,10 @@ mod tests { /// with `todo!()`, which caused a panic at build time. #[test] fn test_import_with_bool_return_type() { - let analyzed = AnalyzedImports { - instance_name: GoIdentifier::public("TestInstance"), - interfaces: vec![], - standalone_functions: vec![], - standalone_types: vec![], - factory_name: GoIdentifier::public("TestFactory"), - constructor_name: GoIdentifier::public("NewTestFactory"), - }; - let resolve = Resolve::new(); - let sizes = SizeAlign::default(); - - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); - // A function returning bool has a single i32 Wasm result + let fixture = host_fixture("is-valid: func(input: string) -> bool;"); let method = InterfaceMethod { - name: "is_valid".to_string(), + name: "is-valid".to_string(), go_method_name: GoIdentifier::public("IsValid"), parameters: vec![Parameter { name: GoIdentifier::private("input"), @@ -661,26 +623,10 @@ mod tests { go_type: GoType::Bool, wit_type: Type::Bool, }), - wit_function: Function { - name: "is_valid".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "input".to_string(), - ty: Type::String, - span: Default::default(), - }], - result: Some(Type::Bool), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }, + wit_function: fixture.function("host", "is-valid").clone(), }; - let param_name = GoIdentifier::private("handler"); - let result = generator.generate_host_function_builder(&method, ¶m_name); - - let code_str = result.to_string().unwrap(); + let code_str = host_function(&fixture, &method); // The host function must declare a uint32 return (Wasm i32 representation of bool) assert!( code_str.contains(") uint32"), @@ -698,48 +644,13 @@ mod tests { /// (`verify: func(bot-id: string, ip: string) -> validator-response`). #[test] fn test_import_with_enum_return_type() { - let mut resolve = Resolve::default(); - - // Create an enum type in the resolve so Type::Id works - let type_id = resolve.types.alloc(TypeDef { - name: Some("status".to_string()), - kind: TypeDefKind::Enum(Enum { - cases: vec![ - EnumCase { - name: "ok".to_string(), - docs: Default::default(), - span: Default::default(), - }, - EnumCase { - name: "error".to_string(), - docs: Default::default(), - span: Default::default(), - }, - ], - }), - owner: TypeOwner::None, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }); - - let sizes = SizeAlign::default(); - - let analyzed = AnalyzedImports { - instance_name: GoIdentifier::public("TestInstance"), - interfaces: vec![], - standalone_functions: vec![], - standalone_types: vec![], - factory_name: GoIdentifier::public("TestFactory"), - constructor_name: GoIdentifier::public("NewTestFactory"), - }; - - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); - // A function returning an enum has a single i32 Wasm result + let fixture = host_fixture( + "enum status { ok, failed } + get-status: func(id: string) -> status;", + ); let method = InterfaceMethod { - name: "get_status".to_string(), + name: "get-status".to_string(), go_method_name: GoIdentifier::public("GetStatus"), parameters: vec![Parameter { name: GoIdentifier::private("id"), @@ -748,28 +659,12 @@ mod tests { }], return_type: Some(WitReturn { go_type: GoType::Uint32, - wit_type: Type::Id(type_id), + wit_type: Type::Id(fixture.type_id("status")), }), - wit_function: Function { - name: "get_status".to_string(), - kind: FunctionKind::Freestanding, - params: vec![Param { - name: "id".to_string(), - ty: Type::String, - span: Default::default(), - }], - result: Some(Type::Id(type_id)), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }, + wit_function: fixture.function("host", "get-status").clone(), }; - let param_name = GoIdentifier::private("handler"); - let result = generator.generate_host_function_builder(&method, ¶m_name); - - let code_str = result.to_string().unwrap(); + let code_str = host_function(&fixture, &method); // The host function must declare a uint32 return (Wasm i32 representation of enum) assert!( code_str.contains(") uint32"), @@ -788,21 +683,9 @@ mod tests { /// the import (host function) pathway, params are already uint32. #[test] fn test_import_u32_params_use_identity_cast() { - let analyzed = AnalyzedImports { - instance_name: GoIdentifier::public("TestInstance"), - interfaces: vec![], - standalone_functions: vec![], - standalone_types: vec![], - factory_name: GoIdentifier::public("TestFactory"), - constructor_name: GoIdentifier::public("NewTestFactory"), - }; - let resolve = Resolve::new(); - let sizes = SizeAlign::default(); - - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); - // A function that takes multiple u32 params — the same pattern as // rate-limit's token-bucket import. + let fixture = host_fixture("compute: func(a: u32, b: u32);"); let method = InterfaceMethod { name: "compute".to_string(), go_method_name: GoIdentifier::public("Compute"), @@ -819,33 +702,10 @@ mod tests { }, ], return_type: None, - wit_function: Function { - name: "compute".to_string(), - kind: FunctionKind::Freestanding, - params: vec![ - Param { - name: "a".to_string(), - ty: Type::U32, - span: Default::default(), - }, - Param { - name: "b".to_string(), - ty: Type::U32, - span: Default::default(), - }, - ], - result: None, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }, + wit_function: fixture.function("host", "compute").clone(), }; - let param_name = GoIdentifier::private("handler"); - let result = generator.generate_host_function_builder(&method, ¶m_name); - - let code_str = result.to_string().unwrap(); + let code_str = host_function(&fixture, &method); // Must use simple uint32() casts, NOT api.DecodeU32() which expects uint64 assert!( !code_str.contains("api.DecodeU32"), @@ -870,42 +730,18 @@ mod tests { /// is a Go syntax error. #[test] fn test_import_zero_params_no_trailing_comma() { - let analyzed = AnalyzedImports { - instance_name: GoIdentifier::public("TestInstance"), - interfaces: vec![], - standalone_functions: vec![], - standalone_types: vec![], - factory_name: GoIdentifier::public("TestFactory"), - constructor_name: GoIdentifier::public("NewTestFactory"), - }; - let resolve = Resolve::new(); - let sizes = SizeAlign::default(); - - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); - // A function with no WIT parameters — only ctx and mod should appear // in the generated Go host function signature. + let fixture = host_fixture("ping: func();"); let method = InterfaceMethod { name: "ping".to_string(), go_method_name: GoIdentifier::public("Ping"), parameters: vec![], return_type: None, - wit_function: Function { - name: "ping".to_string(), - kind: FunctionKind::Freestanding, - params: vec![], - result: None, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }, + wit_function: fixture.function("host", "ping").clone(), }; - let param_name = GoIdentifier::private("handler"); - let result = generator.generate_host_function_builder(&method, ¶m_name); - - let code_str = result.to_string().unwrap(); + let code_str = host_function(&fixture, &method); // Must NOT contain a bare comma on its own line (the symptom of the bug) assert!( !code_str.contains(",\n\t\t,"), @@ -927,43 +763,19 @@ mod tests { /// exercises both the zero-param fix and the result-type fix together. #[test] fn test_import_zero_params_with_return_type() { - let analyzed = AnalyzedImports { - instance_name: GoIdentifier::public("TestInstance"), - interfaces: vec![], - standalone_functions: vec![], - standalone_types: vec![], - factory_name: GoIdentifier::public("TestFactory"), - constructor_name: GoIdentifier::public("NewTestFactory"), - }; - let resolve = Resolve::new(); - let sizes = SizeAlign::default(); - - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); - + let fixture = host_fixture("is-ready: func() -> bool;"); let method = InterfaceMethod { - name: "is_ready".to_string(), + name: "is-ready".to_string(), go_method_name: GoIdentifier::public("IsReady"), parameters: vec![], return_type: Some(WitReturn { go_type: GoType::Bool, wit_type: Type::Bool, }), - wit_function: Function { - name: "is_ready".to_string(), - kind: FunctionKind::Freestanding, - params: vec![], - result: Some(Type::Bool), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }, + wit_function: fixture.function("host", "is-ready").clone(), }; - let param_name = GoIdentifier::private("handler"); - let result = generator.generate_host_function_builder(&method, ¶m_name); - - let code_str = result.to_string().unwrap(); + let code_str = host_function(&fixture, &method); // Must not have consecutive commas assert!( !code_str.contains(",\n\t\t,") && !code_str.contains(", ,"), @@ -981,83 +793,22 @@ mod tests { ); } - fn create_test_world_with_interface() -> (Resolve, WorldId) { - let mut resolve = Resolve::default(); - - // Create a package - let package_name = PackageName { - namespace: "test".to_string(), - name: "pkg".to_string(), - version: None, - }; - let package_id = resolve.packages.alloc(Package { - name: package_name.clone(), - interfaces: Default::default(), - worlds: Default::default(), - docs: Default::default(), - }); - - // Create an interface with a function - let interface_id = resolve.interfaces.alloc(Interface { - name: Some("logger".to_string()), - package: Some(package_id), - functions: [( - "log".to_string(), - Function { - name: "log".to_string(), - params: vec![Param { - name: "message".to_string(), - ty: Type::String, - span: Default::default(), - }], - result: None, - kind: FunctionKind::Freestanding, - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }, - )] - .into(), - types: Default::default(), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - clone_of: None, - }); - - // Create a world with the interface as import - let world = World { - name: "test-world".to_string(), - imports: [( - WorldKey::Name("logger".to_string()), - WorldItem::Interface { - id: interface_id, - stability: Default::default(), - span: Default::default(), - docs: Default::default(), - external_id: None, - }, - )] - .into(), - exports: Default::default(), - docs: Default::default(), - stability: Default::default(), - package: Some(package_id), - includes: Default::default(), - span: Default::default(), - }; - - let world_id = resolve.worlds.alloc(world); - (resolve, world_id) + fn logger_fixture() -> Fixture { + Fixture::parse( + "package test:pkg; + interface logger { + log: func(message: string); + } + world test-world { + import logger; + }", + ) } #[test] fn test_import_analyzer() { - let (resolve, world_id) = create_test_world_with_interface(); - let world = &resolve.worlds[world_id]; - - let analyzer = ImportAnalyzer::new(&resolve, &world); + let fixture = logger_fixture(); + let analyzer = ImportAnalyzer::new(&fixture.resolve, fixture.world()); let analyzed = analyzer.analyze(); // Check that we got one interface @@ -1077,16 +828,14 @@ mod tests { #[test] fn test_import_code_generator() { - let (resolve, world_id) = create_test_world_with_interface(); - let world = &resolve.worlds[world_id]; - let sizes = SizeAlign::default(); + let fixture = logger_fixture(); // Analyze - let analyzer = ImportAnalyzer::new(&resolve, &world); + let analyzer = ImportAnalyzer::new(&fixture.resolve, fixture.world()); let analyzed = analyzer.analyze(); // Generate - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); + let generator = ImportCodeGenerator::new(&fixture.resolve, &analyzed, &fixture.sizes); let mut tokens = Tokens::::new(); generator.format_into(&mut tokens); @@ -1097,154 +846,35 @@ mod tests { #[test] fn test_record_type_generation() { - use crate::codegen::ir::TypeDefinition; - use wit_bindgen_core::wit_parser::{Field, Record, TypeDef, TypeDefKind, TypeOwner}; - - let mut resolve = Resolve::default(); - - // Create a package - let package_name = PackageName { - namespace: "test".to_string(), - name: "records".to_string(), - version: None, - }; - let package_id = resolve.packages.alloc(Package { - name: package_name.clone(), - interfaces: Default::default(), - worlds: Default::default(), - docs: Default::default(), - }); - - // Create a record type similar to the "foo" record - let record_def = Record { - fields: vec![ - Field { - name: "float32".to_string(), - ty: Type::F32, - docs: Default::default(), - span: Default::default(), - }, - Field { - name: "float64".to_string(), - ty: Type::F64, - docs: Default::default(), - span: Default::default(), - }, - Field { - name: "uint32".to_string(), - ty: Type::U32, - docs: Default::default(), - span: Default::default(), - }, - Field { - name: "uint64".to_string(), - ty: Type::U64, - docs: Default::default(), - span: Default::default(), - }, - Field { - name: "s".to_string(), - ty: Type::String, - docs: Default::default(), - span: Default::default(), - }, - ], - }; - - // Create an interface that will own this type - let interface_id = resolve.interfaces.alloc(Interface { - name: Some("types".to_string()), - package: Some(package_id), - functions: Default::default(), - types: Default::default(), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - clone_of: None, - }); - - // Create the TypeDef for the record with proper owner - let type_def = TypeDef { - name: Some("foo".to_string()), - kind: TypeDefKind::Record(record_def), - owner: TypeOwner::Interface(interface_id), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - let type_id = resolve.types.alloc(type_def); - - // Add the type to the interface - resolve.interfaces[interface_id] - .types - .insert("foo".to_string(), type_id); - - // Create a world that imports this interface - let world = World { - name: "test-world".to_string(), - imports: [( - WorldKey::Name("types".to_string()), - WorldItem::Interface { - id: interface_id, - stability: Default::default(), - span: Default::default(), - docs: Default::default(), - external_id: None, - }, - )] - .into(), - exports: Default::default(), - docs: Default::default(), - stability: Default::default(), - package: Some(package_id), - includes: Default::default(), - span: Default::default(), - }; - - let world_id = resolve.worlds.alloc(world); - let world = &resolve.worlds[world_id]; - - // Test the analyzer first - let analyzer = ImportAnalyzer::new(&resolve, &world); + let fixture = Fixture::parse( + "package test:records; + interface types { + record foo { + float32: f32, + float64: f64, + uint32: u32, + uint64: u64, + s: string, + } + } + world test-world { + import types; + }", + ); + let analyzer = ImportAnalyzer::new(&fixture.resolve, fixture.world()); // Test analyze_type_definition directly with the record kind - let type_def = &resolve.types[type_id]; + let type_def = &fixture.resolve.types[fixture.type_id("foo")]; let analyzed_definition = analyzer.analyze_type_definition(&type_def.kind).unwrap(); - println!( - "Direct analysis of type definition: {:?}", - analyzed_definition - ); - // This should be a Record, not an Alias match &analyzed_definition { - TypeDefinition::Record { fields } => { - println!( - "✓ Correctly identified as Record with {} fields", - fields.len() - ); - assert_eq!(fields.len(), 5); - } - TypeDefinition::Alias { target } => { - panic!( - "❌ Incorrectly identified as Alias with target: {:?}", - target - ); - } - other => { - panic!("❌ Unexpected type definition: {:?}", other); - } + TypeDefinition::Record { fields } => assert_eq!(fields.len(), 5), + other => panic!("expected a Record, got: {other:?}"), } - // Test full analysis - let analyzed = analyzer.analyze(); - println!("Full analysis result:"); - println!(" Interfaces: {}", analyzed.interfaces.len()); - println!(" Standalone types: {}", analyzed.standalone_types.len()); - // Check analysis results + let analyzed = analyzer.analyze(); assert_eq!(analyzed.interfaces.len(), 1); let interface = &analyzed.interfaces[0]; assert_eq!(interface.name, "types"); @@ -1255,221 +885,62 @@ mod tests { // would collide with another concrete type in the same world. The // test world's `foo` is unique, so it stays flat. assert_eq!(analyzed_type.name, "foo"); - println!("Analyzed type definition: {:?}", analyzed_type.definition); // This is the key assertion - it should be a Record, not an Alias match &analyzed_type.definition { TypeDefinition::Record { fields } => { - println!( - "✓ Analysis correctly produced Record with {} fields", - fields.len() - ); assert_eq!(fields.len(), 5); // Check that field names are correct let field_names: Vec = fields.iter().map(|(name, _)| String::from(name)).collect(); - println!("Field names: {:?}", field_names); - assert!(field_names.contains(&"Float32".to_string())); assert!(field_names.contains(&"Float64".to_string())); assert!(field_names.contains(&"Uint32".to_string())); assert!(field_names.contains(&"Uint64".to_string())); assert!(field_names.contains(&"S".to_string())); } - TypeDefinition::Alias { target } => { - panic!( - "❌ Analysis incorrectly produced Alias with target: {:?}", - target - ); - } - other => { - panic!( - "❌ Analysis produced unexpected type definition: {:?}", - other - ); - } + other => panic!("expected a Record, got: {other:?}"), } - // Test code generation - let sizes = SizeAlign::default(); - let generator = ImportCodeGenerator::new(&resolve, &analyzed, &sizes); + // Generating the record must not produce the self-referential alias + // `type Foo Foo`. + let generator = ImportCodeGenerator::new(&fixture.resolve, &analyzed, &fixture.sizes); let mut tokens = Tokens::::new(); generator.format_into(&mut tokens); - let output = tokens.to_string().unwrap(); - println!("\nGenerated code:\n{}", output); - println!("Generated code length: {}", output.len()); - - // Debug: let's see what's actually in the analyzed data that's being passed to the generator - println!("\nDebug - what's being passed to generator:"); - println!(" analyzed.interfaces.len(): {}", analyzed.interfaces.len()); - println!( - " analyzed.standalone_types.len(): {}", - analyzed.standalone_types.len() + assert!( + !output.contains("type Foo Foo"), + "generated a self-referential alias, got:\n{output}" ); - - for (i, interface) in analyzed.interfaces.iter().enumerate() { - println!( - " Interface {}: name='{}', types.len()={}", - i, - interface.name, - interface.types.len() - ); - for (j, typ) in interface.types.iter().enumerate() { - println!( - " Type {}: name='{}', definition={:?}", - j, typ.name, typ.definition - ); - } - } - - for (i, typ) in analyzed.standalone_types.iter().enumerate() { - println!( - " Standalone type {}: name='{}', definition={:?}", - i, typ.name, typ.definition - ); - } - - // The issue: types are in interface.types but generator only looks at standalone_types - // Let's see if we can find where types should be moved to standalone_types - - // Expected behavior: Should generate "type Foo struct {" not "type Foo Foo" - if output.contains("type Foo Foo") { - panic!( - "❌ Generated incorrect alias: 'type Foo Foo' - this creates infinite recursion!" - ); - } - - if !output.contains("type Foo struct") && analyzed.interfaces[0].types.len() > 0 { - println!( - "❌ Generated code doesn't contain struct definition, but types were analyzed correctly" - ); - println!("This suggests the code generator isn't processing interface types properly"); - // This is the actual bug - the generator doesn't handle interface types - } - - // For now, let's just verify the analysis is correct (the generation bug is separate) - println!("✓ Test completed - analysis is working correctly"); } #[test] fn test_record_vs_alias_analysis() { - use crate::codegen::ir::TypeDefinition; - use wit_bindgen_core::wit_parser::{Field, Record, TypeDef, TypeDefKind, TypeOwner}; - - let mut resolve = Resolve::default(); - - // Create a package - let package_name = PackageName { - namespace: "test".to_string(), - name: "types".to_string(), - version: None, - }; - let package_id = resolve.packages.alloc(Package { - name: package_name.clone(), - interfaces: Default::default(), - worlds: Default::default(), - docs: Default::default(), - }); - - let interface_id = resolve.interfaces.alloc(Interface { - name: Some("types".to_string()), - package: Some(package_id), - functions: Default::default(), - types: Default::default(), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - clone_of: None, - }); - - // Test 1: Create a proper record type - let record_def = Record { - fields: vec![Field { - name: "x".to_string(), - ty: Type::U32, - docs: Default::default(), - span: Default::default(), - }], - }; - - let record_type_def = TypeDef { - name: Some("my_record".to_string()), - kind: TypeDefKind::Record(record_def), - owner: TypeOwner::Interface(interface_id), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - // Test 2: Create a type alias - let alias_type_def = TypeDef { - name: Some("my_alias".to_string()), - kind: TypeDefKind::Type(Type::String), - owner: TypeOwner::Interface(interface_id), - docs: Default::default(), - stability: Default::default(), - span: Default::default(), - external_id: None, - }; - - let record_type_id = resolve.types.alloc(record_type_def); - let alias_type_id = resolve.types.alloc(alias_type_def); - - let world = World { - name: "test-world".to_string(), - imports: [( - WorldKey::Name("types".to_string()), - WorldItem::Interface { - id: interface_id, - stability: Default::default(), - span: Default::default(), - docs: Default::default(), - external_id: None, - }, - )] - .into(), - exports: Default::default(), - docs: Default::default(), - stability: Default::default(), - package: Some(package_id), - includes: Default::default(), - span: Default::default(), - }; - - let world_id = resolve.worlds.alloc(world); - let world = &resolve.worlds[world_id]; - - let analyzer = ImportAnalyzer::new(&resolve, &world); + let fixture = Fixture::parse( + "package test:types; + interface types { + record my-record { x: u32 } + type my-alias = string; + } + world test-world { + import types; + }", + ); + let analyzer = ImportAnalyzer::new(&fixture.resolve, fixture.world()); // Test record analysis - let record_def = &resolve.types[record_type_id]; - let record_analysis = analyzer.analyze_type_definition(&record_def.kind).unwrap(); - - match record_analysis { - TypeDefinition::Record { .. } => { - println!("✓ Record correctly analyzed as Record"); - } - other => { - panic!("❌ Record incorrectly analyzed as: {:?}", other); - } + let record_def = &fixture.resolve.types[fixture.type_id("my-record")]; + match analyzer.analyze_type_definition(&record_def.kind).unwrap() { + TypeDefinition::Record { .. } => {} + other => panic!("record analyzed as: {other:?}"), } // Test alias analysis - let alias_def = &resolve.types[alias_type_id]; - let alias_analysis = analyzer.analyze_type_definition(&alias_def.kind).unwrap(); - - match alias_analysis { - TypeDefinition::Alias { .. } => { - println!("✓ Alias correctly analyzed as Alias"); - } - other => { - panic!("❌ Alias incorrectly analyzed as: {:?}", other); - } + let alias_def = &fixture.resolve.types[fixture.type_id("my-alias")]; + match analyzer.analyze_type_definition(&alias_def.kind).unwrap() { + TypeDefinition::Alias { .. } => {} + other => panic!("alias analyzed as: {other:?}"), } - - println!("✓ Both record and alias types analyzed correctly"); } } diff --git a/cmd/gravity/src/codegen/mod.rs b/cmd/gravity/src/codegen/mod.rs index 3bb74337..c0f94a60 100644 --- a/cmd/gravity/src/codegen/mod.rs +++ b/cmd/gravity/src/codegen/mod.rs @@ -4,6 +4,8 @@ mod factory; mod func; mod imports; mod ir; +#[cfg(test)] +mod test_wit; mod wasm; pub use bindings::*; diff --git a/cmd/gravity/src/codegen/test_wit.rs b/cmd/gravity/src/codegen/test_wit.rs new file mode 100644 index 00000000..e2e03f87 --- /dev/null +++ b/cmd/gravity/src/codegen/test_wit.rs @@ -0,0 +1,68 @@ +//! Builds `wit_parser` fixtures for unit tests from WIT text, so the tests do +//! not construct wit-parser's structs field by field. + +use wit_bindgen_core::wit_parser::{ + Function, Resolve, SizeAlign, TypeId, World, WorldId, WorldItem, WorldKey, +}; + +pub struct Fixture { + pub resolve: Resolve, + pub world_id: WorldId, + pub sizes: SizeAlign, +} + +impl Fixture { + /// Parses `wit`, which must declare a package and exactly one world. + pub fn parse(wit: &str) -> Self { + let mut resolve = Resolve::default(); + let package = resolve + .push_source("fixture.wit", wit) + .expect("fixture WIT should parse"); + let world_id = resolve + .select_world(&[package], None) + .expect("fixture should declare one world"); + let mut sizes = SizeAlign::default(); + sizes.fill(&resolve).expect("sizes should fill"); + Self { + resolve, + world_id, + sizes, + } + } + + pub fn world(&self) -> &World { + &self.resolve.worlds[self.world_id] + } + + /// The function the world exports as `name`. + pub fn export(&self, name: &str) -> &Function { + match self.world().exports.get(&WorldKey::Name(name.to_string())) { + Some(WorldItem::Function(func)) => func, + other => panic!("world should export function {name}, found {other:?}"), + } + } + + /// The function `name` declared in interface `interface`. + pub fn function(&self, interface: &str, name: &str) -> &Function { + let (_, iface) = self + .resolve + .interfaces + .iter() + .find(|(_, iface)| iface.name.as_deref() == Some(interface)) + .unwrap_or_else(|| panic!("fixture should declare interface {interface}")); + iface + .functions + .get(name) + .unwrap_or_else(|| panic!("interface {interface} should declare function {name}")) + } + + /// The type named `name`, wherever it is declared. + pub fn type_id(&self, name: &str) -> TypeId { + self.resolve + .types + .iter() + .find(|(_, typ)| typ.name.as_deref() == Some(name)) + .map(|(id, _)| id) + .unwrap_or_else(|| panic!("fixture should declare type {name}")) + } +} From b93f6d9f13750f2bcea3ee9f6282754764b39b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B6Rei?= <212411920+arcjet-rei@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:08:12 -0700 Subject: [PATCH 2/3] style: format the export test assertion Co-Authored-By: Claude Opus 5.5 --- cmd/gravity/src/codegen/exports.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/gravity/src/codegen/exports.rs b/cmd/gravity/src/codegen/exports.rs index c40a20e8..a3c18425 100644 --- a/cmd/gravity/src/codegen/exports.rs +++ b/cmd/gravity/src/codegen/exports.rs @@ -135,8 +135,10 @@ mod tests { // Verify function body assert!(generated.contains("arg0 := value")); - assert!(generated - .contains("i.module.ExportedFunction(\"add-number\").Call(ctx, uint64(result0))")); + assert!( + generated + .contains("i.module.ExportedFunction(\"add-number\").Call(ctx, uint64(result0))") + ); assert!(generated.contains("if err1 != nil {")); assert!(generated.contains("panic(err1)")); assert!(generated.contains("results1 := raw1[0]")); From 4555e86a2294a0ae7c8f4ac205c2be6c22a63252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B6Rei?= <212411920+arcjet-rei@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:26:43 -0700 Subject: [PATCH 3/3] test: require a unique name in Fixture::type_id type_id returned the first type with a matching name, so a fixture that declared the same name in two interfaces would get whichever came first. It now panics unless exactly one type has the name, and a test covers the ambiguous case. Co-Authored-By: Claude Opus 5.5 --- cmd/gravity/src/codegen/test_wit.rs | 40 ++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/cmd/gravity/src/codegen/test_wit.rs b/cmd/gravity/src/codegen/test_wit.rs index e2e03f87..8641a61e 100644 --- a/cmd/gravity/src/codegen/test_wit.rs +++ b/cmd/gravity/src/codegen/test_wit.rs @@ -56,13 +56,45 @@ impl Fixture { .unwrap_or_else(|| panic!("interface {interface} should declare function {name}")) } - /// The type named `name`, wherever it is declared. + /// The type named `name`. Panics unless exactly one type in the fixture + /// has that name, so a fixture that declares it in two interfaces fails + /// instead of returning whichever comes first. pub fn type_id(&self, name: &str) -> TypeId { - self.resolve + let matches: Vec = self + .resolve .types .iter() - .find(|(_, typ)| typ.name.as_deref() == Some(name)) + .filter(|(_, typ)| typ.name.as_deref() == Some(name)) .map(|(id, _)| id) - .unwrap_or_else(|| panic!("fixture should declare type {name}")) + .collect(); + match matches.as_slice() { + [id] => *id, + [] => panic!("fixture should declare type {name}"), + _ => panic!("fixture declares {} types named {name}", matches.len()), + } + } +} + +#[cfg(test)] +mod tests { + use super::Fixture; + + #[test] + #[should_panic(expected = "fixture declares 2 types named point")] + fn test_type_id_rejects_ambiguous_name() { + let fixture = Fixture::parse( + "package test:fixture; + interface a { + record point { x: u32 } + } + interface b { + record point { y: u32 } + } + world test-world { + import a; + import b; + }", + ); + fixture.type_id("point"); } }