From 3d14813219c39f5295bda7923ebfe8ccf3abad0b Mon Sep 17 00:00:00 2001 From: robfischer1 <15163713+robfischer1@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:27:57 -0400 Subject: [PATCH 1/4] fix!: name each enum case constant for its enum WIT scopes a case name to its enum; gravity emitted every case as an unqualified package constant. Two enums sharing a case name, or a case sharing a type's name, then redeclared an identifier and the bindings failed go vet: enum_collisions.go:41:2: PeerSvid redeclared in this block The identity lift had to spell its `via` cases `via-peer-svid`, ... to dodge `credential-kind`'s `peer-svid`. Every case constant is now the enum's Go name followed by the case's (ViaPeerSvid, CredentialKindPeerSvid, ShapePoint beside the record Point), as Go spells an enumerated constant (http.MethodGet). The enum's qualified name is already unique (#197), so its constants are too. BREAKING: every enum constant is renamed (Red becomes ColorRed). The upstream examples' tests are updated, and the regressions WIT drops the comment that called constant qualification out of scope. Qualifying only colliding names, as #197 does for types, was rejected: a new enum would then rename an existing enum's constants. Tests: imports::tests::test_enum_case_constants_are_named_for_their_enum (fails without the fix) and the new enum-collisions example (TestSharedCaseNamesStayDistinct, TestCaseNamedLikeAType; without the fix go vet fails with the error above). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 + cmd/gravity/src/codegen/func.rs | 7 +- cmd/gravity/src/codegen/imports.rs | 43 ++- cmd/gravity/src/lib.rs | 13 + cmd/gravity/tests/cmd/enum-collisions.stderr | 0 cmd/gravity/tests/cmd/enum-collisions.stdout | 251 ++++++++++++++++++ cmd/gravity/tests/cmd/enum-collisions.toml | 2 + cmd/gravity/tests/cmd/instructions.stdout | 12 +- cmd/gravity/tests/cmd/memory.stdout | 42 +-- cmd/gravity/tests/cmd/regressions.stdout | 36 +-- examples/enum-collisions/Cargo.toml | 11 + .../enum-collisions/enum_collisions_test.go | 51 ++++ examples/enum-collisions/src/lib.rs | 24 ++ .../enum-collisions/wit/enum-collisions.wit | 22 ++ examples/generate.go | 2 + examples/instructions/instructions_test.go | 6 +- examples/memory/memory_test.go | 6 +- examples/regressions/regressions_test.go | 24 +- examples/regressions/wit/regressions.wit | 7 +- 19 files changed, 496 insertions(+), 71 deletions(-) create mode 100644 cmd/gravity/tests/cmd/enum-collisions.stderr create mode 100644 cmd/gravity/tests/cmd/enum-collisions.stdout create mode 100644 cmd/gravity/tests/cmd/enum-collisions.toml create mode 100644 examples/enum-collisions/Cargo.toml create mode 100644 examples/enum-collisions/enum_collisions_test.go create mode 100644 examples/enum-collisions/src/lib.rs create mode 100644 examples/enum-collisions/wit/enum-collisions.wit diff --git a/Cargo.lock b/Cargo.lock index 2a0cae2a..18b25cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,14 @@ dependencies = [ "wit-component", ] +[[package]] +name = "example-enum-collisions" +version = "0.0.2" +dependencies = [ + "wit-bindgen", + "wit-component", +] + [[package]] name = "example-iface-method-returns-string" version = "0.0.2" diff --git a/cmd/gravity/src/codegen/func.rs b/cmd/gravity/src/codegen/func.rs index 06ad37bb..1a27e7bf 100644 --- a/cmd/gravity/src/codegen/func.rs +++ b/cmd/gravity/src/codegen/func.rs @@ -1048,14 +1048,15 @@ impl Bindgen for Func<'_> { } } } - Instruction::EnumLower { enum_, .. } => { + Instruction::EnumLower { enum_, ty, .. } => { + let name = crate::qualified_type_name(*ty, resolve); let value = &operands[0]; let tmp = self.tmp(); let enum_tmp = &format!("enum{tmp}"); let mut cases: Tokens = Tokens::new(); for (i, case) in enum_.cases.iter().enumerate() { - let case_name = GoIdentifier::public(case.name.clone()); + let case_name = GoIdentifier::public(crate::enum_case_name(&name, &case.name)); quote_in! { cases => $['\r'] case $case_name: @@ -1498,7 +1499,7 @@ impl Bindgen for Func<'_> { let mut cases: Tokens = Tokens::new(); for (i, case) in enum_.cases.iter().enumerate() { - let case_name = GoIdentifier::public(case.name.clone()); + let case_name = GoIdentifier::public(crate::enum_case_name(&name, &case.name)); quote_in! { cases => $['\r'] case $i: diff --git a/cmd/gravity/src/codegen/imports.rs b/cmd/gravity/src/codegen/imports.rs index f3d28b9a..5a9f39af 100644 --- a/cmd/gravity/src/codegen/imports.rs +++ b/cmd/gravity/src/codegen/imports.rs @@ -381,7 +381,9 @@ impl<'a> ImportCodeGenerator<'a> { let enum_type = &GoIdentifier::private(&typ.name); let enum_interface = &typ.go_type_name; let enum_function = &GoIdentifier::private(format!("is-{}", &typ.name)); - let variants = cases.iter().map(GoIdentifier::public); + let variants = cases + .iter() + .map(|case| GoIdentifier::public(crate::enum_case_name(&typ.name, case))); quote_in! { *tokens => $['\n'] type $(enum_interface) interface { @@ -940,4 +942,43 @@ mod tests { other => panic!("alias analyzed as: {other:?}"), } } + + /// Regression test: enum case constants are named for their enum. WIT + /// scopes a case to its enum, and unqualified constants made two enums + /// sharing a case name, or a case sharing a type's name, redeclare an + /// identifier. + #[test] + fn test_enum_case_constants_are_named_for_their_enum() { + let fixture = Fixture::parse( + "package test:fixture; + interface types { + enum via { peer-svid, anonymous } + enum credential-kind { peer-svid, bearer } + } + world test-world { + import types; + }", + ); + let analyzed = ImportAnalyzer::new(&fixture.resolve, fixture.world()).analyze(); + let generator = ImportCodeGenerator::new(&fixture.resolve, &analyzed, &fixture.sizes); + let mut tokens = Tokens::new(); + generator.format_into(&mut tokens); + let generated = tokens.to_string().unwrap(); + + for constant in [ + "ViaPeerSvid via = iota", + "ViaAnonymous via = iota", + "CredentialKindPeerSvid credentialKind = iota", + "CredentialKindBearer credentialKind = iota", + ] { + assert!( + generated.contains(constant), + "expected `{constant}`, got:\n{generated}" + ); + } + assert!( + !generated.contains("\n PeerSvid "), + "no case constant may be unqualified, got:\n{generated}" + ); + } } diff --git a/cmd/gravity/src/lib.rs b/cmd/gravity/src/lib.rs index 58d095ce..60049b7a 100644 --- a/cmd/gravity/src/lib.rs +++ b/cmd/gravity/src/lib.rs @@ -91,6 +91,19 @@ pub fn qualified_type_name(type_id: TypeId, resolve: &Resolve) -> String { } } +/// The kebab-case name of the Go constant for case `case` of the enum +/// named `enum_name` (its `qualified_type_name`): the enum's name, then the +/// case's. +/// +/// Every case is namespaced by its enum, as Go's own convention spells an +/// enumerated constant (`http.MethodGet`). WIT scopes a case name to its +/// enum, so two enums may share one (`via { peer-svid }` and +/// `credential-kind { peer-svid }`), and a case may share a type's name; an +/// unqualified constant redeclared the first and shadowed the second. +pub fn enum_case_name(enum_name: &str, case: &str) -> String { + format!("{enum_name}-{case}") +} + /// Resolves a Wasm type to a Go type. pub fn resolve_wasm_type(typ: &WasmType) -> GoType { match typ { diff --git a/cmd/gravity/tests/cmd/enum-collisions.stderr b/cmd/gravity/tests/cmd/enum-collisions.stderr new file mode 100644 index 00000000..e69de29b diff --git a/cmd/gravity/tests/cmd/enum-collisions.stdout b/cmd/gravity/tests/cmd/enum-collisions.stdout new file mode 100644 index 00000000..9753198d --- /dev/null +++ b/cmd/gravity/tests/cmd/enum-collisions.stdout @@ -0,0 +1,251 @@ +// Code generated by arcjet-gravity; DO NOT EDIT. + +package enum_collisions + +import "context" +import "errors" +import "github.com/tetratelabs/wazero" +import "github.com/tetratelabs/wazero/api" + +import _ "embed" + +//go:embed enum_collisions.wasm +var wasmFileEnumCollisions []byte + +type IEnumCollisionsTypes interface {} + +type Via interface { + isVia() +} + +type via int + +func (via) isVia() {} + +const ( + ViaPeerSvid via = iota + ViaSignedToken via = iota + ViaAnonymous via = iota +) + +type CredentialKind interface { + isCredentialKind() +} + +type credentialKind int + +func (credentialKind) isCredentialKind() {} + +const ( + CredentialKindPeerSvid credentialKind = iota + CredentialKindSignedToken credentialKind = iota + CredentialKindBearer credentialKind = iota +) + +type Point struct { + X int32 + Y int32 +} + +type Shape interface { + isShape() +} + +type shape int + +func (shape) isShape() {} + +const ( + ShapePoint shape = iota + ShapeLine shape = iota +) + +type EnumCollisionsFactory struct { + runtime wazero.Runtime + module wazero.CompiledModule +} + +func NewEnumCollisionsFactory( + ctx context.Context, + types IEnumCollisionsTypes, +) (*EnumCollisionsFactory, error) { + wazeroRuntime := wazero.NewRuntime(ctx) + + _, err0 := wazeroRuntime.NewHostModuleBuilder("gravity:enum-collisions/types"). + Instantiate(ctx) + if err0 != nil { + return nil, err0 + } + + // Compiling the module takes a LONG time, so we want to do it once and hold + // onto it with the Runtime + module, err := wazeroRuntime.CompileModule(ctx, wasmFileEnumCollisions) + if err != nil { + return nil, err + } + return &EnumCollisionsFactory{ + runtime: wazeroRuntime, + module: module, + }, nil +} + +func (f *EnumCollisionsFactory) Instantiate(ctx context.Context) (*EnumCollisionsInstance, error) { + if module, err := f.runtime.InstantiateModule(ctx, f.module, wazero.NewModuleConfig()); err != nil { + return nil, err + } else { + return &EnumCollisionsInstance{module}, nil + } +} + +func (f *EnumCollisionsFactory) Close(ctx context.Context) { + f.runtime.Close(ctx) +} + +type EnumCollisionsInstance struct { + module api.Module +} + +func (i *EnumCollisionsInstance) Close(ctx context.Context) error { + if err := i.module.Close(ctx); err != nil { + return err + } + + return nil +} + +// writeString will put a Go string into the Wasm memory following the Component +// Model calling conventions, such as allocating memory with the realloc function +func writeString( + ctx context.Context, + s string, + memory api.Memory, + realloc api.Function, +) (uint64, uint64, error) { + if len(s) == 0 { + return 1, 0, nil + } + + results, err := realloc.Call(ctx, 0, 0, 1, uint64(len(s))) + if err != nil { + return 1, 0, err + } + ptr := results[0] + ok := memory.Write(uint32(ptr), []byte(s)) + if !ok { + return 1, 0, errors.New("failed to write string to wasm memory") + } + return uint64(ptr), uint64(len(s)), nil +} + +func (i *EnumCollisionsInstance) ViaFor( + ctx context.Context, + k CredentialKind, +) Via { + arg0 := k + var enum0 uint32 + switch arg0 { + case CredentialKindPeerSvid: + enum0 = 0 + case CredentialKindSignedToken: + enum0 = 1 + case CredentialKindBearer: + enum0 = 2 + default: + panic(errors.New("invalid enum type provided")) + } + raw1, err1 := i.module.ExportedFunction("via-for").Call(ctx, uint64(enum0)) + // The return type doesn't contain an error so we panic if one is encountered + if err1 != nil { + panic(err1) + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_via-for"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + var enum2 Via + switch results1 { + case 0: + enum2 = ViaPeerSvid + case 1: + enum2 = ViaSignedToken + case 2: + enum2 = ViaAnonymous + default: + // The return type doesn't contain an error so we panic if one is encountered + panic(errors.New("invalid via discriminant")) + } + return enum2 +} + +func (i *EnumCollisionsInstance) Describe( + ctx context.Context, + s Shape, + p Point, +) string { + arg0 := s + arg1 := p + var enum0 uint32 + switch arg0 { + case ShapePoint: + enum0 = 0 + case ShapeLine: + enum0 = 1 + default: + panic(errors.New("invalid enum type provided")) + } + x1 := arg1.X + y1 := arg1.Y + value2 := uint32(x1) + value3 := uint32(y1) + raw4, err4 := i.module.ExportedFunction("describe").Call(ctx, uint64(enum0), uint64(value2), uint64(value3)) + // The return type doesn't contain an error so we panic if one is encountered + if err4 != nil { + panic(err4) + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_describe"); postFn != nil { + if _, err := postFn.Call(ctx, raw4...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results4 := raw4[0] + ptr5, ok5 := i.module.Memory().ReadUint32Le(uint32(results4 + 0)) + // The return type doesn't contain an error so we panic if one is encountered + if !ok5 { + panic(errors.New("failed to read pointer from memory")) + } + len6, ok6 := i.module.Memory().ReadUint32Le(uint32(results4 + 4)) + // The return type doesn't contain an error so we panic if one is encountered + if !ok6 { + panic(errors.New("failed to read length from memory")) + } + buf7, ok7 := i.module.Memory().Read(ptr5, len6) + // The return type doesn't contain an error so we panic if one is encountered + if !ok7 { + panic(errors.New("failed to read bytes from memory")) + } + str7 := string(buf7) + return str7 +} + diff --git a/cmd/gravity/tests/cmd/enum-collisions.toml b/cmd/gravity/tests/cmd/enum-collisions.toml new file mode 100644 index 00000000..eadc32bc --- /dev/null +++ b/cmd/gravity/tests/cmd/enum-collisions.toml @@ -0,0 +1,2 @@ +bin.name = "gravity" +args = "--world enum-collisions ../../target/wasm32-unknown-unknown/release/example_enum_collisions.wasm" diff --git a/cmd/gravity/tests/cmd/instructions.stdout b/cmd/gravity/tests/cmd/instructions.stdout index 02e9465e..accddc81 100644 --- a/cmd/gravity/tests/cmd/instructions.stdout +++ b/cmd/gravity/tests/cmd/instructions.stdout @@ -21,9 +21,9 @@ type enumValues int func (enumValues) isEnumValues() {} const ( - One enumValues = iota - Two enumValues = iota - Three enumValues = iota + EnumValuesOne enumValues = iota + EnumValuesTwo enumValues = iota + EnumValuesThree enumValues = iota ) type WideParams struct { @@ -258,11 +258,11 @@ func (i *InstructionsInstance) EnumInput( arg0 := val var enum0 uint32 switch arg0 { - case One: + case EnumValuesOne: enum0 = 0 - case Two: + case EnumValuesTwo: enum0 = 1 - case Three: + case EnumValuesThree: enum0 = 2 default: panic(errors.New("invalid enum type provided")) diff --git a/cmd/gravity/tests/cmd/memory.stdout b/cmd/gravity/tests/cmd/memory.stdout index 0cbd7e20..36791421 100644 --- a/cmd/gravity/tests/cmd/memory.stdout +++ b/cmd/gravity/tests/cmd/memory.stdout @@ -29,9 +29,9 @@ type color int func (color) isColor() {} const ( - Red color = iota - Green color = iota - Blue color = iota + ColorRed color = iota + ColorGreen color = iota + ColorBlue color = iota ) type Everything struct { @@ -168,11 +168,11 @@ func NewMemoryFactory( var enum23 Color switch value22 { case 0: - enum23 = Red + enum23 = ColorRed case 1: - enum23 = Green + enum23 = ColorGreen case 2: - enum23 = Blue + enum23 = ColorBlue default: // The return type doesn't contain an error so we panic if one is encountered panic(errors.New("invalid color discriminant")) @@ -276,11 +276,11 @@ func NewMemoryFactory( mod.Memory().WriteUint32Le(arg1+44, uint32(value46)) var enum47 uint32 switch aColor35 { - case Red: + case ColorRed: enum47 = 0 - case Green: + case ColorGreen: enum47 = 1 - case Blue: + case ColorBlue: enum47 = 2 default: panic(errors.New("invalid enum type provided")) @@ -427,11 +427,11 @@ func (i *MemoryInstance) RoundTrip( i.module.Memory().WriteUint32Le(ptr0+44, uint32(value12)) var enum13 uint32 switch aColor1 { - case Red: + case ColorRed: enum13 = 0 - case Green: + case ColorGreen: enum13 = 1 - case Blue: + case ColorBlue: enum13 = 2 default: panic(errors.New("invalid enum type provided")) @@ -555,11 +555,11 @@ func (i *MemoryInstance) RoundTrip( var enum42 Color switch value41 { case 0: - enum42 = Red + enum42 = ColorRed case 1: - enum42 = Green + enum42 = ColorGreen case 2: - enum42 = Blue + enum42 = ColorBlue default: // The return type doesn't contain an error so we panic if one is encountered panic(errors.New("invalid color discriminant")) @@ -679,11 +679,11 @@ func (i *MemoryInstance) CallHostEcho( i.module.Memory().WriteUint32Le(ptr0+44, uint32(value12)) var enum13 uint32 switch aColor1 { - case Red: + case ColorRed: enum13 = 0 - case Green: + case ColorGreen: enum13 = 1 - case Blue: + case ColorBlue: enum13 = 2 default: panic(errors.New("invalid enum type provided")) @@ -807,11 +807,11 @@ func (i *MemoryInstance) CallHostEcho( var enum42 Color switch value41 { case 0: - enum42 = Red + enum42 = ColorRed case 1: - enum42 = Green + enum42 = ColorGreen case 2: - enum42 = Blue + enum42 = ColorBlue default: // The return type doesn't contain an error so we panic if one is encountered panic(errors.New("invalid color discriminant")) diff --git a/cmd/gravity/tests/cmd/regressions.stdout b/cmd/gravity/tests/cmd/regressions.stdout index 4893f1ba..b4bb96b2 100644 --- a/cmd/gravity/tests/cmd/regressions.stdout +++ b/cmd/gravity/tests/cmd/regressions.stdout @@ -32,9 +32,9 @@ type status int func (status) isStatus() {} const ( - Active status = iota - Inactive status = iota - Unknown status = iota + StatusActive status = iota + StatusInactive status = iota + StatusUnknown status = iota ) type IRegressionsProcessor interface { @@ -66,9 +66,9 @@ type emailCheckerValidatorResponse int func (emailCheckerValidatorResponse) isEmailCheckerValidatorResponse() {} const ( - Yes emailCheckerValidatorResponse = iota - No emailCheckerValidatorResponse = iota - Maybe emailCheckerValidatorResponse = iota + EmailCheckerValidatorResponseYes emailCheckerValidatorResponse = iota + EmailCheckerValidatorResponseNo emailCheckerValidatorResponse = iota + EmailCheckerValidatorResponseMaybe emailCheckerValidatorResponse = iota ) type IRegressionsBotVerifier interface { @@ -87,9 +87,9 @@ type botVerifierValidatorResponse int func (botVerifierValidatorResponse) isBotVerifierValidatorResponse() {} const ( - Verified botVerifierValidatorResponse = iota - Spoofed botVerifierValidatorResponse = iota - Unverifiable botVerifierValidatorResponse = iota + BotVerifierValidatorResponseVerified botVerifierValidatorResponse = iota + BotVerifierValidatorResponseSpoofed botVerifierValidatorResponse = iota + BotVerifierValidatorResponseUnverifiable botVerifierValidatorResponse = iota ) type IRegressionsIpSource interface { @@ -131,11 +131,11 @@ func NewRegressionsFactory( value1 := botVerifier.Verify(ctx, str0) var enum2 uint32 switch value1 { - case Verified: + case BotVerifierValidatorResponseVerified: enum2 = 0 - case Spoofed: + case BotVerifierValidatorResponseSpoofed: enum2 = 1 - case Unverifiable: + case BotVerifierValidatorResponseUnverifiable: enum2 = 2 default: panic(errors.New("invalid enum type provided")) @@ -185,11 +185,11 @@ func NewRegressionsFactory( value1 := checker.GetStatus(ctx, str0) var enum2 uint32 switch value1 { - case Active: + case StatusActive: enum2 = 0 - case Inactive: + case StatusInactive: enum2 = 1 - case Unknown: + case StatusUnknown: enum2 = 2 default: panic(errors.New("invalid enum type provided")) @@ -217,11 +217,11 @@ func NewRegressionsFactory( value1 := emailChecker.IsAllowed(ctx, str0) var enum2 uint32 switch value1 { - case Yes: + case EmailCheckerValidatorResponseYes: enum2 = 0 - case No: + case EmailCheckerValidatorResponseNo: enum2 = 1 - case Maybe: + case EmailCheckerValidatorResponseMaybe: enum2 = 2 default: panic(errors.New("invalid enum type provided")) diff --git a/examples/enum-collisions/Cargo.toml b/examples/enum-collisions/Cargo.toml new file mode 100644 index 00000000..99a8f3f2 --- /dev/null +++ b/examples/enum-collisions/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "example-enum-collisions" +version = "0.0.2" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.62.0" +wit-component = "=0.259.0" diff --git a/examples/enum-collisions/enum_collisions_test.go b/examples/enum-collisions/enum_collisions_test.go new file mode 100644 index 00000000..f6c27ff7 --- /dev/null +++ b/examples/enum-collisions/enum_collisions_test.go @@ -0,0 +1,51 @@ +package enum_collisions + +import ( + "context" + "testing" +) + +// Every enum case is a constant named for its enum, so cases that share a +// name across enums, or with a type, are distinct identifiers. Before the +// fix the bindings failed go vet: PeerSvid and Point were redeclared. + +func newInstance(t *testing.T) *EnumCollisionsInstance { + t.Helper() + // The world's `use types.{...}` makes `types` an import with no + // functions, so its host argument has nothing to implement. + fac, err := NewEnumCollisionsFactory(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { fac.Close(context.Background()) }) + ins, err := fac.Instantiate(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ins.Close(context.Background()) }) + return ins +} + +func TestSharedCaseNamesStayDistinct(t *testing.T) { + ins := newInstance(t) + for in, want := range map[CredentialKind]Via{ + CredentialKindPeerSvid: ViaPeerSvid, + CredentialKindSignedToken: ViaSignedToken, + CredentialKindBearer: ViaAnonymous, + } { + if got := ins.ViaFor(t.Context(), in); got != want { + t.Errorf("ViaFor(%v) = %v, want %v", in, got, want) + } + } +} + +func TestCaseNamedLikeAType(t *testing.T) { + ins := newInstance(t) + p := Point{X: 3, Y: -4} + if got := ins.Describe(t.Context(), ShapePoint, p); got != "point(3, -4)" { + t.Errorf("Describe(ShapePoint) = %q", got) + } + if got := ins.Describe(t.Context(), ShapeLine, p); got != "line to (3, -4)" { + t.Errorf("Describe(ShapeLine) = %q", got) + } +} diff --git a/examples/enum-collisions/src/lib.rs b/examples/enum-collisions/src/lib.rs new file mode 100644 index 00000000..cf803494 --- /dev/null +++ b/examples/enum-collisions/src/lib.rs @@ -0,0 +1,24 @@ +wit_bindgen::generate!({ + world: "enum-collisions", +}); + +struct EnumCollisions; + +export!(EnumCollisions); + +impl Guest for EnumCollisions { + fn via_for(k: CredentialKind) -> Via { + match k { + CredentialKind::PeerSvid => Via::PeerSvid, + CredentialKind::SignedToken => Via::SignedToken, + CredentialKind::Bearer => Via::Anonymous, + } + } + + fn describe(s: Shape, p: Point) -> String { + match s { + Shape::Point => format!("point({}, {})", p.x, p.y), + Shape::Line => format!("line to ({}, {})", p.x, p.y), + } + } +} diff --git a/examples/enum-collisions/wit/enum-collisions.wit b/examples/enum-collisions/wit/enum-collisions.wit new file mode 100644 index 00000000..fc9824aa --- /dev/null +++ b/examples/enum-collisions/wit/enum-collisions.wit @@ -0,0 +1,22 @@ +package gravity:enum-collisions; + +// WIT scopes a case name to its enum. Gravity used to emit every case as an +// unqualified package constant, so these failed go vet: +// +// - `via` and `credential-kind` share `peer-svid` and `signed-token` +// (PeerSvid redeclared); +// - `shape`'s case `point` is also a record's name (Point redeclared). +interface types { + enum via { peer-svid, signed-token, anonymous } + enum credential-kind { peer-svid, signed-token, bearer } + + record point { x: s32, y: s32 } + enum shape { point, line } +} + +world enum-collisions { + use types.{via, credential-kind, point, shape}; + + export via-for: func(k: credential-kind) -> via; + export describe: func(s: shape, p: point) -> string; +} diff --git a/examples/generate.go b/examples/generate.go index fb5761c5..ccfa0184 100644 --- a/examples/generate.go +++ b/examples/generate.go @@ -7,6 +7,7 @@ package examples //go:generate cargo build -p example-instructions --target wasm32-unknown-unknown --release //go:generate cargo build -p example-regressions --target wasm32-unknown-unknown --release //go:generate cargo build -p example-variants --target wasm32-unknown-unknown --release +//go:generate cargo build -p example-enum-collisions --target wasm32-unknown-unknown --release //go:generate cargo run --bin gravity -- --world basic --output ./basic/basic.go ../target/wasm32-unknown-unknown/release/example_basic.wasm //go:generate cargo run --bin gravity -- --world records --output ./records/records.go ../target/wasm32-unknown-unknown/release/example_records.wasm @@ -15,3 +16,4 @@ package examples //go:generate cargo run --bin gravity -- --world instructions --output ./instructions/bindings.go ../target/wasm32-unknown-unknown/release/example_instructions.wasm //go:generate cargo run --bin gravity -- --world regressions --output ./regressions/regressions.go ../target/wasm32-unknown-unknown/release/example_regressions.wasm //go:generate cargo run --bin gravity -- --world variants --output ./variants/variants.go ../target/wasm32-unknown-unknown/release/example_variants.wasm +//go:generate cargo run --bin gravity -- --world enum-collisions --output ./enum-collisions/enum_collisions.go ../target/wasm32-unknown-unknown/release/example_enum_collisions.wasm diff --git a/examples/instructions/instructions_test.go b/examples/instructions/instructions_test.go index f29d0464..8699c8cb 100644 --- a/examples/instructions/instructions_test.go +++ b/examples/instructions/instructions_test.go @@ -236,9 +236,9 @@ func Test_EnumInput(t *testing.T) { } defer ins.Close(t.Context()) - ins.EnumInput(t.Context(), One) - ins.EnumInput(t.Context(), Two) - ins.EnumInput(t.Context(), Three) + ins.EnumInput(t.Context(), EnumValuesOne) + ins.EnumInput(t.Context(), EnumValuesTwo) + ins.EnumInput(t.Context(), EnumValuesThree) } // Test_IndirectParams covers the indirect parameter path of the canonical ABI. diff --git a/examples/memory/memory_test.go b/examples/memory/memory_test.go index 1ff160f6..40dcbb8c 100644 --- a/examples/memory/memory_test.go +++ b/examples/memory/memory_test.go @@ -28,19 +28,19 @@ var cases = map[string]Everything{ "min": { AU8: 0, AS8: math.MinInt8, AU16: 0, AS16: math.MinInt16, AU32: 0, AS32: math.MinInt32, AU64: 0, AS64: math.MinInt64, - AF64: -math.MaxFloat64, ABool: false, AChar: 0, AColor: Red, + AF64: -math.MaxFloat64, ABool: false, AChar: 0, AColor: ColorRed, AOpt: nil, AStr: "", AF32: -math.MaxFloat32, }, "max": { AU8: math.MaxUint8, AS8: math.MaxInt8, AU16: math.MaxUint16, AS16: math.MaxInt16, AU32: math.MaxUint32, AS32: math.MaxInt32, AU64: math.MaxUint64, AS64: math.MaxInt64, - AF64: math.MaxFloat64, ABool: true, AChar: 0x10FFFF, AColor: Blue, + AF64: math.MaxFloat64, ABool: true, AChar: 0x10FFFF, AColor: ColorBlue, AOpt: ptr[uint8](math.MaxUint8), AStr: "hello, world", AF32: math.MaxFloat32, }, "mixed": { AU8: 200, AS8: -5, AU16: 65000, AS16: -30000, AU32: 4_000_000_000, AS32: -42, AU64: 1 << 40, AS64: -(1 << 40), - AF64: -2.25, ABool: true, AChar: 'λ', AColor: Green, + AF64: -2.25, ABool: true, AChar: 'λ', AColor: ColorGreen, AOpt: ptr[uint8](0), AStr: "λ😀", AF32: 1.5, }, } diff --git a/examples/regressions/regressions_test.go b/examples/regressions/regressions_test.go index b6b660ee..e7cfc6bb 100644 --- a/examples/regressions/regressions_test.go +++ b/examples/regressions/regressions_test.go @@ -24,11 +24,11 @@ func (Checker) IsEnabled(_ context.Context, key string) bool { func (Checker) GetStatus(_ context.Context, key string) Status { switch key { case "active": - return Active + return StatusActive case "inactive": - return Inactive + return StatusInactive default: - return Unknown + return StatusUnknown } } @@ -65,11 +65,11 @@ type EmailChecker struct{} func (EmailChecker) IsAllowed(_ context.Context, email string) EmailCheckerValidatorResponse { switch email { case "allow@example.com": - return Yes + return EmailCheckerValidatorResponseYes case "block@example.com": - return No + return EmailCheckerValidatorResponseNo default: - return Maybe + return EmailCheckerValidatorResponseMaybe } } @@ -78,11 +78,11 @@ type BotVerifier struct{} func (BotVerifier) Verify(_ context.Context, botID string) BotVerifierValidatorResponse { switch botID { case "verified-bot": - return Verified + return BotVerifierValidatorResponseVerified case "spoofed-bot": - return Spoofed + return BotVerifierValidatorResponseSpoofed default: - return Unverifiable + return BotVerifierValidatorResponseUnverifiable } } @@ -151,9 +151,9 @@ func TestCheckStatus(t *testing.T) { key string want uint32 }{ - {"active", 0}, // Active - {"inactive", 1}, // Inactive - {"unknown", 2}, // Unknown + {"active", 0}, // StatusActive + {"inactive", 1}, // StatusInactive + {"unknown", 2}, // StatusUnknown } for _, tt := range tests { diff --git a/examples/regressions/wit/regressions.wit b/examples/regressions/wit/regressions.wit index e40ddb05..424f4b02 100644 --- a/examples/regressions/wit/regressions.wit +++ b/examples/regressions/wit/regressions.wit @@ -26,10 +26,9 @@ interface pinger { // `validator-response`. Without interface-scoped name qualification, the // generated Go redeclares the same type twice. interface email-checker { - // Case names overlap intentionally with the bot-verifier enum below, but - // `maybe` is used in place of `unknown` so the case CONSTANTS don't - // collide with `checker.status.unknown` (constant qualification is out - // of scope for this regression). + // Case constants are named for their enum (EmailCheckerValidatorResponseYes), + // so they could not collide with `checker.status.unknown` even if this enum + // had an `unknown`; the enum-collisions example covers shared case names. enum validator-response { yes, no, From 1bff4dbfca428ad9db1adb2b0af97cdc88f93d74 Mon Sep 17 00:00:00 2001 From: robfischer1 <15163713+robfischer1@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:32:21 -0400 Subject: [PATCH 2/4] feat: lower result whose E is not a string Gravity lowered only result and result<_, string>; any other err type panicked at generation: not yet implemented: TODO(#4): implement remaining result conversion so the identity lift had to return a `decision { ok: option, refused: option }` record instead of result. The Go shape stays (T, error) / error. The err case comes back as a *ResultError[E], a generic error type emitted once per file when any function needs it; errors.As recovers the E, and a trap, a host failure or any other error is never one. On the host side a function returns &ResultError[E]{Value: e} for the err case; an error that is not a *ResultError[E] has no WIT representation and traps the guest, as a panic in a host function does. Enums, records and variants (including a record dispatched directly as a variant case) work as E, with or without an ok type. ResultError.Error formats its value with %v, so each enum's private type gains a String that answers the WIT case name ("untyped-peer"), and the error text reads as WIT wrote it. Tests: exports::tests::test_export_result_with_enum_err, imports::tests::test_import_result_with_record_err (both fail without the fix with the panic above), imports::tests::test_enum_string_answers_the_wit_case, factory::tests::test_result_error_only_when_needed, and the new result-errors example: TestEnumErr, TestRecordErr, TestVariantErr, TestErrWithoutOk, TestHostReturnsTypedErrs, TestHostPlainErrorTraps (without the fix the bindings do not generate). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 + cmd/gravity/src/codegen/bindings.rs | 12 +- cmd/gravity/src/codegen/exports.rs | 29 + cmd/gravity/src/codegen/factory.rs | 62 +- cmd/gravity/src/codegen/func.rs | 112 ++- cmd/gravity/src/codegen/imports.rs | 89 +- cmd/gravity/src/go/imports.rs | 2 + cmd/gravity/src/lib.rs | 9 +- cmd/gravity/tests/cmd/enum-collisions.stdout | 35 + cmd/gravity/tests/cmd/instructions.stdout | 13 + cmd/gravity/tests/cmd/memory.stdout | 13 + cmd/gravity/tests/cmd/regressions.stdout | 37 + cmd/gravity/tests/cmd/result-errors.stderr | 0 cmd/gravity/tests/cmd/result-errors.stdout | 977 +++++++++++++++++++ cmd/gravity/tests/cmd/result-errors.toml | 2 + examples/generate.go | 2 + examples/result-errors/Cargo.toml | 11 + examples/result-errors/result_errors_test.go | 179 ++++ examples/result-errors/src/lib.rs | 71 ++ examples/result-errors/wit/result-errors.wit | 39 + 20 files changed, 1690 insertions(+), 12 deletions(-) create mode 100644 cmd/gravity/tests/cmd/result-errors.stderr create mode 100644 cmd/gravity/tests/cmd/result-errors.stdout create mode 100644 cmd/gravity/tests/cmd/result-errors.toml create mode 100644 examples/result-errors/Cargo.toml create mode 100644 examples/result-errors/result_errors_test.go create mode 100644 examples/result-errors/src/lib.rs create mode 100644 examples/result-errors/wit/result-errors.wit diff --git a/Cargo.lock b/Cargo.lock index 18b25cec..c7b9068b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,14 @@ dependencies = [ "wit-component", ] +[[package]] +name = "example-result-errors" +version = "0.0.2" +dependencies = [ + "wit-bindgen", + "wit-component", +] + [[package]] name = "example-variants" version = "0.0.2" diff --git a/cmd/gravity/src/codegen/bindings.rs b/cmd/gravity/src/codegen/bindings.rs index 642de345..7816c2e2 100644 --- a/cmd/gravity/src/codegen/bindings.rs +++ b/cmd/gravity/src/codegen/bindings.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use genco::{prelude::*, tokens::Tokens}; -use wit_bindgen_core::wit_parser::{Resolve, SizeAlign, World}; +use wit_bindgen_core::wit_parser::{Resolve, Result_, SizeAlign, Type, TypeDefKind, World}; use crate::{ codegen::{ @@ -77,10 +77,20 @@ impl<'a> Bindings<'a> { analyzed_imports: &AnalyzedImports, import_chains: BTreeMap>, ) { + let typed_result_errors = self.resolve.types.iter().any(|(_, def)| { + matches!( + def.kind, + TypeDefKind::Result(Result_ { + err: Some(err), + .. + }) if err != Type::String + ) + }); let config = FactoryConfig { analyzed_imports, import_chains, wasm_var_name: &self.raw_wasm_var, + typed_result_errors, }; FactoryGenerator::new(config).format_into(&mut self.out) } diff --git a/cmd/gravity/src/codegen/exports.rs b/cmd/gravity/src/codegen/exports.rs index 12e95c98..23178cd0 100644 --- a/cmd/gravity/src/codegen/exports.rs +++ b/cmd/gravity/src/codegen/exports.rs @@ -246,4 +246,33 @@ mod tests { "the wasm export must be called with the param area pointer, got:\n{generated}" ); } + + /// Regression test: an export returning `result` with a non-string + /// E (here an enum) returns the err case as a `*ResultError[E]`. Gravity + /// used to panic generating it: "TODO(#4): implement remaining result + /// conversion". + #[test] + fn test_export_result_with_enum_err() { + let fixture = Fixture::parse( + "package test:fixture; + world test-world { + enum refusal { no-credential, malformed, untyped-peer } + record decided { name: string } + export decide: func(n: u32) -> result; + export only-err: func(n: u32) -> result<_, refusal>; + }", + ); + let decide = generate(&fixture, "decide"); + assert!(decide.contains(") (Decided, error) {"), "got:\n{decide}"); + assert!( + decide.contains("= &ResultError[Refusal]{Value: enum"), + "the err case must be a *ResultError[Refusal], got:\n{decide}" + ); + let only_err = generate(&fixture, "only-err"); + assert!(only_err.contains(") error {"), "got:\n{only_err}"); + assert!( + only_err.contains("= &ResultError[Refusal]{Value: enum"), + "got:\n{only_err}" + ); + } } diff --git a/cmd/gravity/src/codegen/factory.rs b/cmd/gravity/src/codegen/factory.rs index 7efa6abc..bf2fb69d 100644 --- a/cmd/gravity/src/codegen/factory.rs +++ b/cmd/gravity/src/codegen/factory.rs @@ -7,7 +7,7 @@ use crate::{ go::{ GoIdentifier, comment, imports::{ - CONTEXT_CONTEXT, ERRORS_NEW, WAZERO_API_MEMORY, WAZERO_API_MODULE, + CONTEXT_CONTEXT, ERRORS_NEW, FMT_SPRINTF, WAZERO_API_MEMORY, WAZERO_API_MODULE, WAZERO_COMPILED_MODULE, WAZERO_NEW_MODULE_CONFIG, WAZERO_NEW_RUNTIME, WAZERO_RUNTIME, }, }, @@ -18,6 +18,9 @@ pub struct FactoryConfig<'a> { pub analyzed_imports: &'a AnalyzedImports, pub import_chains: BTreeMap>, pub wasm_var_name: &'a GoIdentifier, + /// Whether any function returns a `result` whose err type is not a + /// string, so the bindings need `ResultError`. + pub typed_result_errors: bool, } /// Generator for factory and instance types @@ -69,6 +72,26 @@ impl<'a> FactoryGenerator<'a> { }; } + /// Generate `ResultError`, the error a function returns for the err case + /// of a `result` whose E is not a string. + fn generate_result_error(&self, tokens: &mut Tokens) { + quote_in! { *tokens => + $(comment(&[ + "ResultError is the error a function returns for the err case of a WIT", + "result whose E is not a string; Value is the E. Recover it with", + "errors.As. Any other error is a trap or a host failure, never an E.", + ])) + type ResultError[E any] struct { + Value E + } + $['\n'] + func (e *ResultError[E]) Error() string { + return $FMT_SPRINTF("%v", e.Value) + } + $['\n'] + }; + } + /// Generate the Factory struct, constructor, and methods. fn generate_factory(&self, tokens: &mut Tokens) { let AnalyzedImports { @@ -168,12 +191,16 @@ impl<'a> FormatInto for &FactoryGenerator<'a> { tokens.push(); self.generate_write_string(tokens); tokens.push(); + if self.config.typed_result_errors { + self.generate_result_error(tokens); + tokens.push(); + } } } #[cfg(test)] mod tests { - use genco::lang::go::Tokens; + use genco::{lang::go::Tokens, tokens::FormatInto}; use crate::{ codegen::{FactoryGenerator, factory::FactoryConfig, ir::AnalyzedImports}, @@ -194,6 +221,7 @@ mod tests { analyzed_imports, import_chains: Default::default(), wasm_var_name: &GoIdentifier::public("test-wasm"), + typed_result_errors: false, }; let generator = FactoryGenerator::new(config); let mut tokens = Tokens::new(); @@ -201,4 +229,34 @@ mod tests { assert!(tokens.to_string().unwrap().contains("func writeString")); } + + /// `ResultError` is emitted exactly when a function needs it. + #[test] + fn test_result_error_only_when_needed() { + let analyzed_imports = &AnalyzedImports { + interfaces: vec![], + standalone_types: vec![], + standalone_functions: vec![], + factory_name: GoIdentifier::public("test-factory"), + instance_name: GoIdentifier::public("test-instance"), + constructor_name: GoIdentifier::public("test-constructor"), + }; + for typed_result_errors in [false, true] { + let config = FactoryConfig { + analyzed_imports, + import_chains: Default::default(), + wasm_var_name: &GoIdentifier::public("test-wasm"), + typed_result_errors, + }; + let generator = FactoryGenerator::new(config); + let mut tokens = Tokens::new(); + (&generator).format_into(&mut tokens); + let generated = tokens.to_string().unwrap(); + assert_eq!( + generated.contains("type ResultError[E any] struct"), + typed_result_errors, + "got:\n{generated}" + ); + } + } } diff --git a/cmd/gravity/src/codegen/func.rs b/cmd/gravity/src/codegen/func.rs index 1a27e7bf..0bc63b59 100644 --- a/cmd/gravity/src/codegen/func.rs +++ b/cmd/gravity/src/codegen/func.rs @@ -10,7 +10,7 @@ use crate::{ go::{ GoIdentifier, GoResult, GoType, Operand, comment, imports::{ - ERRORS_NEW, UTF8_VALID_RUNE, WAZERO_API_DECODE_F32, WAZERO_API_DECODE_F64, + ERRORS_AS, ERRORS_NEW, UTF8_VALID_RUNE, WAZERO_API_DECODE_F32, WAZERO_API_DECODE_F64, WAZERO_API_ENCODE_F32, WAZERO_API_ENCODE_F64, }, }, @@ -606,6 +606,69 @@ impl Bindgen for Func<'_> { results.push(Operand::SingleValue(err.into())); } + // A result whose err case is not a string: the err payload is + // returned as a `*ResultError[E]`, so `errors.As` recovers it and a + // trap (any other error) stays distinguishable. + Instruction::ResultLift { + result: + Result_ { + ok, + err: Some(err_typ), + }, + .. + } => { + let err_go = resolve_type(err_typ, resolve); + let (err_block, err_results) = self.pop_block(); + assert_eq!(err_results.len(), 1); + let err_op = &err_results[0]; + + let (ok_block, ok_results) = self.pop_block(); + + let tmp = self.tmp(); + let value = &format!("value{tmp}"); + let err = &format!("err{tmp}"); + let tag = &operands[0]; + match ok { + Some(ok_typ) => { + assert_eq!(ok_results.len(), 1); + let ok_op = &ok_results[0]; + let ok_go = resolve_type(ok_typ, resolve); + quote_in! { self.body => + $['\r'] + var $value $ok_go + var $err error + switch $tag { + case 0: + $ok_block + $value = $ok_op + case 1: + $err_block + $err = &ResultError[$err_go]{Value: $err_op} + default: + $err = $ERRORS_NEW("invalid variant discriminant for expected") + } + }; + results.push(Operand::MultiValue((value.into(), err.into()))); + } + None => { + assert_eq!(ok_results.len(), 0); + quote_in! { self.body => + $['\r'] + var $err error + switch $tag { + case 0: + $ok_block + case 1: + $err_block + $err = &ResultError[$err_go]{Value: $err_op} + default: + $err = $ERRORS_NEW("invalid variant discriminant for expected") + } + }; + results.push(Operand::SingleValue(err.into())); + } + } + } Instruction::ResultLift { .. } => todo!("implement instruction: {inst:?}"), Instruction::Return { amt, .. } => { if *amt != 0 { @@ -767,6 +830,53 @@ impl Bindgen for Func<'_> { } }; } + // The host returns a `*ResultError[E]` for the err case of a + // result whose E is not a string. Any other error has no WIT + // representation, so it traps the guest, as a panic in a host + // function does. + Instruction::ResultLower { + result: + Result_ { + ok, + err: Some(err_typ), + }, + .. + } => { + let err_go = resolve_type(err_typ, resolve); + let (err_block, _) = self.pop_block(); + let (ok_block, _) = self.pop_block(); + let tmp = self.tmp(); + let typed = &format!("typed{tmp}"); + let (ok_value, err) = match (ok, &operands[0]) { + (Some(_), Operand::MultiValue((ok_value, err))) => { + (Some(ok_value.clone()), err.clone()) + } + (None, Operand::SingleValue(err)) => (None, err.clone()), + (_, operand) => panic!( + "impossible: a result lowers from (value, error) or error, got {operand:?}" + ), + }; + let ok_arm = match &ok_value { + Some(ok_value) => quote! { + variantPayload := $ok_value + $ok_block + }, + None => quote!($ok_block), + }; + quote_in! { self.body => + $['\r'] + if $(&err) != nil { + var $typed *ResultError[$err_go] + if !$ERRORS_AS($(&err), &$typed) { + panic($(&err)) + } + variantPayload := $typed.Value + $err_block + } else { + $ok_arm + } + }; + } Instruction::ResultLower { .. } => todo!("implement instruction: {inst:?}"), Instruction::OptionLift { payload, .. } => { let (some, some_results) = self.blocks.pop().unwrap(); diff --git a/cmd/gravity/src/codegen/imports.rs b/cmd/gravity/src/codegen/imports.rs index 5a9f39af..e5ae80f8 100644 --- a/cmd/gravity/src/codegen/imports.rs +++ b/cmd/gravity/src/codegen/imports.rs @@ -19,7 +19,7 @@ use crate::{ }, go::{ GoIdentifier, GoResult, GoType, - imports::{CONTEXT_CONTEXT, WAZERO_API_MODULE}, + imports::{CONTEXT_CONTEXT, FMT_SPRINTF, WAZERO_API_MODULE}, }, resolve_param_type, resolve_type, resolve_wasm_type, }; @@ -383,7 +383,17 @@ impl<'a> ImportCodeGenerator<'a> { let enum_function = &GoIdentifier::private(format!("is-{}", &typ.name)); let variants = cases .iter() - .map(|case| GoIdentifier::public(crate::enum_case_name(&typ.name, case))); + .map(|case| GoIdentifier::public(crate::enum_case_name(&typ.name, case))) + .collect::>(); + // String answers the WIT spelling of the case, so a case + // prints, and a ResultError carrying one reads, as WIT wrote it. + let names = variants.iter().zip(cases).map(|(name, case)| { + quote! { + case $name: + return $(quoted(case)) + } + }); + let unknown = format!("{}(%d)", typ.name); quote_in! { *tokens => $['\n'] type $(enum_interface) interface { @@ -395,9 +405,16 @@ impl<'a> ImportCodeGenerator<'a> { func ($(enum_type)) $enum_function() {} $['\n'] const ( - $(for name in variants join ($['\r']) => $name $enum_type = iota) + $(for name in &variants join ($['\r']) => $name $enum_type = iota) ) $['\n'] + func (e $(enum_type)) String() string { + switch e { + $(for arm in names join ($['\r']) => $arm) + } + return $FMT_SPRINTF($(quoted(unknown)), int(e)) + } + $['\n'] } } TypeDefinition::Alias { target } => { @@ -981,4 +998,70 @@ mod tests { "no case constant may be unqualified, got:\n{generated}" ); } + + /// Regression test: a host import returning `result` with a + /// non-string E recovers the E from the host's error with `errors.As`, + /// and traps on any other error. + #[test] + fn test_import_result_with_record_err() { + let fixture = host_fixture( + "record problem { code: u32 } + probe: func(n: u32) -> result;", + ); + let method = InterfaceMethod { + name: "probe".to_string(), + go_method_name: GoIdentifier::public("Probe"), + parameters: vec![], + return_type: None, + wit_function: fixture.function("host", "probe").clone(), + }; + + let code_str = host_function(&fixture, &method); + assert!( + code_str.contains("*ResultError[Problem]"), + "expected a *ResultError[Problem] target, got:\n{code_str}" + ); + assert!( + code_str.contains("errors.As("), + "expected errors.As to recover the err case, got:\n{code_str}" + ); + assert!( + code_str.contains("variantPayload := typed"), + "the err payload must be the recovered E, got:\n{code_str}" + ); + } + + /// An enum's private type answers its WIT case name from String. + #[test] + fn test_enum_string_answers_the_wit_case() { + let fixture = Fixture::parse( + "package test:fixture; + interface types { + enum refusal { no-credential, untyped-peer } + } + world test-world { + import types; + }", + ); + let analyzed = ImportAnalyzer::new(&fixture.resolve, fixture.world()).analyze(); + let generator = ImportCodeGenerator::new(&fixture.resolve, &analyzed, &fixture.sizes); + let mut tokens = Tokens::new(); + generator.format_into(&mut tokens); + let generated = tokens.to_string().unwrap(); + + assert!( + generated.contains("func (e refusal) String() string {"), + "got:\n{generated}" + ); + for expected in [ + "case RefusalUntypedPeer:", + "return \"untyped-peer\"", + "\"refusal(%d)\"", + ] { + assert!( + generated.contains(expected), + "expected `{expected}`, got:\n{generated}" + ); + } + } } diff --git a/cmd/gravity/src/go/imports.rs b/cmd/gravity/src/go/imports.rs index 94522d57..c5559110 100644 --- a/cmd/gravity/src/go/imports.rs +++ b/cmd/gravity/src/go/imports.rs @@ -11,6 +11,8 @@ impl FormatInto for GoImport { pub static CONTEXT_CONTEXT: GoImport = GoImport("context", "Context"); pub static ERRORS_NEW: GoImport = GoImport("errors", "New"); +pub static ERRORS_AS: GoImport = GoImport("errors", "As"); +pub static FMT_SPRINTF: GoImport = GoImport("fmt", "Sprintf"); pub static FMT_PRINTF: GoImport = GoImport("fmt", "Printf"); pub static WAZERO_RUNTIME: GoImport = GoImport("github.com/tetratelabs/wazero", "Runtime"); pub static WAZERO_NEW_RUNTIME: GoImport = GoImport("github.com/tetratelabs/wazero", "NewRuntime"); diff --git a/cmd/gravity/src/lib.rs b/cmd/gravity/src/lib.rs index 60049b7a..1a3ccdb3 100644 --- a/cmd/gravity/src/lib.rs +++ b/cmd/gravity/src/lib.rs @@ -171,12 +171,11 @@ pub fn resolve_type(typ: &Type, resolve: &Resolve) -> GoType { ok: Some(ok), err: Some(Type::String), }) => GoType::ValueOrError(Box::new(resolve_type(ok, resolve))), + // Any other err type comes back as a `*ResultError[E]`. TypeDefKind::Result(Result_ { - ok: Some(_), + ok: Some(ok), err: Some(_), - }) => { - todo!("TODO(#4): implement remaining result conversion") - } + }) => GoType::ValueOrError(Box::new(resolve_type(ok, resolve))), TypeDefKind::Result(Result_ { ok: Some(ok), err: None, @@ -188,7 +187,7 @@ pub fn resolve_type(typ: &Type, resolve: &Resolve) -> GoType { TypeDefKind::Result(Result_ { ok: None, err: Some(_), - }) => todo!("TODO(#4): implement remaining result conversion"), + }) => GoType::Error, TypeDefKind::Result(Result_ { ok: None, err: None, diff --git a/cmd/gravity/tests/cmd/enum-collisions.stdout b/cmd/gravity/tests/cmd/enum-collisions.stdout index 9753198d..1c13d429 100644 --- a/cmd/gravity/tests/cmd/enum-collisions.stdout +++ b/cmd/gravity/tests/cmd/enum-collisions.stdout @@ -4,6 +4,7 @@ package enum_collisions import "context" import "errors" +import "fmt" import "github.com/tetratelabs/wazero" import "github.com/tetratelabs/wazero/api" @@ -28,6 +29,18 @@ const ( ViaAnonymous via = iota ) +func (e via) String() string { + switch e { + case ViaPeerSvid: + return "peer-svid" + case ViaSignedToken: + return "signed-token" + case ViaAnonymous: + return "anonymous" + } + return fmt.Sprintf("via(%d)", int(e)) +} + type CredentialKind interface { isCredentialKind() } @@ -42,6 +55,18 @@ const ( CredentialKindBearer credentialKind = iota ) +func (e credentialKind) String() string { + switch e { + case CredentialKindPeerSvid: + return "peer-svid" + case CredentialKindSignedToken: + return "signed-token" + case CredentialKindBearer: + return "bearer" + } + return fmt.Sprintf("credential-kind(%d)", int(e)) +} + type Point struct { X int32 Y int32 @@ -60,6 +85,16 @@ const ( ShapeLine shape = iota ) +func (e shape) String() string { + switch e { + case ShapePoint: + return "point" + case ShapeLine: + return "line" + } + return fmt.Sprintf("shape(%d)", int(e)) +} + type EnumCollisionsFactory struct { runtime wazero.Runtime module wazero.CompiledModule diff --git a/cmd/gravity/tests/cmd/instructions.stdout b/cmd/gravity/tests/cmd/instructions.stdout index accddc81..0c66efb8 100644 --- a/cmd/gravity/tests/cmd/instructions.stdout +++ b/cmd/gravity/tests/cmd/instructions.stdout @@ -4,6 +4,7 @@ package instructions import "context" import "errors" +import "fmt" import "github.com/tetratelabs/wazero" import "github.com/tetratelabs/wazero/api" @@ -26,6 +27,18 @@ const ( EnumValuesThree enumValues = iota ) +func (e enumValues) String() string { + switch e { + case EnumValuesOne: + return "one" + case EnumValuesTwo: + return "two" + case EnumValuesThree: + return "three" + } + return fmt.Sprintf("enum-values(%d)", int(e)) +} + type WideParams struct { A uint32 B uint32 diff --git a/cmd/gravity/tests/cmd/memory.stdout b/cmd/gravity/tests/cmd/memory.stdout index 36791421..9e2cac60 100644 --- a/cmd/gravity/tests/cmd/memory.stdout +++ b/cmd/gravity/tests/cmd/memory.stdout @@ -4,6 +4,7 @@ package memory import "context" import "errors" +import "fmt" import "github.com/tetratelabs/wazero" import "github.com/tetratelabs/wazero/api" import "unicode/utf8" @@ -34,6 +35,18 @@ const ( ColorBlue color = iota ) +func (e color) String() string { + switch e { + case ColorRed: + return "red" + case ColorGreen: + return "green" + case ColorBlue: + return "blue" + } + return fmt.Sprintf("color(%d)", int(e)) +} + type Everything struct { AU8 uint8 AS8 int8 diff --git a/cmd/gravity/tests/cmd/regressions.stdout b/cmd/gravity/tests/cmd/regressions.stdout index b4bb96b2..08967ad7 100644 --- a/cmd/gravity/tests/cmd/regressions.stdout +++ b/cmd/gravity/tests/cmd/regressions.stdout @@ -4,6 +4,7 @@ package regressions import "context" import "errors" +import "fmt" import "github.com/tetratelabs/wazero" import "github.com/tetratelabs/wazero/api" @@ -37,6 +38,18 @@ const ( StatusUnknown status = iota ) +func (e status) String() string { + switch e { + case StatusActive: + return "active" + case StatusInactive: + return "inactive" + case StatusUnknown: + return "unknown" + } + return fmt.Sprintf("status(%d)", int(e)) +} + type IRegressionsProcessor interface { Double( ctx context.Context, @@ -71,6 +84,18 @@ const ( EmailCheckerValidatorResponseMaybe emailCheckerValidatorResponse = iota ) +func (e emailCheckerValidatorResponse) String() string { + switch e { + case EmailCheckerValidatorResponseYes: + return "yes" + case EmailCheckerValidatorResponseNo: + return "no" + case EmailCheckerValidatorResponseMaybe: + return "maybe" + } + return fmt.Sprintf("email-checker-validator-response(%d)", int(e)) +} + type IRegressionsBotVerifier interface { Verify( ctx context.Context, @@ -92,6 +117,18 @@ const ( BotVerifierValidatorResponseUnverifiable botVerifierValidatorResponse = iota ) +func (e botVerifierValidatorResponse) String() string { + switch e { + case BotVerifierValidatorResponseVerified: + return "verified" + case BotVerifierValidatorResponseSpoofed: + return "spoofed" + case BotVerifierValidatorResponseUnverifiable: + return "unverifiable" + } + return fmt.Sprintf("bot-verifier-validator-response(%d)", int(e)) +} + type IRegressionsIpSource interface { Lookup( ctx context.Context, diff --git a/cmd/gravity/tests/cmd/result-errors.stderr b/cmd/gravity/tests/cmd/result-errors.stderr new file mode 100644 index 00000000..e69de29b diff --git a/cmd/gravity/tests/cmd/result-errors.stdout b/cmd/gravity/tests/cmd/result-errors.stdout new file mode 100644 index 00000000..bfc04969 --- /dev/null +++ b/cmd/gravity/tests/cmd/result-errors.stdout @@ -0,0 +1,977 @@ +// Code generated by arcjet-gravity; DO NOT EDIT. + +package result_errors + +import "context" +import "errors" +import "fmt" +import "github.com/tetratelabs/wazero" +import "github.com/tetratelabs/wazero/api" + +import _ "embed" + +//go:embed result_errors.wasm +var wasmFileResultErrors []byte + +type IResultErrorsTypes interface {} + +type Refusal interface { + isRefusal() +} + +type refusal int + +func (refusal) isRefusal() {} + +const ( + RefusalNoCredential refusal = iota + RefusalMalformed refusal = iota + RefusalUntypedPeer refusal = iota + RefusalForeignDomain refusal = iota +) + +func (e refusal) String() string { + switch e { + case RefusalNoCredential: + return "no-credential" + case RefusalMalformed: + return "malformed" + case RefusalUntypedPeer: + return "untyped-peer" + case RefusalForeignDomain: + return "foreign-domain" + } + return fmt.Sprintf("refusal(%d)", int(e)) +} + +type Problem struct { + Code uint32 + Detail string +} + +type Fault interface { + isFault() +} + +type FaultRefused struct { + Value Refusal +} + +func (FaultRefused) isFault() {} + +func (Problem) isFault() {} + +type FaultMessage struct { + Value string +} + +func (FaultMessage) isFault() {} + +type FaultUnknown struct {} + +func (FaultUnknown) isFault() {} + +type Decided struct { + Name string + N uint32 +} + +type IResultErrorsHost interface { + Check( + ctx context.Context, + n uint32, + ) (uint32, error) + Probe( + ctx context.Context, + n uint32, + ) error + Explain( + ctx context.Context, + n uint32, + ) (string, error) +} + +type ResultErrorsFactory struct { + runtime wazero.Runtime + module wazero.CompiledModule +} + +func NewResultErrorsFactory( + ctx context.Context, + types IResultErrorsTypes, + host IResultErrorsHost, +) (*ResultErrorsFactory, error) { + wazeroRuntime := wazero.NewRuntime(ctx) + + _, err1 := wazeroRuntime.NewHostModuleBuilder("gravity:result-errors/host"). + NewFunctionBuilder(). + WithFunc(func( + ctx context.Context, + mod api.Module, + arg0 uint32, + arg1 uint32, + ) { + result0 := uint32(arg0) + value1, err1 := host.Check(ctx, result0) + if err1 != nil { + var typed4 *ResultError[Refusal] + if !errors.As(err1, &typed4) { + panic(err1) + } + variantPayload := typed4.Value + mod.Memory().WriteByte(arg1+0, uint8(1)) + var enum3 uint32 + switch variantPayload { + case RefusalNoCredential: + enum3 = 0 + case RefusalMalformed: + enum3 = 1 + case RefusalUntypedPeer: + enum3 = 2 + case RefusalForeignDomain: + enum3 = 3 + default: + panic(errors.New("invalid enum type provided")) + } + mod.Memory().WriteByte(arg1+4, uint8(enum3)) + } else { + variantPayload := value1 + mod.Memory().WriteByte(arg1+0, uint8(0)) + result2 := uint32(variantPayload) + mod.Memory().WriteUint32Le(arg1+4, uint32(result2)) + } + }). + Export("check"). + NewFunctionBuilder(). + WithFunc(func( + ctx context.Context, + mod api.Module, + arg0 uint32, + arg1 uint32, + ) { + result0 := uint32(arg0) + err1 := host.Probe(ctx, result0) + if err1 != nil { + var typed5 *ResultError[Problem] + if !errors.As(err1, &typed5) { + panic(err1) + } + variantPayload := typed5.Value + mod.Memory().WriteByte(arg1+0, uint8(1)) + code2 := variantPayload.Code + detail2 := variantPayload.Detail + result3 := uint32(code2) + mod.Memory().WriteUint32Le(arg1+4, uint32(result3)) + memory4 := mod.Memory() + realloc4 := mod.ExportedFunction("cabi_realloc") + ptr4, len4, err4 := writeString(ctx, detail2, memory4, realloc4) + if err4 != nil { + panic(err4) + } + mod.Memory().WriteUint32Le(arg1+12, uint32(len4)) + mod.Memory().WriteUint32Le(arg1+8, uint32(ptr4)) + } else { + mod.Memory().WriteByte(arg1+0, uint8(0)) + } + }). + Export("probe"). + NewFunctionBuilder(). + WithFunc(func( + ctx context.Context, + mod api.Module, + arg0 uint32, + arg1 uint32, + ) { + result0 := uint32(arg0) + value1, err1 := host.Explain(ctx, result0) + if err1 != nil { + var typed9 *ResultError[Fault] + if !errors.As(err1, &typed9) { + panic(err1) + } + variantPayload := typed9.Value + mod.Memory().WriteByte(arg1+0, uint8(1)) + switch case8 := variantPayload.(type) { + case FaultRefused: + variantPayload := case8.Value + mod.Memory().WriteByte(arg1+4, uint8(0)) + var enum3 uint32 + switch variantPayload { + case RefusalNoCredential: + enum3 = 0 + case RefusalMalformed: + enum3 = 1 + case RefusalUntypedPeer: + enum3 = 2 + case RefusalForeignDomain: + enum3 = 3 + default: + panic(errors.New("invalid enum type provided")) + } + mod.Memory().WriteByte(arg1+8, uint8(enum3)) + case Problem: + variantPayload := case8 + mod.Memory().WriteByte(arg1+4, uint8(1)) + code4 := variantPayload.Code + detail4 := variantPayload.Detail + result5 := uint32(code4) + mod.Memory().WriteUint32Le(arg1+8, uint32(result5)) + memory6 := mod.Memory() + realloc6 := mod.ExportedFunction("cabi_realloc") + ptr6, len6, err6 := writeString(ctx, detail4, memory6, realloc6) + if err6 != nil { + panic(err6) + } + mod.Memory().WriteUint32Le(arg1+16, uint32(len6)) + mod.Memory().WriteUint32Le(arg1+12, uint32(ptr6)) + case FaultMessage: + variantPayload := case8.Value + mod.Memory().WriteByte(arg1+4, uint8(2)) + memory7 := mod.Memory() + realloc7 := mod.ExportedFunction("cabi_realloc") + ptr7, len7, err7 := writeString(ctx, variantPayload, memory7, realloc7) + if err7 != nil { + panic(err7) + } + mod.Memory().WriteUint32Le(arg1+12, uint32(len7)) + mod.Memory().WriteUint32Le(arg1+8, uint32(ptr7)) + case FaultUnknown: + _ = case8 + mod.Memory().WriteByte(arg1+4, uint8(3)) + default: + // The return type doesn't contain an error so we panic if one is encountered + panic(errors.New("invalid variant type provided")) + } + } else { + variantPayload := value1 + mod.Memory().WriteByte(arg1+0, uint8(0)) + memory2 := mod.Memory() + realloc2 := mod.ExportedFunction("cabi_realloc") + ptr2, len2, err2 := writeString(ctx, variantPayload, memory2, realloc2) + if err2 != nil { + panic(err2) + } + mod.Memory().WriteUint32Le(arg1+8, uint32(len2)) + mod.Memory().WriteUint32Le(arg1+4, uint32(ptr2)) + } + }). + Export("explain"). + Instantiate(ctx) + if err1 != nil { + return nil, err1 + } + _, err0 := wazeroRuntime.NewHostModuleBuilder("gravity:result-errors/types"). + Instantiate(ctx) + if err0 != nil { + return nil, err0 + } + + // Compiling the module takes a LONG time, so we want to do it once and hold + // onto it with the Runtime + module, err := wazeroRuntime.CompileModule(ctx, wasmFileResultErrors) + if err != nil { + return nil, err + } + return &ResultErrorsFactory{ + runtime: wazeroRuntime, + module: module, + }, nil +} + +func (f *ResultErrorsFactory) Instantiate(ctx context.Context) (*ResultErrorsInstance, error) { + if module, err := f.runtime.InstantiateModule(ctx, f.module, wazero.NewModuleConfig()); err != nil { + return nil, err + } else { + return &ResultErrorsInstance{module}, nil + } +} + +func (f *ResultErrorsFactory) Close(ctx context.Context) { + f.runtime.Close(ctx) +} + +type ResultErrorsInstance struct { + module api.Module +} + +func (i *ResultErrorsInstance) Close(ctx context.Context) error { + if err := i.module.Close(ctx); err != nil { + return err + } + + return nil +} + +// writeString will put a Go string into the Wasm memory following the Component +// Model calling conventions, such as allocating memory with the realloc function +func writeString( + ctx context.Context, + s string, + memory api.Memory, + realloc api.Function, +) (uint64, uint64, error) { + if len(s) == 0 { + return 1, 0, nil + } + + results, err := realloc.Call(ctx, 0, 0, 1, uint64(len(s))) + if err != nil { + return 1, 0, err + } + ptr := results[0] + ok := memory.Write(uint32(ptr), []byte(s)) + if !ok { + return 1, 0, errors.New("failed to write string to wasm memory") + } + return uint64(ptr), uint64(len(s)), nil +} + +// ResultError is the error a function returns for the err case of a WIT +// result whose E is not a string; Value is the E. Recover it with +// errors.As. Any other error is a trap or a host failure, never an E. +type ResultError[E any] struct { + Value E +} + +func (e *ResultError[E]) Error() string { + return fmt.Sprintf("%v", e.Value) +} + +func (i *ResultErrorsInstance) Decide( + ctx context.Context, + n uint32, +) (Decided, error) { + arg0 := n + result0 := uint32(arg0) + raw1, err1 := i.module.ExportedFunction("decide").Call(ctx, uint64(result0)) + if err1 != nil { + var default1 Decided + return default1, err1 + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_decide"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + value2, ok2 := i.module.Memory().ReadByte(uint32(results1 + 0)) + if !ok2 { + var default2 Decided + return default2, errors.New("failed to read byte from memory") + } + var value11 Decided + var err11 error + switch value2 { + case 0: + ptr3, ok3 := i.module.Memory().ReadUint32Le(uint32(results1 + 4)) + if !ok3 { + var default3 Decided + return default3, errors.New("failed to read pointer from memory") + } + len4, ok4 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok4 { + var default4 Decided + return default4, errors.New("failed to read length from memory") + } + buf5, ok5 := i.module.Memory().Read(ptr3, len4) + if !ok5 { + var default5 Decided + return default5, errors.New("failed to read bytes from memory") + } + str5 := string(buf5) + value6, ok6 := i.module.Memory().ReadUint32Le(uint32(results1 + 12)) + if !ok6 { + var default6 Decided + return default6, errors.New("failed to read i32 from memory") + } + result7 := uint32(value6) + value8 := Decided{ + Name: str5, + N: result7, + } + value11 = value8 + case 1: + value9, ok9 := i.module.Memory().ReadByte(uint32(results1 + 4)) + if !ok9 { + var default9 Decided + return default9, errors.New("failed to read byte from memory") + } + var enum10 Refusal + switch value9 { + case 0: + enum10 = RefusalNoCredential + case 1: + enum10 = RefusalMalformed + case 2: + enum10 = RefusalUntypedPeer + case 3: + enum10 = RefusalForeignDomain + default: + var default0 Decided + return default0, errors.New("invalid refusal discriminant") + } + err11 = &ResultError[Refusal]{Value: enum10} + default: + err11 = errors.New("invalid variant discriminant for expected") + } + return value11, err11 +} + +func (i *ResultErrorsInstance) Inspect( + ctx context.Context, + n uint32, +) (uint32, error) { + arg0 := n + result0 := uint32(arg0) + raw1, err1 := i.module.ExportedFunction("inspect").Call(ctx, uint64(result0)) + if err1 != nil { + var default1 uint32 + return default1, err1 + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_inspect"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + value2, ok2 := i.module.Memory().ReadByte(uint32(results1 + 0)) + if !ok2 { + var default2 uint32 + return default2, errors.New("failed to read byte from memory") + } + var value11 uint32 + var err11 error + switch value2 { + case 0: + value3, ok3 := i.module.Memory().ReadUint32Le(uint32(results1 + 4)) + if !ok3 { + var default3 uint32 + return default3, errors.New("failed to read i32 from memory") + } + result4 := uint32(value3) + value11 = result4 + case 1: + value5, ok5 := i.module.Memory().ReadUint32Le(uint32(results1 + 4)) + if !ok5 { + var default5 uint32 + return default5, errors.New("failed to read i32 from memory") + } + result6 := uint32(value5) + ptr7, ok7 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok7 { + var default7 uint32 + return default7, errors.New("failed to read pointer from memory") + } + len8, ok8 := i.module.Memory().ReadUint32Le(uint32(results1 + 12)) + if !ok8 { + var default8 uint32 + return default8, errors.New("failed to read length from memory") + } + buf9, ok9 := i.module.Memory().Read(ptr7, len8) + if !ok9 { + var default9 uint32 + return default9, errors.New("failed to read bytes from memory") + } + str9 := string(buf9) + value10 := Problem{ + Code: result6, + Detail: str9, + } + err11 = &ResultError[Problem]{Value: value10} + default: + err11 = errors.New("invalid variant discriminant for expected") + } + return value11, err11 +} + +func (i *ResultErrorsInstance) Classify( + ctx context.Context, + n uint32, +) (string, error) { + arg0 := n + result0 := uint32(arg0) + raw1, err1 := i.module.ExportedFunction("classify").Call(ctx, uint64(result0)) + if err1 != nil { + var default1 string + return default1, err1 + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_classify"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + value2, ok2 := i.module.Memory().ReadByte(uint32(results1 + 0)) + if !ok2 { + var default2 string + return default2, errors.New("failed to read byte from memory") + } + var value19 string + var err19 error + switch value2 { + case 0: + ptr3, ok3 := i.module.Memory().ReadUint32Le(uint32(results1 + 4)) + if !ok3 { + var default3 string + return default3, errors.New("failed to read pointer from memory") + } + len4, ok4 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok4 { + var default4 string + return default4, errors.New("failed to read length from memory") + } + buf5, ok5 := i.module.Memory().Read(ptr3, len4) + if !ok5 { + var default5 string + return default5, errors.New("failed to read bytes from memory") + } + str5 := string(buf5) + value19 = str5 + case 1: + value6, ok6 := i.module.Memory().ReadByte(uint32(results1 + 4)) + if !ok6 { + var default6 string + return default6, errors.New("failed to read byte from memory") + } + var value18 Fault + switch value6 { + case 0: + value7, ok7 := i.module.Memory().ReadByte(uint32(results1 + 8)) + if !ok7 { + var default7 string + return default7, errors.New("failed to read byte from memory") + } + var enum8 Refusal + switch value7 { + case 0: + enum8 = RefusalNoCredential + case 1: + enum8 = RefusalMalformed + case 2: + enum8 = RefusalUntypedPeer + case 3: + enum8 = RefusalForeignDomain + default: + var default0 string + return default0, errors.New("invalid refusal discriminant") + } + value18 = FaultRefused{Value: enum8} + case 1: + value9, ok9 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok9 { + var default9 string + return default9, errors.New("failed to read i32 from memory") + } + result10 := uint32(value9) + ptr11, ok11 := i.module.Memory().ReadUint32Le(uint32(results1 + 12)) + if !ok11 { + var default11 string + return default11, errors.New("failed to read pointer from memory") + } + len12, ok12 := i.module.Memory().ReadUint32Le(uint32(results1 + 16)) + if !ok12 { + var default12 string + return default12, errors.New("failed to read length from memory") + } + buf13, ok13 := i.module.Memory().Read(ptr11, len12) + if !ok13 { + var default13 string + return default13, errors.New("failed to read bytes from memory") + } + str13 := string(buf13) + value14 := Problem{ + Code: result10, + Detail: str13, + } + value18 = value14 + case 2: + ptr15, ok15 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok15 { + var default15 string + return default15, errors.New("failed to read pointer from memory") + } + len16, ok16 := i.module.Memory().ReadUint32Le(uint32(results1 + 12)) + if !ok16 { + var default16 string + return default16, errors.New("failed to read length from memory") + } + buf17, ok17 := i.module.Memory().Read(ptr15, len16) + if !ok17 { + var default17 string + return default17, errors.New("failed to read bytes from memory") + } + str17 := string(buf17) + value18 = FaultMessage{Value: str17} + case 3: + value18 = FaultUnknown{} + default: + var default0 string + return default0, errors.New("invalid fault discriminant") + } + err19 = &ResultError[Fault]{Value: value18} + default: + err19 = errors.New("invalid variant discriminant for expected") + } + return value19, err19 +} + +func (i *ResultErrorsInstance) OnlyErr( + ctx context.Context, + n uint32, +) error { + arg0 := n + result0 := uint32(arg0) + raw1, err1 := i.module.ExportedFunction("only-err").Call(ctx, uint64(result0)) + if err1 != nil { + return err1 + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_only-err"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + value2, ok2 := i.module.Memory().ReadByte(uint32(results1 + 0)) + if !ok2 { + return errors.New("failed to read byte from memory") + } + var err5 error + switch value2 { + case 0:case 1: + value3, ok3 := i.module.Memory().ReadByte(uint32(results1 + 1)) + if !ok3 { + return errors.New("failed to read byte from memory") + } + var enum4 Refusal + switch value3 { + case 0: + enum4 = RefusalNoCredential + case 1: + enum4 = RefusalMalformed + case 2: + enum4 = RefusalUntypedPeer + case 3: + enum4 = RefusalForeignDomain + default: + return errors.New("invalid refusal discriminant") + } + err5 = &ResultError[Refusal]{Value: enum4} + default: + err5 = errors.New("invalid variant discriminant for expected") + } + return err5 +} + +func (i *ResultErrorsInstance) ViaCheck( + ctx context.Context, + n uint32, +) (uint32, error) { + arg0 := n + result0 := uint32(arg0) + raw1, err1 := i.module.ExportedFunction("via-check").Call(ctx, uint64(result0)) + if err1 != nil { + var default1 uint32 + return default1, err1 + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_via-check"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + value2, ok2 := i.module.Memory().ReadByte(uint32(results1 + 0)) + if !ok2 { + var default2 uint32 + return default2, errors.New("failed to read byte from memory") + } + var value7 uint32 + var err7 error + switch value2 { + case 0: + value3, ok3 := i.module.Memory().ReadUint32Le(uint32(results1 + 4)) + if !ok3 { + var default3 uint32 + return default3, errors.New("failed to read i32 from memory") + } + result4 := uint32(value3) + value7 = result4 + case 1: + value5, ok5 := i.module.Memory().ReadByte(uint32(results1 + 4)) + if !ok5 { + var default5 uint32 + return default5, errors.New("failed to read byte from memory") + } + var enum6 Refusal + switch value5 { + case 0: + enum6 = RefusalNoCredential + case 1: + enum6 = RefusalMalformed + case 2: + enum6 = RefusalUntypedPeer + case 3: + enum6 = RefusalForeignDomain + default: + var default0 uint32 + return default0, errors.New("invalid refusal discriminant") + } + err7 = &ResultError[Refusal]{Value: enum6} + default: + err7 = errors.New("invalid variant discriminant for expected") + } + return value7, err7 +} + +func (i *ResultErrorsInstance) ViaProbe( + ctx context.Context, + n uint32, +) error { + arg0 := n + result0 := uint32(arg0) + raw1, err1 := i.module.ExportedFunction("via-probe").Call(ctx, uint64(result0)) + if err1 != nil { + return err1 + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_via-probe"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + value2, ok2 := i.module.Memory().ReadByte(uint32(results1 + 0)) + if !ok2 { + return errors.New("failed to read byte from memory") + } + var err9 error + switch value2 { + case 0:case 1: + value3, ok3 := i.module.Memory().ReadUint32Le(uint32(results1 + 4)) + if !ok3 { + return errors.New("failed to read i32 from memory") + } + result4 := uint32(value3) + ptr5, ok5 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok5 { + return errors.New("failed to read pointer from memory") + } + len6, ok6 := i.module.Memory().ReadUint32Le(uint32(results1 + 12)) + if !ok6 { + return errors.New("failed to read length from memory") + } + buf7, ok7 := i.module.Memory().Read(ptr5, len6) + if !ok7 { + return errors.New("failed to read bytes from memory") + } + str7 := string(buf7) + value8 := Problem{ + Code: result4, + Detail: str7, + } + err9 = &ResultError[Problem]{Value: value8} + default: + err9 = errors.New("invalid variant discriminant for expected") + } + return err9 +} + +func (i *ResultErrorsInstance) ViaExplain( + ctx context.Context, + n uint32, +) (string, error) { + arg0 := n + result0 := uint32(arg0) + raw1, err1 := i.module.ExportedFunction("via-explain").Call(ctx, uint64(result0)) + if err1 != nil { + var default1 string + return default1, err1 + } + + // The cleanup via `cabi_post_*` cleans up the memory in the guest. By + // deferring this, we ensure that no memory is corrupted before the function + // is done accessing it. + defer func() { + if postFn := i.module.ExportedFunction("cabi_post_via-explain"); postFn != nil { + if _, err := postFn.Call(ctx, raw1...); err != nil { + // If we get an error during cleanup, something really bad is + // going on, so we panic. Also, you can't return the error from + // the `defer` + panic(errors.New("failed to cleanup")) + } + } + }() + + results1 := raw1[0] + value2, ok2 := i.module.Memory().ReadByte(uint32(results1 + 0)) + if !ok2 { + var default2 string + return default2, errors.New("failed to read byte from memory") + } + var value19 string + var err19 error + switch value2 { + case 0: + ptr3, ok3 := i.module.Memory().ReadUint32Le(uint32(results1 + 4)) + if !ok3 { + var default3 string + return default3, errors.New("failed to read pointer from memory") + } + len4, ok4 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok4 { + var default4 string + return default4, errors.New("failed to read length from memory") + } + buf5, ok5 := i.module.Memory().Read(ptr3, len4) + if !ok5 { + var default5 string + return default5, errors.New("failed to read bytes from memory") + } + str5 := string(buf5) + value19 = str5 + case 1: + value6, ok6 := i.module.Memory().ReadByte(uint32(results1 + 4)) + if !ok6 { + var default6 string + return default6, errors.New("failed to read byte from memory") + } + var value18 Fault + switch value6 { + case 0: + value7, ok7 := i.module.Memory().ReadByte(uint32(results1 + 8)) + if !ok7 { + var default7 string + return default7, errors.New("failed to read byte from memory") + } + var enum8 Refusal + switch value7 { + case 0: + enum8 = RefusalNoCredential + case 1: + enum8 = RefusalMalformed + case 2: + enum8 = RefusalUntypedPeer + case 3: + enum8 = RefusalForeignDomain + default: + var default0 string + return default0, errors.New("invalid refusal discriminant") + } + value18 = FaultRefused{Value: enum8} + case 1: + value9, ok9 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok9 { + var default9 string + return default9, errors.New("failed to read i32 from memory") + } + result10 := uint32(value9) + ptr11, ok11 := i.module.Memory().ReadUint32Le(uint32(results1 + 12)) + if !ok11 { + var default11 string + return default11, errors.New("failed to read pointer from memory") + } + len12, ok12 := i.module.Memory().ReadUint32Le(uint32(results1 + 16)) + if !ok12 { + var default12 string + return default12, errors.New("failed to read length from memory") + } + buf13, ok13 := i.module.Memory().Read(ptr11, len12) + if !ok13 { + var default13 string + return default13, errors.New("failed to read bytes from memory") + } + str13 := string(buf13) + value14 := Problem{ + Code: result10, + Detail: str13, + } + value18 = value14 + case 2: + ptr15, ok15 := i.module.Memory().ReadUint32Le(uint32(results1 + 8)) + if !ok15 { + var default15 string + return default15, errors.New("failed to read pointer from memory") + } + len16, ok16 := i.module.Memory().ReadUint32Le(uint32(results1 + 12)) + if !ok16 { + var default16 string + return default16, errors.New("failed to read length from memory") + } + buf17, ok17 := i.module.Memory().Read(ptr15, len16) + if !ok17 { + var default17 string + return default17, errors.New("failed to read bytes from memory") + } + str17 := string(buf17) + value18 = FaultMessage{Value: str17} + case 3: + value18 = FaultUnknown{} + default: + var default0 string + return default0, errors.New("invalid fault discriminant") + } + err19 = &ResultError[Fault]{Value: value18} + default: + err19 = errors.New("invalid variant discriminant for expected") + } + return value19, err19 +} + diff --git a/cmd/gravity/tests/cmd/result-errors.toml b/cmd/gravity/tests/cmd/result-errors.toml new file mode 100644 index 00000000..39712a2f --- /dev/null +++ b/cmd/gravity/tests/cmd/result-errors.toml @@ -0,0 +1,2 @@ +bin.name = "gravity" +args = "--world result-errors ../../target/wasm32-unknown-unknown/release/example_result_errors.wasm" diff --git a/examples/generate.go b/examples/generate.go index ccfa0184..fa202bd3 100644 --- a/examples/generate.go +++ b/examples/generate.go @@ -8,6 +8,7 @@ package examples //go:generate cargo build -p example-regressions --target wasm32-unknown-unknown --release //go:generate cargo build -p example-variants --target wasm32-unknown-unknown --release //go:generate cargo build -p example-enum-collisions --target wasm32-unknown-unknown --release +//go:generate cargo build -p example-result-errors --target wasm32-unknown-unknown --release //go:generate cargo run --bin gravity -- --world basic --output ./basic/basic.go ../target/wasm32-unknown-unknown/release/example_basic.wasm //go:generate cargo run --bin gravity -- --world records --output ./records/records.go ../target/wasm32-unknown-unknown/release/example_records.wasm @@ -17,3 +18,4 @@ package examples //go:generate cargo run --bin gravity -- --world regressions --output ./regressions/regressions.go ../target/wasm32-unknown-unknown/release/example_regressions.wasm //go:generate cargo run --bin gravity -- --world variants --output ./variants/variants.go ../target/wasm32-unknown-unknown/release/example_variants.wasm //go:generate cargo run --bin gravity -- --world enum-collisions --output ./enum-collisions/enum_collisions.go ../target/wasm32-unknown-unknown/release/example_enum_collisions.wasm +//go:generate cargo run --bin gravity -- --world result-errors --output ./result-errors/result_errors.go ../target/wasm32-unknown-unknown/release/example_result_errors.wasm diff --git a/examples/result-errors/Cargo.toml b/examples/result-errors/Cargo.toml new file mode 100644 index 00000000..8667fddf --- /dev/null +++ b/examples/result-errors/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "example-result-errors" +version = "0.0.2" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.62.0" +wit-component = "=0.259.0" diff --git a/examples/result-errors/result_errors_test.go b/examples/result-errors/result_errors_test.go new file mode 100644 index 00000000..3d2a0329 --- /dev/null +++ b/examples/result-errors/result_errors_test.go @@ -0,0 +1,179 @@ +package result_errors + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +// A result whose E is not a string returns its err case as a +// *ResultError[E]; errors.As recovers the E. Before the fix gravity panicked +// generating these bindings: "TODO(#4): implement remaining result conversion". + +func refusalFor(n uint32) Refusal { + return []Refusal{RefusalNoCredential, RefusalMalformed, RefusalUntypedPeer, RefusalForeignDomain}[n%4] +} + +// Host answers each import with the same err cases the guest's exports use, +// and with a plain error (one with no WIT representation) for n == 99. +type Host struct{} + +var errPlain = errors.New("not a WIT error") + +func (Host) Check(_ context.Context, n uint32) (uint32, error) { + switch { + case n == 99: + return 0, errPlain + case n < 4: + return 0, &ResultError[Refusal]{Value: refusalFor(n)} + } + return n + 1, nil +} + +func (Host) Probe(_ context.Context, n uint32) error { + if n%2 == 1 { + return &ResultError[Problem]{Value: Problem{Code: n, Detail: "host"}} + } + return nil +} + +func (Host) Explain(_ context.Context, n uint32) (string, error) { + switch n { + case 0: + return "", &ResultError[Fault]{Value: FaultRefused{Value: RefusalUntypedPeer}} + case 1: + return "", &ResultError[Fault]{Value: Problem{Code: 7, Detail: "x"}} + case 2: + return "", &ResultError[Fault]{Value: FaultMessage{Value: "from host"}} + case 3: + return "", &ResultError[Fault]{Value: FaultUnknown{}} + } + return "host fine", nil +} + +func newInstance(t *testing.T) *ResultErrorsInstance { + t.Helper() + fac, err := NewResultErrorsFactory(t.Context(), nil, Host{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { fac.Close(context.Background()) }) + ins, err := fac.Instantiate(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ins.Close(context.Background()) }) + return ins +} + +// errValue recovers the E of a *ResultError[E], failing the test otherwise. +func errValue[E any](t *testing.T, err error) E { + t.Helper() + var re *ResultError[E] + if !errors.As(err, &re) { + var zero E + t.Fatalf("error %v (%T) is not a *ResultError[%T]", err, err, zero) + } + return re.Value +} + +func TestEnumErr(t *testing.T) { + ins := newInstance(t) + for n := range uint32(4) { + if _, err := ins.Decide(t.Context(), n); errValue[Refusal](t, err) != refusalFor(n) { + t.Errorf("Decide(%d): err %v, want %v", n, err, refusalFor(n)) + } + } + got, err := ins.Decide(t.Context(), 7) + if err != nil || got != (Decided{Name: "n7", N: 7}) { + t.Errorf("Decide(7) = %+v, %v", got, err) + } + // The enum's String names the WIT case, so the error reads as WIT wrote it. + if _, err := ins.Decide(t.Context(), 2); err == nil || err.Error() != "untyped-peer" { + t.Errorf("Decide(2) error text = %v, want untyped-peer", err) + } +} + +func TestRecordErr(t *testing.T) { + ins := newInstance(t) + if got, err := ins.Inspect(t.Context(), 4); err != nil || got != 8 { + t.Errorf("Inspect(4) = %d, %v", got, err) + } + _, err := ins.Inspect(t.Context(), 5) + if p := errValue[Problem](t, err); p != (Problem{Code: 5, Detail: "odd"}) { + t.Errorf("Inspect(5) err = %+v", p) + } +} + +func TestVariantErr(t *testing.T) { + ins := newInstance(t) + for n, want := range map[uint32]Fault{ + 0: FaultRefused{Value: RefusalForeignDomain}, + 1: Problem{Code: 1, Detail: "p"}, + 2: FaultMessage{Value: "m"}, + 3: FaultUnknown{}, + } { + _, err := ins.Classify(t.Context(), n) + if got := errValue[Fault](t, err); !reflect.DeepEqual(got, want) { + t.Errorf("Classify(%d) err = %#v, want %#v", n, got, want) + } + } + if got, err := ins.Classify(t.Context(), 9); err != nil || got != "fine" { + t.Errorf("Classify(9) = %q, %v", got, err) + } +} + +func TestErrWithoutOk(t *testing.T) { + ins := newInstance(t) + if err := ins.OnlyErr(t.Context(), 0); err != nil { + t.Errorf("OnlyErr(0) = %v", err) + } + if got := errValue[Refusal](t, ins.OnlyErr(t.Context(), 3)); got != RefusalForeignDomain { + t.Errorf("OnlyErr(3) err = %v", got) + } +} + +func TestHostReturnsTypedErrs(t *testing.T) { + ins := newInstance(t) + for n := range uint32(4) { + if _, err := ins.ViaCheck(t.Context(), n); errValue[Refusal](t, err) != refusalFor(n) { + t.Errorf("ViaCheck(%d) err = %v", n, err) + } + } + if got, err := ins.ViaCheck(t.Context(), 10); err != nil || got != 11 { + t.Errorf("ViaCheck(10) = %d, %v", got, err) + } + if err := ins.ViaProbe(t.Context(), 2); err != nil { + t.Errorf("ViaProbe(2) = %v", err) + } + if p := errValue[Problem](t, ins.ViaProbe(t.Context(), 3)); p != (Problem{Code: 3, Detail: "host"}) { + t.Errorf("ViaProbe(3) err = %+v", p) + } + for n, want := range map[uint32]Fault{ + 0: FaultRefused{Value: RefusalUntypedPeer}, + 1: Problem{Code: 7, Detail: "x"}, + 2: FaultMessage{Value: "from host"}, + 3: FaultUnknown{}, + } { + _, err := ins.ViaExplain(t.Context(), n) + if got := errValue[Fault](t, err); !reflect.DeepEqual(got, want) { + t.Errorf("ViaExplain(%d) err = %#v, want %#v", n, got, want) + } + } +} + +// A host error with no WIT representation traps the guest: the export +// returns an error, and it is not a *ResultError. +func TestHostPlainErrorTraps(t *testing.T) { + ins := newInstance(t) + _, err := ins.ViaCheck(t.Context(), 99) + var re *ResultError[Refusal] + if err == nil || errors.As(err, &re) { + t.Fatalf("ViaCheck(99) err = %v, want a trap", err) + } + if !strings.Contains(err.Error(), errPlain.Error()) { + t.Errorf("trap %q does not carry the host's error", err) + } +} diff --git a/examples/result-errors/src/lib.rs b/examples/result-errors/src/lib.rs new file mode 100644 index 00000000..8515fa4b --- /dev/null +++ b/examples/result-errors/src/lib.rs @@ -0,0 +1,71 @@ +use gravity::result_errors::host; + +wit_bindgen::generate!({ + world: "result-errors", +}); + +struct ResultErrors; + +export!(ResultErrors); + +fn refusal(n: u32) -> Refusal { + match n % 4 { + 0 => Refusal::NoCredential, + 1 => Refusal::Malformed, + 2 => Refusal::UntypedPeer, + _ => Refusal::ForeignDomain, + } +} + +impl Guest for ResultErrors { + fn decide(n: u32) -> Result { + if n < 4 { + Err(refusal(n)) + } else { + Ok(Decided { + name: format!("n{n}"), + n, + }) + } + } + + fn inspect(n: u32) -> Result { + if n.is_multiple_of(2) { + Ok(n * 2) + } else { + Err(Problem { + code: n, + detail: "odd".into(), + }) + } + } + + fn classify(n: u32) -> Result { + match n { + 0 => Err(Fault::Refused(Refusal::ForeignDomain)), + 1 => Err(Fault::Problem(Problem { + code: 1, + detail: "p".into(), + })), + 2 => Err(Fault::Message("m".into())), + 3 => Err(Fault::Unknown), + _ => Ok("fine".into()), + } + } + + fn only_err(n: u32) -> Result<(), Refusal> { + if n == 0 { Ok(()) } else { Err(refusal(n)) } + } + + fn via_check(n: u32) -> Result { + host::check(n) + } + + fn via_probe(n: u32) -> Result<(), Problem> { + host::probe(n) + } + + fn via_explain(n: u32) -> Result { + host::explain(n) + } +} diff --git a/examples/result-errors/wit/result-errors.wit b/examples/result-errors/wit/result-errors.wit new file mode 100644 index 00000000..5ad95c45 --- /dev/null +++ b/examples/result-errors/wit/result-errors.wit @@ -0,0 +1,39 @@ +package gravity:result-errors; + +// result where E is not a string: an enum, a record, a variant, with +// and without an ok type, returned by the guest and by the host. Gravity used +// to lower only result and panicked generating anything else +// ("TODO(#4): implement remaining result conversion"). +interface types { + enum refusal { no-credential, malformed, untyped-peer, foreign-domain } + record problem { code: u32, detail: string } + variant fault { + refused(refusal), + problem(problem), + message(string), + unknown, + } + record decided { name: string, n: u32 } +} + +interface host { + use types.{refusal, problem, fault}; + + check: func(n: u32) -> result; + probe: func(n: u32) -> result<_, problem>; + explain: func(n: u32) -> result; +} + +world result-errors { + use types.{refusal, problem, fault, decided}; + import host; + + export decide: func(n: u32) -> result; + export inspect: func(n: u32) -> result; + export classify: func(n: u32) -> result; + export only-err: func(n: u32) -> result<_, refusal>; + + export via-check: func(n: u32) -> result; + export via-probe: func(n: u32) -> result<_, problem>; + export via-explain: func(n: u32) -> result; +} From 8dea71f7605b0d1cbde047f9a81b2973e86b59ed Mon Sep 17 00:00:00 2001 From: robfischer1 <15163713+robfischer1@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:34:01 -0400 Subject: [PATCH 3/4] fix!: ask no host for an interface imported only for its types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A world that imports an interface only for its types — through `use types.{...}`, or as the dependency of an exported interface — imports no function from it. Gravity still emitted an empty Go interface (`type IIdentityCoreTypes interface {}`), a factory parameter of that type and an empty host module, so every caller passed a placeholder (the identity lift's `struct{}{}`, the spike's leading nil). Such an interface now gets none of the three; its types are still generated. BREAKING: NewFactory loses the parameter for each types-only interface. Tests: imports::tests::test_types_only_interface_needs_no_host (fails without the fix: a host module chain and ITestWorldTypes are emitted); the enum-collisions and result-errors examples call their factories without the placeholder (without the fix they do not compile: not enough arguments). Co-Authored-By: Claude Opus 5.5 --- cmd/gravity/src/codegen/factory.rs | 2 +- cmd/gravity/src/codegen/imports.rs | 48 ++++++++++++++++++- cmd/gravity/src/codegen/ir.rs | 10 ++++ cmd/gravity/tests/cmd/enum-collisions.stdout | 9 ---- cmd/gravity/tests/cmd/result-errors.stdout | 8 ---- .../enum-collisions/enum_collisions_test.go | 6 +-- examples/result-errors/result_errors_test.go | 2 +- 7 files changed, 62 insertions(+), 23 deletions(-) diff --git a/cmd/gravity/src/codegen/factory.rs b/cmd/gravity/src/codegen/factory.rs index bf2fb69d..04a63eaa 100644 --- a/cmd/gravity/src/codegen/factory.rs +++ b/cmd/gravity/src/codegen/factory.rs @@ -176,7 +176,7 @@ impl<'a> FactoryGenerator<'a> { quote! { ctx $CONTEXT_CONTEXT, - $(for interface in interfaces.iter() join ($['\r']) => + $(for interface in interfaces.iter().filter(|i| i.needs_host()) join ($['\r']) => $(&interface.constructor_param_name) $(&interface.go_interface_name), ) } diff --git a/cmd/gravity/src/codegen/imports.rs b/cmd/gravity/src/codegen/imports.rs index e5ae80f8..7ebd0b60 100644 --- a/cmd/gravity/src/codegen/imports.rs +++ b/cmd/gravity/src/codegen/imports.rs @@ -287,7 +287,12 @@ impl<'a> ImportCodeGenerator<'a> { pub fn import_chains(&self) -> BTreeMap> { let mut chains = BTreeMap::new(); + // An interface with no functions (one a world imports only for its + // types) gets no host module: the guest imports nothing from it. for (i, interface) in self.analyzed.interfaces.iter().enumerate() { + if !interface.needs_host() { + continue; + } let err = &GoIdentifier::private(format!("err{i}")); let mut chain = quote! { _, $err := wazeroRuntime.NewHostModuleBuilder($(quoted(&interface.wazero_module_name))). @@ -321,7 +326,9 @@ impl FormatInto for ImportCodeGenerator<'_> { fn format_into(self, tokens: &mut Tokens) { // Generate interface type definitions for interface in &self.analyzed.interfaces { - self.generate_interface_type(interface, tokens); + if interface.needs_host() { + self.generate_interface_type(interface, tokens); + } for typ in &interface.types { self.generate_type_definition(typ, tokens); @@ -1064,4 +1071,43 @@ mod tests { ); } } + + /// Regression test: a world that imports an interface only for its types + /// (here through `use`) needs no host for it. Gravity used to emit an + /// empty Go interface, a factory argument of that type and an empty host + /// module, so every caller passed a placeholder. + #[test] + fn test_types_only_interface_needs_no_host() { + let fixture = Fixture::parse( + "package test:fixture; + interface types { + record point { x: u32 } + } + world test-world { + use types.{point}; + export f: func(p: point) -> point; + }", + ); + let analyzed = ImportAnalyzer::new(&fixture.resolve, fixture.world()).analyze(); + assert_eq!( + analyzed.interfaces.len(), + 1, + "types is an import of the world" + ); + assert!(!analyzed.interfaces[0].needs_host()); + + let generator = ImportCodeGenerator::new(&fixture.resolve, &analyzed, &fixture.sizes); + assert!( + generator.import_chains().is_empty(), + "no host module for a types-only interface" + ); + let mut tokens = Tokens::new(); + generator.format_into(&mut tokens); + let generated = tokens.to_string().unwrap(); + assert!(generated.contains("type Point struct"), "got:\n{generated}"); + assert!( + !generated.contains("ITestWorldTypes"), + "no Go interface for a types-only interface, got:\n{generated}" + ); + } } diff --git a/cmd/gravity/src/codegen/ir.rs b/cmd/gravity/src/codegen/ir.rs index a141fadb..af2fcabc 100644 --- a/cmd/gravity/src/codegen/ir.rs +++ b/cmd/gravity/src/codegen/ir.rs @@ -58,6 +58,16 @@ pub struct AnalyzedInterface { pub wazero_module_name: String, } +impl AnalyzedInterface { + /// Whether the host must implement this interface. A world that imports + /// an interface only for its types (`use types.{...}`, or an exported + /// interface's dependencies) imports no function from it, so it needs + /// no Go interface, no factory argument and no host module. + pub fn needs_host(&self) -> bool { + !self.methods.is_empty() + } +} + /// Method signature for an interface #[derive(Debug, Clone)] pub struct InterfaceMethod { diff --git a/cmd/gravity/tests/cmd/enum-collisions.stdout b/cmd/gravity/tests/cmd/enum-collisions.stdout index 1c13d429..1c22cea3 100644 --- a/cmd/gravity/tests/cmd/enum-collisions.stdout +++ b/cmd/gravity/tests/cmd/enum-collisions.stdout @@ -13,8 +13,6 @@ import _ "embed" //go:embed enum_collisions.wasm var wasmFileEnumCollisions []byte -type IEnumCollisionsTypes interface {} - type Via interface { isVia() } @@ -102,16 +100,9 @@ type EnumCollisionsFactory struct { func NewEnumCollisionsFactory( ctx context.Context, - types IEnumCollisionsTypes, ) (*EnumCollisionsFactory, error) { wazeroRuntime := wazero.NewRuntime(ctx) - _, err0 := wazeroRuntime.NewHostModuleBuilder("gravity:enum-collisions/types"). - Instantiate(ctx) - if err0 != nil { - return nil, err0 - } - // Compiling the module takes a LONG time, so we want to do it once and hold // onto it with the Runtime module, err := wazeroRuntime.CompileModule(ctx, wasmFileEnumCollisions) diff --git a/cmd/gravity/tests/cmd/result-errors.stdout b/cmd/gravity/tests/cmd/result-errors.stdout index bfc04969..ec029744 100644 --- a/cmd/gravity/tests/cmd/result-errors.stdout +++ b/cmd/gravity/tests/cmd/result-errors.stdout @@ -13,8 +13,6 @@ import _ "embed" //go:embed result_errors.wasm var wasmFileResultErrors []byte -type IResultErrorsTypes interface {} - type Refusal interface { isRefusal() } @@ -98,7 +96,6 @@ type ResultErrorsFactory struct { func NewResultErrorsFactory( ctx context.Context, - types IResultErrorsTypes, host IResultErrorsHost, ) (*ResultErrorsFactory, error) { wazeroRuntime := wazero.NewRuntime(ctx) @@ -260,11 +257,6 @@ func NewResultErrorsFactory( if err1 != nil { return nil, err1 } - _, err0 := wazeroRuntime.NewHostModuleBuilder("gravity:result-errors/types"). - Instantiate(ctx) - if err0 != nil { - return nil, err0 - } // Compiling the module takes a LONG time, so we want to do it once and hold // onto it with the Runtime diff --git a/examples/enum-collisions/enum_collisions_test.go b/examples/enum-collisions/enum_collisions_test.go index f6c27ff7..7bb90706 100644 --- a/examples/enum-collisions/enum_collisions_test.go +++ b/examples/enum-collisions/enum_collisions_test.go @@ -11,9 +11,9 @@ import ( func newInstance(t *testing.T) *EnumCollisionsInstance { t.Helper() - // The world's `use types.{...}` makes `types` an import with no - // functions, so its host argument has nothing to implement. - fac, err := NewEnumCollisionsFactory(t.Context(), nil) + // The world imports `types` only for its types, so the factory takes no + // host argument for it. + fac, err := NewEnumCollisionsFactory(t.Context()) if err != nil { t.Fatal(err) } diff --git a/examples/result-errors/result_errors_test.go b/examples/result-errors/result_errors_test.go index 3d2a0329..26b0f399 100644 --- a/examples/result-errors/result_errors_test.go +++ b/examples/result-errors/result_errors_test.go @@ -55,7 +55,7 @@ func (Host) Explain(_ context.Context, n uint32) (string, error) { func newInstance(t *testing.T) *ResultErrorsInstance { t.Helper() - fac, err := NewResultErrorsFactory(t.Context(), nil, Host{}) + fac, err := NewResultErrorsFactory(t.Context(), Host{}) if err != nil { t.Fatal(err) } From 8ab36fd02338d4d5abe358977204fe43dbe27fd5 Mon Sep 17 00:00:00 2001 From: robfischer1 <15163713+robfischer1@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:43:56 -0400 Subject: [PATCH 4/4] fix: name each host module with its interface's full id A host module was named `:/`, without the package version. A guest built from a versioned package imports `:/@`, so instantiation failed: module[gravity:interface-exports/log@0.1.0] not instantiated The module is now named by Resolve::id_of, the interface's full id, which carries the version when the package has one. A package declared as `package example:pkg@0.1.0` names its imports with that version, so any import it gains needs this. Tests: imports::tests::test_host_module_name_carries_the_package_version (fails without the fix: "test:fixture/host" for "test:fixture/host@1.2.3"); the interface-exports example (next commit) imports from a versioned package at runtime. Co-Authored-By: Claude Opus 5.5 --- cmd/gravity/src/codegen/imports.rs | 40 +++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/cmd/gravity/src/codegen/imports.rs b/cmd/gravity/src/codegen/imports.rs index 7ebd0b60..971bf2ed 100644 --- a/cmd/gravity/src/codegen/imports.rs +++ b/cmd/gravity/src/codegen/imports.rs @@ -94,15 +94,13 @@ impl<'a> ImportAnalyzer<'a> { let go_interface_name = GoIdentifier::public(format!("i-{}-{}", self.world.name, interface_name)); - let wazero_module_name = if let Some(package_id) = interface.package { - let package = &self.resolve.packages[package_id]; - format!( - "{}:{}/{}", - package.name.namespace, package.name.name, interface_name - ) - } else { - interface_name.to_string() - }; + // The module the guest imports from: the interface's full id, which + // carries the package version when there is one + // (`example:pkg/types@0.1.0`). + let wazero_module_name = self + .resolve + .id_of(interface_id) + .unwrap_or_else(|| interface_name.to_string()); AnalyzedInterface { name: interface_name.clone(), @@ -1110,4 +1108,28 @@ mod tests { "no Go interface for a types-only interface, got:\n{generated}" ); } + + /// Regression test: a guest built from a versioned package imports + /// `:/@`. The host module was + /// named without the version, so instantiation failed: + /// "module[gravity:interface-exports/log@0.1.0] not instantiated". + #[test] + fn test_host_module_name_carries_the_package_version() { + let fixture = Fixture::parse( + "package test:fixture@1.2.3; + interface host { + ping: func(); + } + world test-world { + import host; + }", + ); + let analyzed = ImportAnalyzer::new(&fixture.resolve, fixture.world()).analyze(); + let names: Vec<&str> = analyzed + .interfaces + .iter() + .map(|i| i.wazero_module_name.as_str()) + .collect(); + assert_eq!(names, ["test:fixture/host@1.2.3"]); + } }