diff --git a/.github/workflows/CargoPublish.yml b/.github/workflows/CargoPublish.yml index 68b89ef31..3b401d72f 100644 --- a/.github/workflows/CargoPublish.yml +++ b/.github/workflows/CargoPublish.yml @@ -52,7 +52,7 @@ jobs: VERSION="${VERSION#refs/heads/release/v}" echo "VERSION=$VERSION" >> $GITHUB_ENV fi - ./dev/verify-version.sh "$VERSION" hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-guest-tracing + ./dev/verify-version.sh "$VERSION" hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-component-csharp hyperlight-guest-tracing - name: Determine which crates need publishing run: | @@ -64,6 +64,7 @@ jobs: hyperlight-guest-bin \ hyperlight-component-util \ hyperlight-component-macro \ + hyperlight-component-csharp \ hyperlight-host \ hyperlight-guest-tracing >> "$GITHUB_ENV" @@ -134,6 +135,13 @@ jobs: CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} if: env.PUBLISH_HYPERLIGHT_HOST != 'false' + - name: Publish hyperlight-component-csharp + continue-on-error: ${{ inputs.dry_run }} + run: cargo publish --manifest-path ./src/hyperlight_component_csharp/Cargo.toml ${{ inputs.dry_run && '--dry-run' || '' }} + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }} + if: env.PUBLISH_HYPERLIGHT_COMPONENT_CSHARP != 'false' + # TODO: Do we want to publish hyperlight-guest-capi to crates.io given that it's not for Rust consumption? # - name: Publish hyperlight-guest-capi diff --git a/.github/workflows/dep_code_checks.yml b/.github/workflows/dep_code_checks.yml index 28d239339..39dac8f59 100644 --- a/.github/workflows/dep_code_checks.yml +++ b/.github/workflows/dep_code_checks.yml @@ -98,6 +98,10 @@ jobs: - name: Run loom concurrency tests run: just test-loom + # Uses the .NET SDK installed in the runner image. + - name: Test generated C# bindings + run: just test-csharp-bindings + # A job step, not a post-job step, so the save runs before rust-cache's # post-job cleanup deletes the sysroot. Only main saves, as caches are not # shared across branches. @@ -185,6 +189,10 @@ jobs: just test-compilation-no-default-features debug just test-compilation-no-default-features release + # Uses the .NET SDK installed in the runner image. + - name: Test generated C# bindings + run: just test-csharp-bindings + # A job step, not a post-job step, so the save runs before rust-cache's # post-job cleanup deletes the sysroot. Only main saves, as caches are not # shared across branches. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eac2ff63..bd179cf24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Prerelease] - Unreleased ### Added +* `hyperlight-component-csharp` crate for generating C# host bindings from WIT worlds by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1946 ### Changed +* **Breaking:** `hyperlight-component-macro`: `host_bindgen!` and `guest_bindgen!` encode values little-endian and qualify function names with their interface, such as `wasi:http/types@0.2.0#[method]fields.get` by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1946 ### Removed diff --git a/Cargo.lock b/Cargo.lock index b39a56b86..1f577378a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,6 +1655,14 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "hyperlight-component-csharp" +version = "0.18.0" +dependencies = [ + "hyperlight-component-util", + "proc-macro2", +] + [[package]] name = "hyperlight-component-macro" version = "0.18.0" diff --git a/Cargo.toml b/Cargo.toml index 60ee2443d..9670e0f5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "src/hyperlight_libc", "src/hyperlight_component_util", "src/hyperlight_component_macro", + "src/hyperlight_component_csharp", "src/trace_dump", ] # Guests have custom linker flags and live in their own nested workspace, @@ -47,6 +48,7 @@ hyperlight-guest-tracing = { path = "src/hyperlight_guest_tracing", version = "0 hyperlight-libc = { path = "src/hyperlight_libc", version = "0.18.0", default-features = false } hyperlight-component-util = { path = "src/hyperlight_component_util", version = "0.18.0", default-features = false } hyperlight-component-macro = { path = "src/hyperlight_component_macro", version = "0.18.0", default-features = false } +hyperlight-component-csharp = { path = "src/hyperlight_component_csharp", version = "0.18.0", default-features = false } [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/Justfile b/Justfile index 0ccfb4c2f..35c3748c1 100644 --- a/Justfile +++ b/Justfile @@ -151,7 +151,7 @@ code-checks-like-ci config=default-target hypervisor="kvm": {{ if os() == "linux" { "just clippy-exhaustive " + config } else { "" } }} @# Verify MSRV - ./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-guest-tracing + ./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-component-csharp hyperlight-guest-tracing @# Check cargo features compile just check @@ -160,6 +160,9 @@ code-checks-like-ci config=default-target hypervisor="kvm": just test-compilation-no-default-features debug just test-compilation-no-default-features release + @# Test generated C# bindings + just test-csharp-bindings + build-guests-like-ci config=default-target: @# Build and move Rust guests just build-rust-guests {{config}} @@ -285,8 +288,9 @@ test-integration target=default-target features="": @# run execute_on_heap test with feature "executable_heap" on (runs with off during normal tests) {{ cargo-cmd }} test {{ if features =="" {"--features executable_heap"} else if features=="no-default-features" {"--no-default-features --features executable_heap"} else {"--no-default-features -F executable_heap," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test integration_test execute_on_heap - @# run component-util integration tests that exercise WIT input codegen - {{ cargo-cmd }} test -p hyperlight-component-util --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test wasmtime_guest_codegen + @# run component-util unit tests and the integration tests that exercise WIT input codegen + {{ cargo-cmd }} test -p hyperlight-component-util --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --lib --test wasmtime_guest_codegen + {{ cargo-cmd }} test -p hyperlight-component-csharp --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --lib @# run the rest of the integration tests. `snapshot_goldens` is in @# the glob but no-ops without a mode token, so it needs no golden @@ -336,6 +340,10 @@ test-rust-tracing target=default-target features="": test-doc target=default-target features="": {{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} {{ if features =="" {''} else { "--features " + features } }} --doc +# generates and tests C# bindings for the component test fixtures. Needs the .NET 8 SDK or newer. +test-csharp-bindings: + dotnet run --project src/tests/component_wire/csharp + # Nextest runs one Miri process per job, each with its own interpreter, so # memory grows with the job count at roughly 0.4GB per job. Lower MIRI_JOBS on # a machine with many cores but little RAM. @@ -414,6 +422,7 @@ clippy-exhaustive target=default-target: ./hack/clippy-package-features.sh hyperlight-testing {{ target }} {{ target-triple }} ./hack/clippy-package-features.sh hyperlight-component-macro {{ target }} {{ target-triple }} ./hack/clippy-package-features.sh hyperlight-component-util {{ target }} {{ target-triple }} + ./hack/clippy-package-features.sh hyperlight-component-csharp {{ target }} {{ target-triple }} {{ if hyperlight-target-arch == "x86_64" { "./hack/clippy-package-features.sh hyperlight-guest-tracing " + target } else { "" } }} just clippy-guests {{ target }} @@ -423,7 +432,7 @@ clippy-package package target=default-target: # Verify Minimum Supported Rust Version verify-msrv: - ./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-guest-tracing + ./dev/verify-msrv.sh hyperlight-common hyperlight-guest hyperlight-guest-bin hyperlight-host hyperlight-component-util hyperlight-component-macro hyperlight-component-csharp hyperlight-guest-tracing ##################### ### RUST EXAMPLES ### @@ -548,7 +557,7 @@ ensure-cargo-llvm-cov: # host-side packages to collect coverage for (guest/no_std crates are excluded because they # define #[panic_handler] and cannot be compiled for the host target under coverage instrumentation) -coverage-packages := "-p hyperlight-common -p hyperlight-host -p hyperlight-testing -p hyperlight-component-util -p hyperlight-component-macro" +coverage-packages := "-p hyperlight-common -p hyperlight-host -p hyperlight-testing -p hyperlight-component-util -p hyperlight-component-macro -p hyperlight-component-csharp" # run all tests and examples with coverage instrumentation, collecting profdata without # generating a report. Mirrors test-like-ci + run-examples-like-ci to exercise all code paths diff --git a/docs/README.md b/docs/README.md index 9682b9269..918c5e244 100644 --- a/docs/README.md +++ b/docs/README.md @@ -36,6 +36,7 @@ This project is composed internally of several components, depicted in the below * [Security guidance for developers](./security-guidance-for-developers.md) * [Paging Development Notes](./paging-development-notes.md) * [Virtqueue host and guest communication](./virtio-host-guest-communication.md) +* [Component wire format](./component-wire-format.md) * [How to debug a Hyperlight guest](./how-to-debug-a-hyperlight-guest.md) * [How to use Flatbuffers in Hyperlight](./how-to-use-flatbuffers.md) * [How to make a Hyperlight release](./how-to-make-releases.md) diff --git a/docs/component-wire-format.md b/docs/component-wire-format.md new file mode 100644 index 000000000..7e3230f23 --- /dev/null +++ b/docs/component-wire-format.md @@ -0,0 +1,99 @@ +# Component wire format + +Bindings generated from WIT exchange values in this format. It is used by +`host_bindgen!`, `guest_bindgen!`, the hyperlight-wasm guest runtime, and the +C# generator in `hyperlight-component-csharp`. The host and guest must be built +from the same Hyperlight version. + +## Calls + +A component function is a Hyperlight guest or host function. Each WIT +parameter is a separate `VecBytes` argument holding the encoded value. The +result is one `VecBytes` value. A function without a result returns an empty +buffer. + +## Function names + +| Function | Wire name | Example | +|---|---|---| +| Member of an imported or exported instance | `#` | `wasi:http/types@0.2.0#[method]fields.get` | +| Imported or exported by the world itself | `` | `run` | + +`` and `` are the import and export names in the +component type, including package versions and resource method prefixes. + +## Values + +Integers, floats and lengths are little-endian. Compound values concatenate +their parts with no alignment. + +| WIT type | Encoding | +|---|---| +| `bool` | 1 byte, `0` or `1` | +| `s8`, `u8` | 1 byte | +| `s16`, `u16` | 2 bytes | +| `s32`, `u32`, `f32` | 4 bytes | +| `s64`, `u64`, `f64` | 8 bytes | +| `char` | Unicode scalar value as `u32` | +| `string` | `u32` byte length, then UTF-8 bytes | +| `list` | `u32` element count, then each element | +| `list` | `N` elements | +| `record`, `tuple` | Fields in declaration order | +| `option` | `u8` tag. `0` is `none`. `1` is `some`, followed by the payload | +| `result` | `u8` tag. `0` is `ok` and `1` is `err`, followed by the payload if the case has a type | +| `variant` | `u32` case index, followed by the payload if the case has a type | +| `enum` | `u32` case index | +| `flags` | Bitset of `ceil(n / 8)` bytes. Flag `i` is bit `i % 8` of byte `i / 8` | +| `own`, `borrow` | `u32` handle | + +[`src/tests/component_wire/vectors.txt`](../src/tests/component_wire/vectors.txt) +lists encodings of the types in +[`wire.wit`](../src/tests/component_wire/wire.wit). The Rust host bindings and +the C# bindings are both tested against it. +[`csharp/edge.wit`](../src/tests/component_wire/csharp/edge.wit) covers C# +naming cases. `just test-csharp-bindings` generates C# for both and runs the C# +tests. + +## C# host bindings + +`hyperlight_component_csharp::generate_from_path` generates C# for the host +side of a world. The generated code depends on the `Hyperlight.Component` +namespace in `hyperlight_component_csharp::RUNTIME_SOURCE`. It targets .NET 8 +or later. + +For a world `w`, the generator emits: + +* `WComponent`, constructed from an `IGuestCaller`. It has a property for each + exported interface and a method for each function the world exports. +* `WComponent.RegisterImports`, which registers host functions on an + `IHostFunctionRegistry` from an implementation of each imported interface. +* A C# interface `IName` for each imported interface `name`, and `IWImports` + for functions the world imports. +* Types for every record, variant, enum and flags in the world's interfaces. + +| WIT type | C# type | +|---|---| +| `bool`, integers, floats | `bool`, `sbyte` to `ulong`, `float`, `double` | +| `char` | `System.Text.Rune` | +| `string` | `string` | +| `list`, `list` | `byte[]` | +| `list`, `list` | `IReadOnlyList` | +| `tuple<...>` | Value tuple | +| `option` | `T?`, or `Option` when `T` is an `option` | +| `result` | `Result`, with `Unit` for a missing type | +| `record` | Sealed record with `required init` properties | +| `variant` | Abstract record with a nested sealed record per case | +| `enum` | `enum` with `uint` values | +| `flags` | `[Flags]` enum with `uint` values | + +Resources, async functions, futures, streams, maps and flags with more than 32 +members are rejected. + +Records, variant cases, `Option` and `Result` compare lists by element. +Variants and `Result` have `Match` and `Switch` methods that take one function +per case. These give a compile error when a case is missing. + +For a root namespace `Root`, types from `ns:pkg/iface` are in +`Root.Ns.Pkg.Iface`. The C# interface or exports class for `iface` is in +`Root.Ns.Pkg`. A world that uses two versions of a package gets the version +after `Pkg`, such as `Root.Ns.Pkg.V1_0_0`. diff --git a/docs/how-to-make-releases.md b/docs/how-to-make-releases.md index 14df208a7..4d377c7e4 100644 --- a/docs/how-to-make-releases.md +++ b/docs/how-to-make-releases.md @@ -63,6 +63,7 @@ When this job is done, a new [GitHub release](https://github.com/hyperlight-dev/ - `hyperlight-guest-bin` - `hyperlight-component-util` - `hyperlight-component-macro` +- `hyperlight-component-csharp` - `hyperlight-host` - `hyperlight-guest-tracing` diff --git a/src/hyperlight_component_csharp/Cargo.toml b/src/hyperlight_component_csharp/Cargo.toml new file mode 100644 index 000000000..00e7ffe24 --- /dev/null +++ b/src/hyperlight_component_csharp/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hyperlight-component-csharp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme = "README.md" +description = """ +Generates C# host bindings for Hyperlight components from WIT worlds +""" + +[lib] +name = "hyperlight_component_csharp" + +[dependencies] +hyperlight-component-util = { workspace = true } + +[dev-dependencies] +proc-macro2 = { version = "1.0.106" } diff --git a/src/hyperlight_component_csharp/README.md b/src/hyperlight_component_csharp/README.md new file mode 100644 index 000000000..3f642682b --- /dev/null +++ b/src/hyperlight_component_csharp/README.md @@ -0,0 +1,42 @@ +# hyperlight-component-csharp + +Generates C# host bindings for a WIT world. A .NET host uses them to call a +Hyperlight component guest and to serve its imports. + +hyperlight-wasm's .NET packages use this crate to generate bindings at build +time. Hyperlight itself has no .NET host. + +## Usage + +```rust +let cs = hyperlight_component_csharp::generate_from_path( + Path::new("wit/world.wit"), + None, // world name, or None for the last world + "MyApp.Guest", // root C# namespace +)?; +std::fs::write("Bindings.g.cs", cs)?; +``` + +`generate` takes a `hyperlight_component_util::util::WitSource`, the input +type of the bindgen macros. It reads the world through the same component type +elaboration as the macros. + +For a world `w`, the output has: + +* `WComponent`, which calls the guest's exports. +* `WComponent.RegisterImports`, which registers host implementations of the + guest's imports. +* A C# type for each record, variant, enum and flags type. + +See [docs/component-wire-format.md](../../docs/component-wire-format.md) for +the type mapping and the wire format. + +## Runtime.cs + +The generated code needs the C# types in [`src/Runtime.cs`](src/Runtime.cs). +The Rust constant `hyperlight_component_csharp::RUNTIME_SOURCE` holds the text +of this file, so tools can write it out next to the generated code. + +A .NET host library, such as hyperlight-wasm's, includes this file in its build. +It implements the file's `IGuestCaller` and `IHostFunctionRegistry` interfaces +with its sandbox. diff --git a/src/hyperlight_component_csharp/examples/csharp_bindgen.rs b/src/hyperlight_component_csharp/examples/csharp_bindgen.rs new file mode 100644 index 000000000..800a2ee16 --- /dev/null +++ b/src/hyperlight_component_csharp/examples/csharp_bindgen.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Generates C# host bindings for a WIT world. +//! +//! Usage: `csharp_bindgen ` + +use std::path::Path; +use std::process::ExitCode; + +use hyperlight_component_csharp::generate_from_path; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let [wit, namespace, out] = args.as_slice() else { + eprintln!("usage: csharp_bindgen "); + return ExitCode::FAILURE; + }; + let result = generate_from_path(Path::new(wit), None, namespace) + .map_err(|e| e.to_string()) + .and_then(|cs| std::fs::write(out, cs).map_err(|e| format!("{out}: {e}"))); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/src/hyperlight_component_csharp/src/Runtime.cs b/src/hyperlight_component_csharp/src/Runtime.cs new file mode 100644 index 000000000..b43e9ea2d --- /dev/null +++ b/src/hyperlight_component_csharp/src/Runtime.cs @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Support types for C# bindings generated by hyperlight-component-csharp. +// The encoding is specified in docs/component-wire-format.md. + +#nullable enable +// Members named after WIT types and object overrides need no docs. +#pragma warning disable CS1591 + +using System; +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Hyperlight.Component +{ + /// Calls guest functions by wire name. + public interface IGuestCaller + { + /// + /// Calls with one encoded buffer per + /// parameter and returns the encoded result. + /// + byte[] Call(string function, byte[][] args); + } + + /// Registers host functions by wire name. + public interface IHostFunctionRegistry + { + /// + /// Registers , which receives one + /// encoded buffer per parameter and returns the encoded result. + /// + void Register(string function, int parameterCount, Func implementation); + } + + /// Thrown when encoded data does not match the expected type. + public sealed class WireFormatException : Exception + { + public WireFormatException(string message) : base(message) { } + } + + /// + /// Equality that compares lists element by element, including lists + /// nested in tuples. Generated records and variants use it for fields + /// that can hold lists. + /// + public static class Structural + { + public static bool Equal(T a, T b) => EqualValues(a, b); + + public static int Hash(T value) => HashValue(value); + + private static bool EqualValues(object? a, object? b) + { + if (ReferenceEquals(a, b)) + { + return true; + } + if (a is null || b is null) + { + return false; + } + if (a is byte[] x && b is byte[] y) + { + return x.AsSpan().SequenceEqual(y); + } + if (a is not string && a is IEnumerable ea && b is IEnumerable eb) + { + var ia = ea.GetEnumerator(); + var ib = eb.GetEnumerator(); + while (true) + { + var hasA = ia.MoveNext(); + if (hasA != ib.MoveNext()) + { + return false; + } + if (!hasA) + { + return true; + } + if (!EqualValues(ia.Current, ib.Current)) + { + return false; + } + } + } + if (a is ITuple ta && b is ITuple tb) + { + if (ta.Length != tb.Length) + { + return false; + } + for (var i = 0; i < ta.Length; i++) + { + if (!EqualValues(ta[i], tb[i])) + { + return false; + } + } + return true; + } + return a.Equals(b); + } + + private static int HashValue(object? value) + { + switch (value) + { + case null: + return 0; + case string: + return value.GetHashCode(); + case IEnumerable items: + { + var hash = new HashCode(); + foreach (var item in items) + { + hash.Add(HashValue(item)); + } + return hash.ToHashCode(); + } + case ITuple tuple: + { + var hash = new HashCode(); + for (var i = 0; i < tuple.Length; i++) + { + hash.Add(HashValue(tuple[i])); + } + return hash.ToHashCode(); + } + default: + return value.GetHashCode(); + } + } + } + + /// The payload of a result case without a type. + public readonly struct Unit : IEquatable + { + public bool Equals(Unit other) => true; + public override bool Equals(object? obj) => obj is Unit; + public override int GetHashCode() => 0; + public override string ToString() => "()"; + + public static bool operator ==(Unit left, Unit right) => true; + + public static bool operator !=(Unit left, Unit right) => false; + } + + /// + /// A WIT option whose payload is itself an option. + /// Other options map to nullable types. + /// + public readonly struct Option : IEquatable> + { + private readonly T _value; + + private Option(T value) + { + HasValue = true; + _value = value; + } + + public static Option None => default; + + public static Option Some(T value) => new(value); + + public bool HasValue { get; } + + public T Value => HasValue ? _value : throw new InvalidOperationException("option has no value"); + + public bool Equals(Option other) => + HasValue == other.HasValue && (!HasValue || Structural.Equal(_value, other._value)); + + public override bool Equals(object? obj) => obj is Option other && Equals(other); + + public override int GetHashCode() => HasValue ? HashCode.Combine(true, Structural.Hash(_value)) : 0; + + public override string ToString() => HasValue ? $"Some({_value})" : "None"; + + public static bool operator ==(Option left, Option right) => left.Equals(right); + + public static bool operator !=(Option left, Option right) => !left.Equals(right); + } + + /// A WIT result. + public readonly struct Result : IEquatable> + { + private readonly TOk _ok; + private readonly TErr _err; + + private Result(bool isOk, TOk ok, TErr err) + { + IsOk = isOk; + _ok = ok; + _err = err; + } + + public static Result FromOk(TOk value) => new(true, value, default!); + + public static Result FromErr(TErr value) => new(false, default!, value); + + public bool IsOk { get; } + + public TOk Ok => IsOk ? _ok : throw new InvalidOperationException("result is an error"); + + public TErr Err => IsOk ? throw new InvalidOperationException("result is not an error") : _err; + + /// Calls or and returns its result. + public TResult Match(Func ok, Func err) => + IsOk ? ok(_ok) : err(_err); + + /// Calls or . + public void Switch(Action ok, Action err) + { + if (IsOk) + { + ok(_ok); + } + else + { + err(_err); + } + } + + public bool Equals(Result other) => + IsOk == other.IsOk && (IsOk + ? Structural.Equal(_ok, other._ok) + : Structural.Equal(_err, other._err)); + + public override bool Equals(object? obj) => obj is Result other && Equals(other); + + public override int GetHashCode() => + IsOk ? HashCode.Combine(true, Structural.Hash(_ok)) : HashCode.Combine(false, Structural.Hash(_err)); + + public override string ToString() => IsOk ? $"Ok({_ok})" : $"Err({_err})"; + + public static bool operator ==(Result left, Result right) => left.Equals(right); + + public static bool operator !=(Result left, Result right) => !left.Equals(right); + } + + /// Encodes values in the Hyperlight component wire format. + public sealed class WireWriter + { + private byte[] _buffer; + private int _length; + + public WireWriter(int capacity = 64) + { + _buffer = new byte[Math.Max(capacity, 1)]; + } + + public byte[] ToArray() => _buffer.AsSpan(0, _length).ToArray(); + + public void Bool(bool value) => U8(value ? (byte)1 : (byte)0); + + public void U8(byte value) => Grow(1)[0] = value; + + public void S8(sbyte value) => Grow(1)[0] = unchecked((byte)value); + + public void U16(ushort value) => BinaryPrimitives.WriteUInt16LittleEndian(Grow(2), value); + + public void S16(short value) => BinaryPrimitives.WriteInt16LittleEndian(Grow(2), value); + + public void U32(uint value) => BinaryPrimitives.WriteUInt32LittleEndian(Grow(4), value); + + public void S32(int value) => BinaryPrimitives.WriteInt32LittleEndian(Grow(4), value); + + public void U64(ulong value) => BinaryPrimitives.WriteUInt64LittleEndian(Grow(8), value); + + public void S64(long value) => BinaryPrimitives.WriteInt64LittleEndian(Grow(8), value); + + public void F32(float value) => BinaryPrimitives.WriteSingleLittleEndian(Grow(4), value); + + public void F64(double value) => BinaryPrimitives.WriteDoubleLittleEndian(Grow(8), value); + + public void Char(Rune value) => U32((uint)value.Value); + + public void String(string value) + { + ArgumentNullException.ThrowIfNull(value); + var length = Encoding.UTF8.GetByteCount(value); + Length(length); + Encoding.UTF8.GetBytes(value, Grow(length)); + } + + /// Writes the element count of a list. + public void Length(int count) => U32(checked((uint)count)); + + /// Writes a list<u8>. + public void Bytes(byte[] value) + { + ArgumentNullException.ThrowIfNull(value); + Length(value.Length); + value.CopyTo(Grow(value.Length)); + } + + /// Writes a list<u8, N>. + public void FixedBytes(byte[] value, int count) + { + CheckFixedLength(value, count); + value.CopyTo(Grow(count)); + } + + /// Writes the low bytes of a flags bitmask. + public void Flags(uint bits, int byteCount) + { + var bytes = Grow(byteCount); + for (var i = 0; i < byteCount; i++) + { + bytes[i] = (byte)(bits >> (8 * i)); + } + } + + /// Throws unless a fixed-length list has elements. + public static void CheckFixedLength(IReadOnlyCollection value, int count) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Count != count) + { + throw new ArgumentException($"fixed-length list needs {count} elements, got {value.Count}"); + } + } + + private Span Grow(int count) + { + if (_buffer.Length - _length < count) + { + Array.Resize(ref _buffer, Math.Max(checked(_length + count), _buffer.Length * 2)); + } + var span = _buffer.AsSpan(_length, count); + _length += count; + return span; + } + } + + /// Decodes values in the Hyperlight component wire format. + public ref struct WireReader + { + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + private readonly ReadOnlySpan _data; + private int _position; + + public WireReader(ReadOnlySpan data) + { + _data = data; + _position = 0; + } + + /// Throws if any input is left unread. + public void End() + { + if (_position != _data.Length) + { + throw new WireFormatException($"{_data.Length - _position} trailing bytes"); + } + } + + public bool Bool() => U8() != 0; + + public byte U8() => Take(1)[0]; + + public sbyte S8() => unchecked((sbyte)Take(1)[0]); + + public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2)); + + public short S16() => BinaryPrimitives.ReadInt16LittleEndian(Take(2)); + + public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4)); + + public int S32() => BinaryPrimitives.ReadInt32LittleEndian(Take(4)); + + public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8)); + + public long S64() => BinaryPrimitives.ReadInt64LittleEndian(Take(8)); + + public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4)); + + public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8)); + + public Rune Char() + { + var value = U32(); + if (!Rune.IsValid(value)) + { + throw new WireFormatException($"invalid char {value:x}"); + } + return new Rune(value); + } + + public string String() + { + var bytes = Take(Length()); + try + { + return StrictUtf8.GetString(bytes); + } + catch (DecoderFallbackException e) + { + throw new WireFormatException($"invalid UTF-8: {e.Message}"); + } + } + + /// Reads the element count of a list. + public int Length() + { + var count = U32(); + // Every element takes at least one byte. + if (count > (uint)(_data.Length - _position)) + { + throw new WireFormatException($"list length {count} exceeds remaining input"); + } + return (int)count; + } + + /// Reads a list<u8>. + public byte[] Bytes() => Take(Length()).ToArray(); + + /// Reads a list<u8, N>. + public byte[] FixedBytes(int count) => Take(count).ToArray(); + + /// Reads a one byte tag that must be below . + public byte Tag8(int cases) + { + var tag = U8(); + if (tag >= cases) + { + throw new WireFormatException($"invalid tag {tag}"); + } + return tag; + } + + /// Reads a four byte tag that must be below . + public uint Tag32(uint cases) + { + var tag = U32(); + if (tag >= cases) + { + throw new WireFormatException($"invalid tag {tag}"); + } + return tag; + } + + /// Reads a flags bitmask of bytes. + public uint Flags(int byteCount) + { + var bytes = Take(byteCount); + uint bits = 0; + for (var i = 0; i < byteCount; i++) + { + bits |= (uint)bytes[i] << (8 * i); + } + return bits; + } + + private ReadOnlySpan Take(int count) + { + if ((uint)count > (uint)(_data.Length - _position)) + { + throw new WireFormatException("unexpected end of input"); + } + var span = _data.Slice(_position, count); + _position += count; + return span; + } + } +} diff --git a/src/hyperlight_component_csharp/src/codec.rs b/src/hyperlight_component_csharp/src/codec.rs new file mode 100644 index 000000000..10d57a231 --- /dev/null +++ b/src/hyperlight_component_csharp/src/codec.rs @@ -0,0 +1,706 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# types, declarations and encoding for WIT value types. + +use hyperlight_component_util::emit::ResolvedBoundVar; +use hyperlight_component_util::etypes::{Defined, FloatWidth, IntWidth, Tyvar, Value, VariantCase}; + +use crate::names::{Src, camel, case, member, pascal}; +use crate::{RT, S}; + +/// What a type variable reference stands for. +enum Target<'b> { + /// A declared C# type, by index into `Root::decls` + Named(usize), + /// A value type alias, whose definition is relative to the given + /// `var_offset` + Alias(Value<'b>, usize), +} + +fn target<'b>(s: &S<'_, 'b>, v: &Value<'b>) -> Option> { + let Value::Var(Some(Tyvar::Bound(n)), _) = v else { + return None; + }; + let ResolvedBoundVar::Definite { + final_bound_var, + ty: Defined::Value(def), + } = s.resolve_bound_var(*n) + else { + unreachable!("rejected while collecting"); + }; + let var = s.var_offset + final_bound_var as usize; + Some(match s.root_mod.decls.iter().position(|d| d.var == var) { + Some(i) => Target::Named(i), + None => Target::Alias(def, var + 1), + }) +} + +/// Run `f` on an alias definition with its own `var_offset`. +fn alias<'b, R>(s: &mut S<'_, 'b>, offset: usize, f: impl FnOnce(&mut S<'_, 'b>) -> R) -> R { + let mut s = s.clone(); + s.var_offset = offset; + f(&mut s) +} + +/// The C# type and `WireWriter`/`WireReader` method for a primitive type. +fn primitive(v: &Value) -> Option<(&'static str, &'static str)> { + Some(match v { + Value::Bool => ("bool", "Bool"), + Value::S(IntWidth::I8) => ("sbyte", "S8"), + Value::U(IntWidth::I8) => ("byte", "U8"), + Value::S(IntWidth::I16) => ("short", "S16"), + Value::U(IntWidth::I16) => ("ushort", "U16"), + Value::S(IntWidth::I32) => ("int", "S32"), + Value::U(IntWidth::I32) => ("uint", "U32"), + Value::S(IntWidth::I64) => ("long", "S64"), + Value::U(IntWidth::I64) => ("ulong", "U64"), + Value::F(FloatWidth::F32) => ("float", "F32"), + Value::F(FloatWidth::F64) => ("double", "F64"), + Value::Char => ("global::System.Text.Rune", "Char"), + Value::String => ("string", "String"), + _ => return None, + }) +} + +fn is_u8(v: &Value) -> bool { + matches!(v, Value::U(IntWidth::I8)) +} + +fn is_option<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> bool { + match (v, target(s, v)) { + (Value::Option(_), _) => true, + (_, Some(Target::Alias(def, off))) => alias(s, off, |s| is_option(s, &def)), + _ => false, + } +} + +/// Whether a value of `v` can hold a list outside a record or variant. +/// Lists compare by reference in C#, so equality of such values goes +/// through `Structural`. Records and variants compare their own lists. +fn contains_list<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> bool { + match v { + Value::List(_) | Value::FixList(..) => true, + Value::Option(t) => contains_list(s, t), + Value::Result(ok, err) => [ok.as_ref(), err.as_ref()] + .into_iter() + .flatten() + .any(|t| contains_list(s, t)), + Value::Tuple(ts) => ts.iter().any(|t| contains_list(s, t)), + Value::Var(..) => match target(s, v) { + Some(Target::Alias(def, off)) => alias(s, off, |s| contains_list(s, &def)), + _ => false, + }, + _ => false, + } +} + +/// Whether the C# type of `v` is a value type, which decides how +/// `option` maps to a nullable type. +fn is_value_type<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> bool { + match v { + Value::String | Value::List(_) | Value::FixList(..) => false, + Value::Var(..) => match target(s, v) { + Some(Target::Named(i)) => { + matches!(s.root_mod.decls[i].value, Value::Enum(_) | Value::Flags(_)) + } + Some(Target::Alias(def, off)) => alias(s, off, |s| is_value_type(s, &def)), + None => false, + }, + _ => true, + } +} + +/// The C# type of `v`. +pub(crate) fn ty<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> String { + if let Some((ty, _)) = primitive(v) { + return ty.to_string(); + } + match v { + Value::Var(..) => match target(s, v) { + Some(Target::Named(i)) => s.root_mod.type_name(&s.root_mod.decls[i]), + Some(Target::Alias(def, off)) => alias(s, off, |s| ty(s, &def)), + None => unreachable!(), + }, + Value::Tuple(ts) if ts.len() == 1 => { + format!("global::System.ValueTuple<{}>", ty(s, &ts[0])) + } + Value::Tuple(ts) => { + let types = ts.iter().map(|t| ty(s, t)).collect::>(); + format!("({})", types.join(", ")) + } + Value::Option(inner) if is_option(s, inner) => format!("{RT}.Option<{}>", ty(s, inner)), + Value::Option(inner) => format!("{}?", ty(s, inner)), + Value::Result(ok, err) => format!( + "{RT}.Result<{}, {}>", + ty_or_unit(s, ok.as_ref().as_ref()), + ty_or_unit(s, err.as_ref().as_ref()) + ), + Value::List(inner) | Value::FixList(inner, _) if is_u8(inner) => "byte[]".to_string(), + Value::List(inner) | Value::FixList(inner, _) => format!( + "global::System.Collections.Generic.IReadOnlyList<{}>", + ty(s, inner) + ), + _ => unreachable!("rejected while collecting"), + } +} + +fn ty_or_unit<'b>(s: &mut S<'_, 'b>, v: Option<&Value<'b>>) -> String { + v.map_or_else(|| format!("{RT}.Unit"), |v| ty(s, v)) +} + +/// Emit statements that encode `value` of type `v` into the +/// `WireWriter` named `w`. +pub(crate) fn write<'b>(s: &mut S<'_, 'b>, src: &mut Src, v: &Value<'b>, value: &str, w: &str) { + if let Some((_, method)) = primitive(v) { + src.line(format!("{w}.{method}({value});")); + return; + } + match v { + Value::Var(..) => match target(s, v) { + Some(Target::Named(i)) => { + let root = &*s.root_mod; + src.line(format!( + "{}.Write_{}({w}, {value});", + root.codec, root.decls[i].mangled + )); + } + Some(Target::Alias(def, off)) => alias(s, off, |s| write(s, src, &def, value, w)), + None => unreachable!(), + }, + Value::Tuple(ts) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + for (i, ty) in ts.iter().enumerate() { + write(s, src, ty, &tuple_item(&t, i), w); + } + } + Value::Option(inner) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + let (has_value, payload) = if is_option(s, inner) || is_value_type(s, inner) { + (format!("{t}.HasValue"), format!("{t}.Value")) + } else { + (format!("{t} is not null"), t.clone()) + }; + src.line(format!("if ({has_value})")); + src.open(); + src.line(format!("{w}.U8(1);")); + write(s, src, inner, &payload, w); + src.close(); + src.line("else"); + src.open(); + src.line(format!("{w}.U8(0);")); + src.close(); + } + Value::Result(ok, err) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + src.line(format!("if ({t}.IsOk)")); + src.open(); + src.line(format!("{w}.U8(0);")); + if let Some(ok) = ok.as_ref() { + write(s, src, ok, &format!("{t}.Ok"), w); + } + src.close(); + src.line("else"); + src.open(); + src.line(format!("{w}.U8(1);")); + if let Some(err) = err.as_ref() { + write(s, src, err, &format!("{t}.Err"), w); + } + src.close(); + } + Value::List(inner) if is_u8(inner) => src.line(format!("{w}.Bytes({value});")), + Value::FixList(inner, n) if is_u8(inner) => { + src.line(format!("{w}.FixedBytes({value}, {n});")); + } + Value::List(inner) | Value::FixList(inner, _) => { + let t = s.root_mod.temp(); + src.line(format!("var {t} = {value};")); + match v { + Value::FixList(_, n) => { + src.line(format!("{RT}.WireWriter.CheckFixedLength({t}, {n});")); + } + _ => { + src.line(format!( + "global::System.ArgumentNullException.ThrowIfNull({t});" + )); + src.line(format!("{w}.Length({t}.Count);")); + } + } + let e = s.root_mod.temp(); + src.line(format!("foreach (var {e} in {t})")); + src.open(); + write(s, src, inner, &e, w); + src.close(); + } + _ => unreachable!("rejected while collecting"), + } +} + +/// Emit statements that decode a value of type `v` from the +/// `WireReader` named `r`, and return the local holding it. +pub(crate) fn read<'b>(s: &mut S<'_, 'b>, src: &mut Src, v: &Value<'b>, r: &str) -> String { + if let Value::Var(..) = v + && let Some(Target::Alias(def, off)) = target(s, v) + { + return alias(s, off, |s| read(s, src, &def, r)); + } + let t = s.root_mod.temp(); + if let Some((_, method)) = primitive(v) { + src.line(format!("var {t} = {r}.{method}();")); + return t; + } + match v { + Value::Var(..) => { + let Some(Target::Named(i)) = target(s, v) else { + unreachable!() + }; + let root = &*s.root_mod; + src.line(format!( + "var {t} = {}.Read_{}(ref {r});", + root.codec, root.decls[i].mangled + )); + } + Value::Tuple(ts) => { + let items = ts.iter().map(|ty| read(s, src, ty, r)).collect::>(); + if items.len() == 1 { + src.line(format!("var {t} = new {}({});", ty(s, v), items[0])); + } else { + src.line(format!("var {t} = ({});", items.join(", "))); + } + } + Value::Option(inner) => { + let option_ty = ty(s, v); + let nested = is_option(s, inner); + src.line(format!("{option_ty} {t};")); + src.line(format!("if ({r}.Tag8(2) == 1)")); + src.open(); + let x = read(s, src, inner, r); + if nested { + src.line(format!("{t} = {option_ty}.Some({x});")); + } else { + src.line(format!("{t} = {x};")); + } + src.close(); + src.line("else"); + src.open(); + if nested { + src.line(format!("{t} = {option_ty}.None;")); + } else { + src.line(format!("{t} = null;")); + } + src.close(); + } + Value::Result(ok, err) => { + let result_ty = ty(s, v); + src.line(format!("{result_ty} {t};")); + src.line(format!("if ({r}.Tag8(2) == 0)")); + src.open(); + let x = read_or_unit(s, src, ok.as_ref().as_ref(), r); + src.line(format!("{t} = {result_ty}.FromOk({x});")); + src.close(); + src.line("else"); + src.open(); + let x = read_or_unit(s, src, err.as_ref().as_ref(), r); + src.line(format!("{t} = {result_ty}.FromErr({x});")); + src.close(); + } + Value::List(inner) if is_u8(inner) => src.line(format!("var {t} = {r}.Bytes();")), + Value::FixList(inner, n) if is_u8(inner) => { + src.line(format!("var {t} = {r}.FixedBytes({n});")); + } + Value::List(inner) | Value::FixList(inner, _) => { + let n = s.root_mod.temp(); + // A guest-supplied length is not trusted as a capacity. + let capacity = match v { + Value::FixList(_, len) => { + src.line(format!("var {n} = {len};")); + n.clone() + } + _ => { + src.line(format!("var {n} = {r}.Length();")); + String::new() + } + }; + let elem = ty(s, inner); + src.line(format!( + "var {t} = new global::System.Collections.Generic.List<{elem}>({capacity});" + )); + let i = s.root_mod.temp(); + src.line(format!("for (var {i} = 0; {i} < {n}; {i}++)")); + src.open(); + let x = read(s, src, inner, r); + src.line(format!("{t}.Add({x});")); + src.close(); + } + _ => unreachable!("rejected while collecting"), + } + t +} + +fn read_or_unit<'b>(s: &mut S<'_, 'b>, src: &mut Src, v: Option<&Value<'b>>, r: &str) -> String { + match v { + Some(v) => read(s, src, v, r), + None => format!("default({RT}.Unit)"), + } +} + +/// A clone of `s` positioned at the definition of the declared type `i`. +fn at_decl<'c, 'b>(s: &'c mut S<'_, 'b>, i: usize) -> (S<'c, 'b>, Value<'b>, String) { + let d = &s.root_mod.decls[i]; + let (var, value, name) = (d.var, d.value.clone(), d.name.clone()); + let mut s = s.clone(); + s.var_offset = var + 1; + (s, value, name) +} + +/// Declaration of the record, variant, enum or flags type `i`. +pub(crate) fn emit_decl(s: &mut S<'_, '_>, i: usize) -> Src { + let (mut s, value, name) = at_decl(s, i); + let s = &mut s; + let mut src = Src::default(); + match &value { + Value::Record(fields) => { + src.line(format!("public sealed record {name}")); + src.open(); + for (i, f) in fields.iter().enumerate() { + if i > 0 { + src.line(""); + } + src.line(format!( + "public required {} {} {{ get; init; }}", + ty(s, &f.ty), + member(f.name.name, &name) + )); + } + let fields = fields + .iter() + .map(|f| (member(f.name.name, &name), &f.ty)) + .collect::>(); + emit_structural_equality(s, &mut src, &name, &fields); + src.close(); + } + Value::Variant(cases) => { + src.line(format!("public abstract record {name}")); + src.open(); + src.line(format!("private {name}() {{ }}")); + for c in cases { + let case_name = case(c.name.name, &name); + src.line(""); + match &c.ty { + Some(t) if contains_list(s, t) => { + src.line(format!( + "public sealed record {case_name}({} Value) : {name}", + ty(s, t) + )); + src.open(); + emit_structural_equality( + s, + &mut src, + &case_name, + &[("Value".to_string(), t)], + ); + src.close(); + } + Some(t) => src.line(format!( + "public sealed record {case_name}({} Value) : {name};", + ty(s, t) + )), + None => src.line(format!("public sealed record {case_name} : {name};")), + } + } + emit_match(s, &mut src, &name, cases); + src.close(); + } + Value::Enum(names) => { + src.line(format!("public enum {name} : uint")); + src.open(); + for (i, c) in names.iter().enumerate() { + src.line(format!("{} = {i},", pascal(c.name))); + } + src.close(); + } + Value::Flags(flags) => { + src.line("[global::System.Flags]"); + src.line(format!("public enum {name} : uint")); + src.open(); + if flags.iter().all(|f| pascal(f.name) != "None") { + src.line("None = 0,"); + } + for (i, f) in flags.iter().enumerate() { + src.line(format!("{} = 1u << {i},", pascal(f.name))); + } + src.close(); + } + _ => unreachable!("only named types are declared"), + } + src +} + +/// `Equals` and `GetHashCode` for a sealed record whose `fields` may +/// hold lists, which compare by reference by default. +fn emit_structural_equality<'b>( + s: &mut S<'_, 'b>, + src: &mut Src, + name: &str, + fields: &[(String, &Value<'b>)], +) { + let lists = fields + .iter() + .map(|(_, ty)| contains_list(s, ty)) + .collect::>(); + if !lists.contains(&true) { + return; + } + let eqs = fields + .iter() + .zip(&lists) + .map(|((field, t), list)| { + if *list { + format!("{RT}.Structural.Equal({field}, other.{field})") + } else { + format!( + "global::System.Collections.Generic.EqualityComparer<{}>.Default.Equals({field}, other.{field})", + ty(s, t) + ) + } + }) + .collect::>(); + src.line(""); + src.line(format!("public bool Equals({name}? other) =>")); + src.indent_lines(|src| { + src.line("other is not null"); + for (i, eq) in eqs.iter().enumerate() { + let end = if i + 1 == eqs.len() { ";" } else { "" }; + src.line(format!("&& {eq}{end}")); + } + }); + src.line(""); + src.line("public override int GetHashCode()"); + src.open(); + src.line("var hash = new global::System.HashCode();"); + for ((field, _), list) in fields.iter().zip(&lists) { + if *list { + src.line(format!("hash.Add({RT}.Structural.Hash({field}));")); + } else { + src.line(format!("hash.Add({field});")); + } + } + src.line("return hash.ToHashCode();"); + src.close(); +} + +/// `Match` and `Switch`, which take one function per case so that a +/// case added to the WIT breaks every caller at compile time. +fn emit_match<'b>(s: &mut S<'_, 'b>, src: &mut Src, name: &str, cases: &[VariantCase<'b>]) { + let payloads = cases + .iter() + .map(|c| c.ty.as_ref().map(|t| ty(s, t))) + .collect::>(); + let handler = |c: &VariantCase, payload: &Option, result: Option<&str>| { + let ty = match (result, payload) { + (Some(r), Some(p)) => format!("global::System.Func<{p}, {r}>"), + (Some(r), None) => format!("global::System.Func<{r}>"), + (None, Some(p)) => format!("global::System.Action<{p}>"), + (None, None) => "global::System.Action".to_string(), + }; + format!("{ty} {}", camel(c.name.name)) + }; + let call = |c: &VariantCase| { + let case_name = case(c.name.name, name); + match c.ty { + Some(_) => ( + format!("{case_name} __c"), + format!("{}(__c.Value)", camel(c.name.name)), + ), + None => (case_name, format!("{}()", camel(c.name.name))), + } + }; + + src.line(""); + src.line("/// Calls the function for this case and returns its result."); + let params = cases + .iter() + .zip(&payloads) + .map(|(c, p)| handler(c, p, Some("TResult"))); + src.line(format!( + "public TResult Match({}) => this switch", + params.collect::>().join(", ") + )); + src.open(); + for c in cases { + let (pattern, invoke) = call(c); + src.line(format!("{pattern} => {invoke},")); + } + src.line("_ => throw new global::System.InvalidOperationException(),"); + src.close_with("};"); + + src.line(""); + src.line("/// Calls the action for this case."); + let params = cases + .iter() + .zip(&payloads) + .map(|(c, p)| handler(c, p, None)); + src.line(format!( + "public void Switch({})", + params.collect::>().join(", ") + )); + src.open(); + src.line("switch (this)"); + src.open(); + for c in cases { + let (pattern, invoke) = call(c); + src.line(format!("case {pattern}:")); + src.indent_lines(|src| { + src.line(format!("{invoke};")); + src.line("break;"); + }); + } + src.line("default:"); + src.indent_lines(|src| src.line("throw new global::System.InvalidOperationException();")); + src.close(); + src.close(); +} + +/// Encode and decode methods for the declared type `i`. +pub(crate) fn emit_type_codec(s: &mut S<'_, '_>, src: &mut Src, i: usize) { + let full = s.root_mod.type_name(&s.root_mod.decls[i]); + let mangled = s.root_mod.decls[i].mangled.clone(); + let (mut s, value, name) = at_decl(s, i); + let s = &mut s; + + src.line(format!( + "public static void Write_{mangled}({RT}.WireWriter w, {full} v)" + )); + src.open(); + match &value { + Value::Record(fields) => { + src.line("global::System.ArgumentNullException.ThrowIfNull(v);"); + for f in fields { + write( + s, + src, + &f.ty, + &format!("v.{}", member(f.name.name, &name)), + "w", + ); + } + } + Value::Variant(cases) => { + src.line("switch (v)"); + src.open(); + for (i, c) in cases.iter().enumerate() { + let case_ty = format!("{full}.{}", case(c.name.name, &name)); + match &c.ty { + Some(payload) => { + let x = s.root_mod.temp(); + src.line(format!("case {case_ty} {x}:")); + src.indent_lines(|src| { + src.line(format!("w.U32({i});")); + write(s, src, payload, &format!("{x}.Value"), "w"); + src.line("break;"); + }); + } + None => { + src.line(format!("case {case_ty}:")); + src.indent_lines(|src| { + src.line(format!("w.U32({i});")); + src.line("break;"); + }); + } + } + } + src.line("default:"); + src.indent_lines(|src| { + src.line( + "throw new global::System.ArgumentException($\"unknown case {v}\", nameof(v));", + ); + }); + src.close(); + } + Value::Enum(names) => { + src.line(format!("if ((uint)v >= {})", names.len())); + src.open(); + src.line("throw new global::System.ArgumentOutOfRangeException(nameof(v));"); + src.close(); + src.line("w.U32((uint)v);"); + } + Value::Flags(flags) => { + let mask = flags_mask(flags.len()); + src.line(format!("if (((uint)v & ~{mask}u) != 0)")); + src.open(); + src.line("throw new global::System.ArgumentOutOfRangeException(nameof(v));"); + src.close(); + src.line(format!("w.Flags((uint)v, {});", flags.len().div_ceil(8))); + } + _ => unreachable!("only named types are declared"), + } + src.close(); + + src.line(""); + src.line(format!( + "public static {full} Read_{mangled}(ref {RT}.WireReader r)" + )); + src.open(); + match &value { + Value::Record(fields) => { + let fields = fields + .iter() + .map(|f| (member(f.name.name, &name), read(s, src, &f.ty, "r"))) + .collect::>(); + src.line(format!("return new {full}")); + src.open(); + for (field, value) in fields { + src.line(format!("{field} = {value},")); + } + src.close_with("};"); + } + Value::Variant(cases) => { + src.line(format!("switch (r.Tag32({}))", cases.len())); + src.open(); + for (i, c) in cases.iter().enumerate() { + let case_ty = format!("{full}.{}", case(c.name.name, &name)); + src.line(format!("case {i}:")); + src.open(); + match &c.ty { + Some(payload) => { + let x = read(s, src, payload, "r"); + src.line(format!("return new {case_ty}({x});")); + } + None => src.line(format!("return new {case_ty}();")), + } + src.close(); + } + src.line("default:"); + src.indent_lines(|src| { + src.line("throw new global::System.InvalidOperationException();"); + }); + src.close(); + } + Value::Enum(names) => { + src.line(format!("return ({full})r.Tag32({});", names.len())); + } + Value::Flags(flags) => { + let mask = flags_mask(flags.len()); + src.line(format!( + "return ({full})(r.Flags({}) & {mask}u);", + flags.len().div_ceil(8) + )); + } + _ => unreachable!("only named types are declared"), + } + src.close(); +} + +/// Accessor for element `i` of a C# value tuple. +fn tuple_item(tuple: &str, i: usize) -> String { + format!("{tuple}.Item{}", i + 1) +} + +/// Hex literal with the low `count` bits set. WIT flags have at most 32 +/// members. +fn flags_mask(count: usize) -> String { + format!("0x{:X}", u32::MAX >> (32 - count)) +} diff --git a/src/hyperlight_component_csharp/src/host.rs b/src/hyperlight_component_csharp/src/host.rs new file mode 100644 index 000000000..9ad1739ad --- /dev/null +++ b/src/hyperlight_component_csharp/src/host.rs @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! The host side of a world: interfaces for imports, classes that call +//! exports, and registration of host functions. + +use hyperlight_component_util::emit::split_wit_name; +use hyperlight_component_util::etypes::{Component, ExternDecl, ExternDesc, Func}; +use hyperlight_component_util::hl::emit_fn_hl_name; + +use crate::codec::{read, ty, write}; +use crate::names::{Src, camel, member, pascal, string_literal}; +use crate::{RT, S}; + +/// Functions of one imported or exported instance, or of the world +/// itself. +struct Group<'c, 'b> { + /// Instance name, or `None` for functions of the world itself + instance: Option<&'b str>, + /// `var_offset` for the function types + offset: usize, + functions: Vec<(&'b str, &'c Func<'b>)>, +} + +fn groups<'c, 'b>(decls: &'c [ExternDecl<'b>], offset: usize) -> Vec> { + let mut groups = Vec::new(); + let mut world = Vec::new(); + for ed in decls { + match &ed.desc { + ExternDesc::Instance(it) => { + let functions = it + .exports + .iter() + .filter_map(|e| match &e.desc { + ExternDesc::Func(f) => Some((e.kebab_name, f)), + _ => None, + }) + .collect::>(); + if !functions.is_empty() { + groups.push(Group { + instance: Some(ed.kebab_name), + offset, + functions, + }); + } + } + ExternDesc::Func(f) => world.push((ed.kebab_name, f)), + _ => {} + } + } + if !world.is_empty() { + groups.push(Group { + instance: None, + offset, + functions: world, + }); + } + groups +} + +/// Write the interfaces for the imports of the world `ct`, the classes +/// that call its exports, and the component class. +pub(crate) fn emit_toplevel<'b>(s: &mut S<'_, 'b>, ct: &Component<'b>) { + let imports = groups(&ct.imports, ct.instance.evars.len()); + let exports = groups(&ct.instance.unqualified.exports, 0); + for group in &imports { + if let Some(instance) = group.instance { + let ns = s.root_mod.qualify(s.root_mod.package_namespace(instance)); + let name = import_interface_name(s, group); + let src = emit_import_interface(s, &name, group); + s.root_mod.add(ns, src); + } + } + for group in &exports { + if let Some(instance) = group.instance { + let ns = s.root_mod.qualify(s.root_mod.package_namespace(instance)); + let name = export_class_name(group); + let src = emit_export_class(s, &name, group); + s.root_mod.add(ns, src); + } + } + let src = emit_component(s, &imports, &exports); + s.root_mod.add(s.root_mod.ns.clone(), src); +} + +fn import_interface_name(s: &S, group: &Group) -> String { + match group.instance { + Some(instance) => format!("I{}", pascal(split_wit_name(instance).name)), + None => format!("I{}Imports", s.root_mod.world_type), + } +} + +fn export_class_name(group: &Group) -> String { + format!( + "{}Exports", + pascal(split_wit_name(group.instance.unwrap()).name) + ) +} + +fn qualified_group_type(s: &S, group: &Group, name: String) -> String { + let ns = match group.instance { + Some(instance) => s.root_mod.qualify(s.root_mod.package_namespace(instance)), + None => s.root_mod.ns.clone(), + }; + format!("global::{ns}.{name}") +} + +/// Member names for interfaces, qualified with their package when two +/// interfaces share a name. +fn group_member_names(s: &S, groups: &[Group]) -> Vec { + let bases = groups + .iter() + .map(|g| match g.instance { + Some(instance) => split_wit_name(instance).name.to_string(), + None => "world".to_string(), + }) + .collect::>(); + groups + .iter() + .zip(&bases) + .map(|(g, base)| { + let collides = bases.iter().filter(|b| *b == base).count() > 1; + match (g.instance, collides) { + (Some(instance), true) => format!( + "{}{}", + s.root_mod + .package_namespace(instance) + .unwrap_or_default() + .replace('.', ""), + pascal(base) + ), + _ => pascal(base), + } + }) + .collect() +} + +fn signature<'b>(s: &mut S<'_, 'b>, group: &Group<'_, 'b>, f: &Func<'b>, name: &str) -> String { + let mut s = s.clone(); + s.var_offset = group.offset; + let params = f + .params + .iter() + .map(|p| format!("{} {}", ty(&mut s, &p.ty), camel(p.name.name))) + .collect::>() + .join(", "); + let result = f.result.as_ref().map_or("void".into(), |t| ty(&mut s, t)); + format!("{result} {name}({params})") +} + +fn wire_name(s: &mut S, group: &Group, function: &str) -> String { + let mut s = s.clone(); + s.cur_instance = group.instance.map(str::to_string); + emit_fn_hl_name(&s, function) +} + +/// A member name of the generated class `class`, escaped if it clashes +/// with the class name, an inherited member, or `RegisterImports` on the +/// component class. +fn class_member(name: &str, class: &str) -> String { + let name = member(name, class); + if name == "RegisterImports" { + format!("{name}_") + } else { + name + } +} + +fn emit_import_interface<'b>(s: &mut S<'_, 'b>, name: &str, group: &Group<'_, 'b>) -> Src { + let mut src = Src::default(); + src.line(format!("public interface {name}")); + src.open(); + for (i, (function, f)) in group.functions.iter().enumerate() { + if i > 0 { + src.line(""); + } + src.line(format!("{};", signature(s, group, f, &pascal(function)))); + } + src.close(); + src +} + +fn emit_export_class<'b>(s: &mut S<'_, 'b>, name: &str, group: &Group<'_, 'b>) -> Src { + let mut src = Src::default(); + src.line(format!("public sealed class {name}")); + src.open(); + src.line(format!("private readonly {RT}.IGuestCaller _caller;")); + src.line(""); + src.line(format!("internal {name}({RT}.IGuestCaller caller)")); + src.open(); + src.line("_caller = caller;"); + src.close(); + emit_export_methods(s, &mut src, name, group); + src.close(); + src +} + +fn emit_export_methods<'b>(s: &mut S<'_, 'b>, src: &mut Src, class: &str, group: &Group<'_, 'b>) { + for (function, f) in &group.functions { + src.line(""); + src.line(format!( + "public {}", + signature(s, group, f, &class_member(function, class)) + )); + let call = format!( + "_caller.Call({}, __args)", + string_literal(&wire_name(s, group, function)) + ); + let mut s = s.clone(); + s.var_offset = group.offset; + let s = &mut s; + src.open(); + if f.params.is_empty() { + src.line("var __args = global::System.Array.Empty();"); + } else { + src.line(format!("var __args = new byte[{}][];", f.params.len())); + } + for (i, p) in f.params.iter().enumerate() { + src.open(); + src.line(format!("var __w = new {RT}.WireWriter();")); + write(s, src, &p.ty, &camel(p.name.name), "__w"); + src.line(format!("__args[{i}] = __w.ToArray();")); + src.close(); + } + match &f.result { + Some(t) => { + src.line(format!("var __r = new {RT}.WireReader({call});")); + let value = read(s, src, t, "__r"); + src.line("__r.End();"); + src.line(format!("return {value};")); + } + None => src.line(format!("{call};")), + } + src.close(); + } +} + +fn emit_component<'b>( + s: &mut S<'_, 'b>, + imports: &[Group<'_, 'b>], + exports: &[Group<'_, 'b>], +) -> Src { + let world_type = s.root_mod.world_type.clone(); + let name = format!("{world_type}Component"); + let mut src = Src::default(); + + if let Some(group) = imports.iter().find(|g| g.instance.is_none()) { + let iface = import_interface_name(s, group); + src.append(emit_import_interface(s, &iface, group)); + src.line(""); + } + + src.line(format!("public sealed class {name}")); + src.open(); + src.line(format!("private readonly {RT}.IGuestCaller _caller;")); + src.line(""); + src.line(format!("public {name}({RT}.IGuestCaller caller)")); + src.open(); + src.line("_caller = caller;"); + let export_names = group_member_names(s, exports) + .iter() + .map(|n| class_member(n, &name)) + .collect::>(); + for (group, prop) in exports.iter().zip(&export_names) { + if group.instance.is_some() { + let class = qualified_group_type(s, group, export_class_name(group)); + src.line(format!("{prop} = new {class}(caller);")); + } + } + src.close(); + for (group, prop) in exports.iter().zip(&export_names) { + if group.instance.is_some() { + let class = qualified_group_type(s, group, export_class_name(group)); + src.line(""); + src.line(format!("public {class} {prop} {{ get; }}")); + } + } + if let Some(group) = exports.iter().find(|g| g.instance.is_none()) { + emit_export_methods(s, &mut src, &name, group); + } + + let import_names = group_member_names(s, imports); + let params = imports + .iter() + .zip(&import_names) + .map(|(group, base)| { + let ty = qualified_group_type(s, group, import_interface_name(s, group)); + let mut param = camel(base); + if param == "registry" { + param.push('_'); + } + (ty, param, group) + }) + .collect::>(); + src.line(""); + src.line("/// Registers the world's imports as host functions."); + let decls = std::iter::once(format!("{RT}.IHostFunctionRegistry registry")) + .chain(params.iter().map(|(ty, param, _)| format!("{ty} {param}"))) + .collect::>() + .join(", "); + src.line(format!("public static void RegisterImports({decls})")); + src.open(); + for (_, param, group) in ¶ms { + for (function, f) in &group.functions { + emit_registration(s, &mut src, param, group, function, f); + } + } + src.close(); + src.close(); + src +} + +fn emit_registration<'b>( + s: &mut S<'_, 'b>, + src: &mut Src, + target: &str, + group: &Group<'_, 'b>, + function: &str, + f: &Func<'b>, +) { + src.line(format!( + "registry.Register({}, {}, __args =>", + string_literal(&wire_name(s, group, function)), + f.params.len() + )); + let mut s = s.clone(); + s.var_offset = group.offset; + let s = &mut s; + src.open(); + let mut args = Vec::new(); + for (i, p) in f.params.iter().enumerate() { + let reader = format!("__r{i}"); + src.line(format!("var {reader} = new {RT}.WireReader(__args[{i}]);")); + let value = read(s, src, &p.ty, &reader); + src.line(format!("{reader}.End();")); + args.push(value); + } + let call = format!("{target}.{}({})", pascal(function), args.join(", ")); + match &f.result { + Some(t) => { + src.line(format!("var __ret = {call};")); + src.line(format!("var __w = new {RT}.WireWriter();")); + write(s, src, t, "__ret", "__w"); + src.line("return __w.ToArray();"); + } + None => { + src.line(format!("{call};")); + src.line("return global::System.Array.Empty();"); + } + } + src.close_with("});"); +} diff --git a/src/hyperlight_component_csharp/src/lib.rs b/src/hyperlight_component_csharp/src/lib.rs new file mode 100644 index 000000000..474635cb9 --- /dev/null +++ b/src/hyperlight_component_csharp/src/lib.rs @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# bindings for the host side of a WIT world. +//! +//! The generator walks the elaborated component type from +//! [`hyperlight_component_util`], which the Rust bindings also use. The +//! generated code encodes values in the format described in +//! `docs/component-wire-format.md` and calls guest functions through the +//! interfaces in [`RUNTIME_SOURCE`]. It covers every WIT value type. +//! Resources, async functions, futures, streams, maps and flags with more +//! than 32 members are rejected. + +mod codec; +mod host; +mod names; +mod types; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::path::Path; + +use hyperlight_component_util::emit::{State, split_wit_name}; +use hyperlight_component_util::etypes::{Component, ExternDesc, Value}; +use hyperlight_component_util::util::{WitSource, read_wit_type}; +use names::{Src, pascal}; + +/// C# source for the `Hyperlight.Component` namespace that generated +/// bindings depend on. +pub const RUNTIME_SOURCE: &str = include_str!("Runtime.cs"); + +/// Namespace of [`RUNTIME_SOURCE`]. +const RT: &str = "global::Hyperlight.Component"; + +/// A WIT input the C# bindings cannot be generated for. +#[derive(Debug)] +pub struct Error(String); + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +fn error(msg: impl Into) -> Error { + Error(msg.into()) +} + +/// Generate C# bindings for a world in the `.wit` file or directory at +/// `path`. A relative `path` is resolved against `CARGO_MANIFEST_DIR` when +/// set, and the working directory otherwise. See [`generate`] for `world` +/// and `namespace`. +pub fn generate_from_path( + path: &Path, + world: Option<&str>, + namespace: &str, +) -> Result { + generate(WitSource::Wit(path.to_path_buf()), world, namespace) +} + +/// Generate C# bindings for the world named `world` in `source`, or its +/// last world. All generated types are placed under `namespace`. +/// +/// # Panics +/// +/// If `source` cannot be read or elaborated, as the bindgen macros do. +pub fn generate(source: WitSource, world: Option<&str>, namespace: &str) -> Result { + if !namespace.split('.').all(names::is_identifier) { + return Err(error(format!("invalid C# namespace '{namespace}'"))); + } + let world = world.map(str::to_string); + read_wit_type(source, world, |name, ct| emit(&name, ct, namespace)) +} + +type S<'a, 'b> = State<'a, 'b, Root<'b>>; + +fn emit(name: &str, ct: &Component<'_>, namespace: &str) -> Result { + let mut root = Root::new(namespace, name, ct); + // Export types refer to the existential variables, then the + // universal ones. Import types refer to the universal ones. + let mut bound_vars = ct + .instance + .evars + .iter() + .rev() + .chain(ct.uvars.iter().rev()) + .cloned() + .collect::>(); + let mut vars_needs_vars = VecDeque::new(); + let mut s = State::new( + &mut root, + &mut bound_vars, + &mut vars_needs_vars, + false, + false, + ); + types::emit_toplevel(&mut s, ct)?; + host::emit_toplevel(&mut s, ct); + Ok(root.render()) +} + +/// A record, variant, enum or flags type declared in C#. +struct Decl<'b> { + /// Absolute index of the type variable that defines it + var: usize, + /// Definition, relative to the variables after `var` + value: Value<'b>, + /// Fully qualified C# namespace + namespace: String, + name: String, + /// Suffix of its codec methods + mangled: String, + /// Whether the world declares it outside any interface + world_level: bool, +} + +/// The C# source for one world, grouped by namespace, and the names of +/// its declared types. +pub(crate) struct Root<'b> { + /// Root C# namespace + ns: String, + /// WIT name of the world + world: String, + /// C# name of the world, e.g. `Wire` for `test:wire/wire@0.1.0` + world_type: String, + /// Fully qualified name of the static class holding codec methods + codec: String, + decls: Vec>, + /// `namespace:package` names the world uses at several versions + versioned: HashSet, + /// Package namespaces and version of the world, which also hold + /// its inline interfaces + world_package: (Vec, String), + /// Counter for unique local variable names + temps: usize, + /// Source of each namespace, in the order first written + namespaces: Vec<(String, Src)>, + /// Methods of the codec class + codec_methods: Src, +} + +impl<'b> Root<'b> { + fn new(ns: &str, world: &str, ct: &Component<'_>) -> Self { + let wn = split_wit_name(world); + let world_type = pascal(wn.name); + let world_package = ( + wn.namespaces + .iter() + .map(|n| n.to_string()) + .collect::>(), + wn._version.join("@"), + ); + let mut versions = HashMap::>::new(); + for ed in ct.imports.iter().chain(&ct.instance.unqualified.exports) { + if let ExternDesc::Instance(_) = ed.desc { + let (namespaces, version) = package(&world_package, ed.kebab_name); + if !namespaces.is_empty() { + versions + .entry(namespaces.join(":")) + .or_default() + .insert(version); + } + } + } + Root { + ns: ns.to_string(), + world: world.to_string(), + codec: format!("global::{ns}.{world_type}Codec"), + world_type, + decls: Vec::new(), + versioned: versions + .into_iter() + .filter(|(_, v)| v.len() > 1) + .map(|(k, _)| k) + .collect(), + world_package, + temps: 0, + namespaces: Vec::new(), + codec_methods: Src::default(), + } + } + + /// Append a declaration to the namespace `ns`. + fn add(&mut self, ns: String, src: Src) { + match self.namespaces.iter_mut().find(|(n, _)| *n == ns) { + Some((_, existing)) => { + existing.line(""); + existing.append(src); + } + None => self.namespaces.push((ns, src)), + } + } + + fn add_codec_methods(&mut self, src: Src) { + if !self.codec_methods.is_empty() { + self.codec_methods.line(""); + } + self.codec_methods.append(src); + } + + fn render(mut self) -> String { + let name = self.codec.rsplit('.').next().unwrap().to_string(); + let mut codec = Src::default(); + codec.line(format!("internal static class {name}")); + codec.open(); + codec.append(std::mem::take(&mut self.codec_methods)); + codec.close(); + self.add(self.ns.clone(), codec); + + let mut out = Src::default(); + out.line("// "); + out.line(format!( + "// Generated by hyperlight-component-csharp from WIT world {}.", + self.world + )); + out.line("// "); + out.line(""); + out.line("#nullable enable"); + for (ns, src) in self.namespaces { + out.line(""); + out.line(format!("namespace {ns}")); + out.open(); + out.append(src); + out.close(); + } + out.into_string() + } + + /// Namespace of an instance's package relative to the root namespace, + /// e.g. `Wasi.Http`. The version is added, e.g. `Wasi.Http.V0_2_0`, + /// when the world uses the package at several versions. + fn package_namespace(&self, instance: &str) -> Option { + let (namespaces, version) = package(&self.world_package, instance); + if namespaces.is_empty() { + return None; + } + let mut ns = namespaces + .iter() + .map(|n| pascal(n)) + .collect::>() + .join("."); + if self.versioned.contains(&namespaces.join(":")) { + let version = version + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + ns.push_str(&format!(".V{version}")); + } + Some(ns) + } + + /// Namespace of an instance's types relative to the root namespace, + /// e.g. `Wasi.Http.Types`. + fn interface_namespace(&self, instance: &str) -> String { + let name = pascal(split_wit_name(instance).name); + match self.package_namespace(instance) { + Some(pkg) => format!("{pkg}.{name}"), + None => name, + } + } + + fn qualify(&self, relative: Option) -> String { + match relative { + Some(ns) => format!("{}.{ns}", self.ns), + None => self.ns.clone(), + } + } + + fn temp(&mut self) -> String { + self.temps += 1; + format!("__t{}", self.temps - 1) + } + + fn decl(&self, var: usize) -> Option<&Decl<'b>> { + self.decls.iter().find(|d| d.var == var) + } + + /// Fully qualified C# name of a declared type. + fn type_name(&self, d: &Decl) -> String { + format!("global::{}.{}", d.namespace, d.name) + } +} + +/// Package namespaces and version of an instance. Inline interfaces +/// belong to the world's package. +fn package(world_package: &(Vec, String), instance: &str) -> (Vec, String) { + let wn = split_wit_name(instance); + if wn.namespaces.is_empty() { + return world_package.clone(); + } + ( + wn.namespaces.iter().map(|n| n.to_string()).collect(), + wn._version.join("@"), + ) +} + +#[cfg(test)] +mod tests; diff --git a/src/hyperlight_component_csharp/src/names.rs b/src/hyperlight_component_csharp/src/names.rs new file mode 100644 index 000000000..28a7fbacd --- /dev/null +++ b/src/hyperlight_component_csharp/src/names.rs @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# identifier and source formatting helpers. + +const KEYWORDS: &[&str] = &[ + "abstract", + "as", + "base", + "bool", + "break", + "byte", + "case", + "catch", + "char", + "checked", + "class", + "const", + "continue", + "decimal", + "default", + "delegate", + "do", + "double", + "else", + "enum", + "event", + "explicit", + "extern", + "false", + "finally", + "fixed", + "float", + "for", + "foreach", + "goto", + "if", + "implicit", + "in", + "int", + "interface", + "internal", + "is", + "lock", + "long", + "namespace", + "new", + "null", + "object", + "operator", + "out", + "override", + "params", + "private", + "protected", + "public", + "readonly", + "ref", + "return", + "sbyte", + "sealed", + "short", + "sizeof", + "stackalloc", + "static", + "string", + "struct", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "uint", + "ulong", + "unchecked", + "unsafe", + "ushort", + "using", + "virtual", + "void", + "volatile", + "while", +]; + +/// Members every C# type inherits, plus members synthesized for +/// records. A generated member with one of these names would clash. +const RESERVED_MEMBERS: &[&str] = &[ + "Deconstruct", + "EqualityContract", + "Equals", + "Finalize", + "GetHashCode", + "GetType", + "MemberwiseClone", + "PrintMembers", + "ToString", +]; + +/// `foo-bar` to `FooBar`. +pub(super) fn pascal(kebab: &str) -> String { + kebab + .split('-') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } + }) + .collect() +} + +/// `foo-bar` to `fooBar`, escaped if it is a keyword. +pub(super) fn camel(kebab: &str) -> String { + let pascal = pascal(kebab); + let mut chars = pascal.chars(); + let name: String = match chars.next() { + Some(first) => first.to_lowercase().chain(chars).collect(), + None => String::new(), + }; + if KEYWORDS.contains(&name.as_str()) { + format!("@{name}") + } else { + name + } +} + +/// A member name of type `enclosing` that does not clash with the +/// type name or an inherited member. +pub(super) fn member(kebab: &str, enclosing: &str) -> String { + let name = pascal(kebab); + if name == enclosing || RESERVED_MEMBERS.contains(&name.as_str()) { + format!("{name}_") + } else { + name + } +} + +/// The nested type name of a variant case, which must not clash with +/// the `Match` and `Switch` methods generated on the variant or the +/// `Value` property of a case with a payload. +pub(super) fn case(kebab: &str, enclosing: &str) -> String { + let name = member(kebab, enclosing); + if name == "Match" || name == "Switch" || name == "Value" { + format!("{name}_") + } else { + name + } +} + +/// Whether `s` is a C# identifier that needs no `@` escape. +pub(super) fn is_identifier(s: &str) -> bool { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') + && chars.all(|c| c.is_alphanumeric() || c == '_') + && !KEYWORDS.contains(&s) +} + +/// A C# string literal. +pub(super) fn string_literal(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Indented C# source text. +#[derive(Default)] +pub(super) struct Src { + out: String, + indent: usize, +} + +impl Src { + pub(super) fn line(&mut self, line: impl AsRef) { + let line = line.as_ref(); + if !line.is_empty() { + for _ in 0..self.indent { + self.out.push_str(" "); + } + self.out.push_str(line); + } + self.out.push('\n'); + } + + pub(super) fn open(&mut self) { + self.line("{"); + self.indent += 1; + } + + pub(super) fn close(&mut self) { + self.close_with("}"); + } + + pub(super) fn close_with(&mut self, line: &str) { + self.indent -= 1; + self.line(line); + } + + /// Run `f` one indentation level deeper. + pub(super) fn indent_lines(&mut self, f: impl FnOnce(&mut Self)) { + self.indent += 1; + f(self); + self.indent -= 1; + } + + pub(super) fn is_empty(&self) -> bool { + self.out.is_empty() + } + + /// Append `other`, indented to the current level. + pub(super) fn append(&mut self, other: Src) { + for line in other.out.lines() { + self.line(line); + } + } + + pub(super) fn into_string(self) -> String { + self.out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifiers() { + assert_eq!(pascal("on-request"), "OnRequest"); + assert_eq!(pascal("flag0a"), "Flag0a"); + assert_eq!(pascal("http-URL"), "HttpURL"); + assert_eq!(camel("query-string"), "queryString"); + assert_eq!(camel("string"), "@string"); + assert_eq!(member("to-string", "Foo"), "ToString_"); + assert_eq!(member("foo", "Foo"), "Foo_"); + assert_eq!(case("match", "Foo"), "Match_"); + assert_eq!(case("switch", "Foo"), "Switch_"); + assert_eq!(case("matches", "Foo"), "Matches"); + assert_eq!(case("value", "Foo"), "Value_"); + assert!(is_identifier("_a1")); + assert!(!is_identifier("1a")); + assert!(!is_identifier("class")); + assert_eq!(string_literal("a\"b\\c"), "\"a\\\"b\\\\c\""); + } +} diff --git a/src/hyperlight_component_csharp/src/tests.rs b/src/hyperlight_component_csharp/src/tests.rs new file mode 100644 index 000000000..59b26c3d3 --- /dev/null +++ b/src/hyperlight_component_csharp/src/tests.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use hyperlight_component_util::{emit, host, rtypes, util}; +use proc_macro2::{TokenStream, TokenTree}; + +use super::*; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/component_wire") +} + +fn generate_str(wit: &str) -> Result { + generate(WitSource::Inline(wit.to_string()), None, "Test") +} + +/// String literals in `tokens` that can be wire names. +fn string_literals(tokens: TokenStream, out: &mut BTreeSet) { + for token in tokens { + match token { + TokenTree::Group(g) => string_literals(g.stream(), out), + TokenTree::Literal(l) => { + let l = l.to_string(); + if let Some(s) = l.strip_prefix('"').and_then(|l| l.strip_suffix('"')) + && !s.contains(' ') + { + out.insert(s.to_string()); + } + } + _ => {} + } + } +} + +/// The first string literal after each occurrence of `call`. +fn call_names(source: &str, call: &str) -> Vec { + source + .match_indices(call) + .map(|(i, _)| { + let rest = &source[i + call.len()..]; + rest.split('"').nth(1).unwrap().to_string() + }) + .collect() +} + +#[test] +fn wire_names_match_rust_host_bindings() { + let rust = util::read_wit_type( + util::WitSource::Wit("../tests/component_wire/wire.wit".into()), + None, + |kebab, ct| { + emit::run_state(false, false, |s| { + rtypes::emit_toplevel(s, &kebab, ct); + host::emit_toplevel(s, &kebab, ct); + }) + }, + ); + let mut rust_names = BTreeSet::new(); + string_literals(rust, &mut rust_names); + + let cs = generate_from_path(&fixture_dir().join("wire.wit"), None, "Test").unwrap(); + let cs_names = call_names(&cs, "_caller.Call(") + .into_iter() + .chain(call_names(&cs, "registry.Register(")) + .collect::>(); + + assert_eq!(rust_names, cs_names); + assert!(cs_names.contains("test:wire/echo@0.1.0#echo-everything")); + assert!(cs_names.contains("test:wire/host@0.1.0#log")); + assert!(cs_names.contains("now")); + assert!(cs_names.contains("run")); +} + +#[test] +fn rejects_resources() { + let resource = generate_str( + "package a:b; interface i { resource r; f: func(x: borrow); } world w { export i; }", + ); + assert!(resource.unwrap_err().to_string().contains("resources")); +} + +#[test] +#[should_panic(expected = "async")] +fn rejects_streams() { + let _ = generate_str("package a:b; world w { export f: func(x: stream); }"); +} + +#[test] +#[should_panic(expected = "async")] +fn rejects_async_functions() { + let _ = generate_str("package a:b; world w { export f: async func(); }"); +} + +#[test] +fn rejects_invalid_namespace() { + let generate = |namespace| { + generate( + WitSource::Inline("package a:b; world w {}".to_string()), + None, + namespace, + ) + }; + for namespace in ["", "A..B", "A.B-C", "A B", "A.1B", "A.class"] { + assert!(generate(namespace).is_err(), "{namespace}"); + } + assert!(generate("A.B_c").is_ok()); +} + +#[test] +fn rejects_more_than_32_flags() { + let names = (0..33) + .map(|i| format!("g{i}")) + .collect::>() + .join(", "); + let wit = format!( + "package a:b; interface i {{ flags big {{ {names} }} f: func(x: big); }} world w {{ export i; }}" + ); + let error = generate_str(&wit).unwrap_err().to_string(); + assert!(error.contains("more than 32"), "{error}"); +} + +#[test] +fn rejects_types_named_like_generated_classes() { + let error = generate_str( + "package a:b; world w { record w-component { x: u32 } export f: func(x: w-component); }", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("WComponent"), "{error}"); +} diff --git a/src/hyperlight_component_csharp/src/types.rs b/src/hyperlight_component_csharp/src/types.rs new file mode 100644 index 000000000..64623a6d1 --- /dev/null +++ b/src/hyperlight_component_csharp/src/types.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Declares a C# type with codec methods for each record, variant, enum +//! and flags type the world uses, and rejects what the bindings do not +//! support. + +use hyperlight_component_util::emit::ResolvedBoundVar; +use hyperlight_component_util::etypes::{ + Component, Defined, ExternDecl, ExternDesc, Func, Handleable, Tyvar, Value, +}; + +use crate::codec::{emit_decl, emit_type_codec}; +use crate::names::{Src, pascal}; +use crate::{Decl, Error, S, error}; + +/// Walk the imports, then the exports, of the world `ct`. +pub(crate) fn emit_toplevel<'b>(s: &mut S<'_, 'b>, ct: &Component<'b>) -> Result<(), Error> { + s.var_offset = ct.instance.evars.len(); + for ed in &ct.imports { + emit_extern_decl(s, ed, true)?; + } + s.var_offset = 0; + for ed in &ct.instance.unqualified.exports { + emit_extern_decl(s, ed, true)?; + } + + // World types share the root namespace with the generated classes. + let world = &s.root_mod.world_type; + let generated = [ + format!("{world}Component"), + format!("I{world}Imports"), + format!("{world}Codec"), + ]; + if let Some(d) = s + .root_mod + .decls + .iter() + .find(|d| d.world_level && generated.contains(&d.name)) + { + return Err(error(format!( + "type '{}' clashes with the generated class {}", + s.bound_vars[d.var].origin.last_name().unwrap_or_default(), + d.name + ))); + } + Ok(()) +} + +fn emit_extern_decl<'b>(s: &mut S<'_, 'b>, ed: &ExternDecl<'b>, top: bool) -> Result<(), Error> { + match &ed.desc { + ExternDesc::Type(Defined::Handleable(Handleable::Var(Tyvar::Bound(n)))) => visit_var(s, *n), + ExternDesc::Type(_) => Err(error(format!( + "type '{}': resources are not supported", + ed.kebab_name + ))), + ExternDesc::Func(f) => visit_func(s, ed.kebab_name, f), + ExternDesc::Instance(it) if top => { + for ed in &it.exports { + emit_extern_decl(s, ed, false)?; + } + Ok(()) + } + _ => Err(error(format!( + "'{}': nested instances, components and core modules are not supported", + ed.kebab_name + ))), + } +} + +fn visit_func<'b>(s: &mut S<'_, 'b>, name: &str, f: &Func<'b>) -> Result<(), Error> { + if name.starts_with('[') { + return Err(error(format!( + "function '{name}': resources are not supported" + ))); + } + for p in &f.params { + visit_value(s, &p.ty)?; + } + if let Some(ty) = &f.result { + visit_value(s, ty)?; + } + Ok(()) +} + +/// Check that `v` is supported and declare every type it uses that +/// needs a declaration. +fn visit_value<'b>(s: &mut S<'_, 'b>, v: &Value<'b>) -> Result<(), Error> { + match v { + Value::Bool | Value::S(_) | Value::U(_) | Value::F(_) | Value::Char | Value::String => { + Ok(()) + } + Value::List(t) | Value::FixList(t, _) | Value::Option(t) => visit_value(s, t), + Value::Tuple(ts) => ts.iter().try_for_each(|t| visit_value(s, t)), + Value::Result(ok, err) => { + for t in [ok.as_ref(), err.as_ref()].into_iter().flatten() { + visit_value(s, t)?; + } + Ok(()) + } + Value::Var(Some(Tyvar::Bound(n)), _) => visit_var(s, *n), + Value::Own(_) | Value::Borrow(_) => Err(error("resources are not supported")), + Value::Record(_) | Value::Variant(_) | Value::Enum(_) | Value::Flags(_) => Err(error( + "anonymous records, variants, enums and flags are not supported", + )), + Value::Var(..) => Err(error("unresolved type variable")), + } +} + +/// Visit the definition of the type variable `n`, and declare it if it is +/// a record, variant, enum or flags type. Its name is assigned before +/// its fields are visited, and its source is written after. +fn visit_var<'b>(s: &mut S<'_, 'b>, n: u32) -> Result<(), Error> { + let (var, v) = match s.resolve_bound_var(n) { + ResolvedBoundVar::Definite { + final_bound_var, + ty: Defined::Value(v), + } => (s.var_offset + final_bound_var as usize, v), + ResolvedBoundVar::Resource { .. } => { + return Err(error("resources are not supported")); + } + ResolvedBoundVar::Definite { .. } => { + return Err(error("non-value type definitions are not supported")); + } + }; + if s.root_mod.decl(var).is_some() { + return Ok(()); + } + let mut s = s.clone(); + s.var_offset = var + 1; + let fields = match &v { + Value::Record(fields) => fields.iter().map(|f| &f.ty).collect(), + Value::Variant(cases) => cases.iter().filter_map(|c| c.ty.as_ref()).collect(), + Value::Enum(_) | Value::Flags(_) => Vec::new(), + _ => return visit_value(&mut s, &v), + }; + let i = declare(&mut s, var, &v)?; + for ty in fields { + visit_value(&mut s, ty)?; + } + let ns = s.root_mod.decls[i].namespace.clone(); + let decl = emit_decl(&mut s, i); + s.root_mod.add(ns, decl); + let mut methods = Src::default(); + emit_type_codec(&mut s, &mut methods, i); + s.root_mod.add_codec_methods(methods); + Ok(()) +} + +/// Name the C# type for the type variable `var` after the WIT type and +/// interface it comes from, and return its index in `Root::decls`. +fn declare<'b>(s: &mut S<'_, 'b>, var: usize, v: &Value<'b>) -> Result { + let origin = &s.bound_vars[var].origin; + let path = origin.path.as_deref().unwrap_or_default(); + let (wit_name, instance) = match path { + [ty] => (ty.name(), None), + [ty, instance] => (ty.name(), Some(instance.name())), + _ => return Err(error("types of nested instances are not supported")), + }; + if let Value::Flags(flags) = v + && flags.len() > 32 + { + return Err(error(format!( + "type '{wit_name}': flags with more than 32 members are not supported" + ))); + } + let root = &mut *s.root_mod; + let name = pascal(wit_name); + let (namespace, base) = match instance { + Some(instance) => { + let relative = root.interface_namespace(instance); + let base = format!("{}_{name}", relative.replace('.', "_")); + (root.qualify(Some(relative)), base) + } + None => (root.ns.clone(), name.clone()), + }; + let mut mangled = base.clone(); + let mut n = 1; + while root.decls.iter().any(|d| d.mangled == mangled) { + n += 1; + mangled = format!("{base}{n}"); + } + root.decls.push(Decl { + var, + value: v.clone(), + namespace, + name, + mangled, + world_level: instance.is_none(), + }); + Ok(root.decls.len() - 1) +} diff --git a/src/hyperlight_component_util/src/emit.rs b/src/hyperlight_component_util/src/emit.rs index 80faf14db..837fc00fd 100644 --- a/src/hyperlight_component_util/src/emit.rs +++ b/src/hyperlight_component_util/src/emit.rs @@ -314,11 +314,12 @@ pub enum ResolvedBoundVar<'a> { }, } -/// A whole grab-bag of useful state to have while emitting Rust +/// A whole grab-bag of useful state to have while emitting bindings. +/// `M` holds the output. [`Mod`] holds Rust output. #[derive(Debug)] -pub struct State<'a, 'b> { - /// A pointer to a [`Mod`] that everything we emit will end up in - pub root_mod: &'a mut Mod, +pub struct State<'a, 'b, M = Mod> { + /// A pointer to the output that everything we emit will end up in + pub root_mod: &'a mut M, /// A cursor to the current submodule (under [`State::root_mod`]), /// where decls that we are looking at right now should end up pub mod_cursor: Vec, @@ -414,6 +415,10 @@ pub struct State<'a, 'b> { /// When a name is in this set, the parent namespace is prepended to /// disambiguate the trait member name. pub colliding_import_names: HashSet, + /// Full kebab name of the instance whose functions are being + /// emitted, e.g. `wasi:http/types@0.2.0`. Wire function names are + /// derived from it. + pub cur_instance: Option, } /// Create a State with all of its &mut references pointing to @@ -440,9 +445,9 @@ pub fn run_state<'b, F: for<'a> FnMut(&mut State<'a, 'b>)>( root_mod.into_tokens() } -impl<'a, 'b> State<'a, 'b> { +impl<'a, 'b, M> State<'a, 'b, M> { pub fn new( - root_mod: &'a mut Mod, + root_mod: &'a mut M, bound_vars: &'a mut VecDeque>, vars_needs_vars: &'a mut VecDeque>, is_guest: bool, @@ -467,9 +472,10 @@ impl<'a, 'b> State<'a, 'b> { is_guest, is_wasmtime_guest, colliding_import_names: HashSet::new(), + cur_instance: None, } } - pub fn clone<'c>(&'c mut self) -> State<'c, 'b> { + pub fn clone<'c>(&'c mut self) -> State<'c, 'b, M> { State { root_mod: self.root_mod, mod_cursor: self.mod_cursor.clone(), @@ -489,8 +495,33 @@ impl<'a, 'b> State<'a, 'b> { is_guest: self.is_guest, is_wasmtime_guest: self.is_wasmtime_guest, colliding_import_names: self.colliding_import_names.clone(), + cur_instance: self.cur_instance.clone(), + } + } + /// Resolve a type variable as far as possible: either this ends + /// up with a definition, in which case, let's get that, or it + /// ends up with a resource type, in which case we return the + /// resource index + /// + /// Distinct from [`Ctx::resolve_tv`], which is mostly concerned + /// with free variables, because this is concerned entirely with + /// bound variables. + pub fn resolve_bound_var(&self, n: u32) -> ResolvedBoundVar<'b> { + let noff = self.var_offset as u32 + n; + match &self.bound_vars[noff as usize].bound { + TypeBound::Eq(Defined::Handleable(Handleable::Var(Tyvar::Bound(nn)))) => { + self.resolve_bound_var(n + 1 + nn) + } + TypeBound::Eq(t) => ResolvedBoundVar::Definite { + final_bound_var: n, + ty: t.clone(), + }, + TypeBound::SubResource => ResolvedBoundVar::Resource { rtidx: noff }, } } +} + +impl<'a, 'b> State<'a, 'b> { /// Obtain a reference to the [`Mod`] that we are currently /// generating code in, creating it if necessary pub fn cur_mod<'c>(&'c mut self) -> &'c mut Mod { @@ -524,6 +555,13 @@ impl<'a, 'b> State<'a, 'b> { s.mod_cursor = cursor; s } + /// Copy the state to emit the functions of the instance `wn`, + /// moving the module cursor to its namespace. + pub fn with_instance<'c>(&'c mut self, wn: &WitName) -> State<'c, 'b> { + let mut s = self.with_cursor(wn.namespace_idents()); + s.cur_instance = Some(wn.kebab.to_string()); + s + } /// Copy the state, replacing its [`State::cur_needs_vars`] reference, /// allowing a caller to capture the vars referenced by any emit /// run with the resultant state @@ -772,27 +810,6 @@ impl<'a, 'b> State<'a, 'b> { } self.root_mod.adjust_vars(n); } - /// Resolve a type variable as far as possible: either this ends - /// up with a definition, in which case, let's get that, or it - /// ends up with a resource type, in which case we return the - /// resource index - /// - /// Distinct from [`Ctx::resolve_tv`], which is mostly concerned - /// with free variables, because this is concerned entirely with - /// bound variables. - pub fn resolve_bound_var(&self, n: u32) -> ResolvedBoundVar<'b> { - let noff = self.var_offset as u32 + n; - match &self.bound_vars[noff as usize].bound { - TypeBound::Eq(Defined::Handleable(Handleable::Var(Tyvar::Bound(nn)))) => { - self.resolve_bound_var(n + 1 + nn) - } - TypeBound::Eq(t) => ResolvedBoundVar::Definite { - final_bound_var: n, - ty: t.clone(), - }, - TypeBound::SubResource => ResolvedBoundVar::Resource { rtidx: noff }, - } - } /// Construct a namespace path referring to the resource trait for /// a resource with the given name @@ -813,6 +830,8 @@ impl<'a, 'b> State<'a, 'b> { /// namespaces, an actual name, and possibly a SemVer version #[derive(Debug, Clone)] pub struct WitName<'a> { + /// The unparsed name + pub kebab: &'a str, pub namespaces: Vec<&'a str>, pub name: &'a str, pub _version: Vec<&'a str>, @@ -844,6 +863,7 @@ pub fn split_wit_name(n: &str) -> WitName<'_> { let name = versioned_name.next().unwrap(); namespaces.extend(slash_components.rev()); WitName { + kebab: n, namespaces, name, _version: versioned_name.collect(), diff --git a/src/hyperlight_component_util/src/guest.rs b/src/hyperlight_component_util/src/guest.rs index ffbcb596f..339571aa8 100644 --- a/src/hyperlight_component_util/src/guest.rs +++ b/src/hyperlight_component_util/src/guest.rs @@ -127,7 +127,7 @@ fn emit_import_extern_decl<'a, 'b, 'c>( /// declaration that implements this imported instance in terms of /// hyperlight host calls fn emit_import_instance<'a, 'b, 'c>(s: &'c mut State<'a, 'b>, wn: WitName, it: &'c Instance<'b>) { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); let imports = it @@ -241,7 +241,7 @@ fn emit_export_instance<'a, 'b, 'c>( path: Vec, it: &'c Instance<'b>, ) -> TokenStream { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); s.cur_trait = Some(kebab_to_type(wn.name)); let exports = it diff --git a/src/hyperlight_component_util/src/hl.rs b/src/hyperlight_component_util/src/hl.rs index 6029468fe..77719a7a1 100644 --- a/src/hyperlight_component_util/src/hl.rs +++ b/src/hyperlight_component_util/src/hl.rs @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -use itertools::Itertools; use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; @@ -9,16 +8,16 @@ use crate::emit::{ResolvedBoundVar, State, kebab_to_cons, kebab_to_flags_const, use crate::etypes::{self, Defined, Handleable, Tyvar, Value}; use crate::rtypes; -/// Construct a string that can be used "on the wire" to identify a -/// given function between the guest/host. This should be replaced -/// with an integer index so that we can dispatch less dynamically in -/// the future. -pub fn emit_fn_hl_name(s: &State, kebab: &str) -> String { - s.mod_cursor - .iter() - .map(|x| x.to_string()) - .chain(std::iter::once(kebab.to_string())) - .join("::") +/// Construct the string that identifies a function on the wire +/// between the guest and host: `#` for instance +/// members, e.g. `wasi:http/types@0.2.0#[method]fields.get`, and the +/// plain function name for functions imported or exported directly +/// by the world. +pub fn emit_fn_hl_name(s: &State<'_, '_, M>, kebab: &str) -> String { + match &s.cur_instance { + Some(instance) => format!("{instance}#{kebab}"), + None => kebab.to_string(), + } } /// Emit code to unmarshal a value into a toplevel type (i.e. types @@ -133,7 +132,7 @@ pub fn emit_hl_unmarshal_toplevel_value( } }); quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()); let #inid = &#id[4..]; match n { #(#vcs,)* @@ -148,7 +147,7 @@ pub fn emit_hl_unmarshal_toplevel_value( quote! { #i => ( #tname::#case_name, 4) } }); quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()); match n { #(#vcs,)* _ => panic!("invalid value for enum"), @@ -193,15 +192,15 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let (tid, width) = rtypes::numeric_rtype(vt); let blen = width as usize / 8; quote! { - (#tid::from_ne_bytes(#id[0..#blen].try_into().unwrap()), #blen) + (#tid::from_le_bytes(#id[0..#blen].try_into().unwrap()), #blen) } } Value::Char => quote! { - (unsafe { char::from_u32_unchecked(u32::from_ne_bytes( + (unsafe { char::from_u32_unchecked(u32::from_le_bytes( #id[0..4].try_into().unwrap())) }, 4) }, Value::String => quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()) as usize; + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()) as usize; let s = ::alloc::string::ToString::to_string(::core::str::from_utf8(&#id[4..4 + n]).unwrap()); // todo: better error handling (s, n + 4) }, @@ -210,7 +209,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let inid = format_ident!("{}_elem", id); let vtun = emit_hl_unmarshal_value(s, inid.clone(), vt); quote! { - let n = u32::from_ne_bytes(#id[0..4].try_into().unwrap()) as usize; + let n = u32::from_le_bytes(#id[0..4].try_into().unwrap()) as usize; let mut #retid = alloc::vec::Vec::new(); let mut cursor = 4; for i in 0..n { @@ -270,7 +269,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let inid = format_ident!("{}_body", id); let vtun = emit_hl_unmarshal_value(s, inid.clone(), vt); quote! { - let n = u8::from_ne_bytes(#id[0..1].try_into().unwrap()); + let n = u8::from_le_bytes(#id[0..1].try_into().unwrap()); if n != 0 { let #inid = &#id[1..]; let (x, b) = { #vtun }; @@ -293,7 +292,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr quote! { ((), 0) } }; quote! { - let i = u8::from_ne_bytes(#id[0..1].try_into().unwrap()); + let i = u8::from_le_bytes(#id[0..1].try_into().unwrap()); let #inid = &#id[1..]; if i == 0 { let (x, b) = { #vtun1 }; @@ -311,19 +310,19 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let rid = format_ident!("HostResource{}", vi); if s.is_wasmtime_guest { quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (::wasmtime::component::Resource::<#rid>::new_own(i), 4) } } else { quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (#rid { rep: i }, 4) } } } else { let rid = format_ident!("resource{}", vi); quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); let Some(v) = rts.#rid[i as usize].take() else { // todo: better error handling panic!(""); @@ -339,7 +338,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr let rid = format_ident!("HostResource{}", vi); if s.is_wasmtime_guest { quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (::wasmtime::component::Resource::<#rid>::new_borrow(i), 4) } } else { @@ -356,7 +355,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr // AsRef or something in the // future... quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); (#rid { rep: i }, 4) } @@ -364,7 +363,7 @@ pub fn emit_hl_unmarshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStr } else { let rid = format_ident!("resource{}", vi); quote! { - let i = u32::from_ne_bytes(#id[0..4].try_into().unwrap()); + let i = u32::from_le_bytes(#id[0..4].try_into().unwrap()); let Some(v) = rts.#rid[i as usize].borrow() else { // todo: better error handling panic!(""); @@ -474,7 +473,7 @@ pub fn emit_hl_marshal_toplevel_value( let vtun = emit_hl_marshal_value(s, bodyid.clone(), ty); quote! { #tname::#case_name(#bodyid) => { - #retid.extend(u32::to_ne_bytes(#i)); + #retid.extend(u32::to_le_bytes(#i)); #retid.extend({ #vtun }) } } @@ -482,7 +481,7 @@ pub fn emit_hl_marshal_toplevel_value( None => { quote! { #tname::#case_name => { - #retid.extend(u32::to_ne_bytes(#i)); + #retid.extend(u32::to_le_bytes(#i)); } } } @@ -504,7 +503,7 @@ pub fn emit_hl_marshal_toplevel_value( quote! { #tname::#case_name => #i } }); quote! { - alloc::vec::Vec::from(u32::to_ne_bytes(match #id { + alloc::vec::Vec::from(u32::to_le_bytes(match #id { #(#vcs,)* })) } @@ -528,10 +527,10 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea }, Value::S(_) | Value::U(_) | Value::F(_) => { let (tid, _) = rtypes::numeric_rtype(vt); - quote! { alloc::vec::Vec::from(#tid::to_ne_bytes(#id)) } + quote! { alloc::vec::Vec::from(#tid::to_le_bytes(#id)) } } Value::Char => quote! { - alloc::vec::Vec::from((#id as u32).to_ne_bytes()) + alloc::vec::Vec::from((#id as u32).to_le_bytes()) }, Value::String => { let retid = format_ident!("{}_string", id); @@ -539,7 +538,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { let mut #retid = alloc::vec::Vec::new(); let #bytesid = #id.into_bytes(); - #retid.extend(alloc::vec::Vec::from(u32::to_ne_bytes(#bytesid.len() as u32))); + #retid.extend(alloc::vec::Vec::from(u32::to_le_bytes(#bytesid.len() as u32))); #retid.extend(#bytesid); #retid } @@ -551,7 +550,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { let mut #retid = alloc::vec::Vec::new(); let n = #id.len(); - #retid.extend(alloc::vec::Vec::from(u32::to_ne_bytes(n as u32))); + #retid.extend(alloc::vec::Vec::from(u32::to_le_bytes(n as u32))); for #inid in #id { #retid.extend({ #vtun }) } @@ -598,11 +597,11 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { match #id { ::core::option::Option::Some(#bodyid) => { - let mut #retid = alloc::vec::Vec::from(u8::to_ne_bytes(1)); + let mut #retid = alloc::vec::Vec::from(u8::to_le_bytes(1)); #retid.extend({ #vtun }); #retid }, - ::core::option::Option::None => alloc::vec::Vec::from(u8::to_ne_bytes(0)) + ::core::option::Option::None => alloc::vec::Vec::from(u8::to_le_bytes(0)) } } } @@ -624,12 +623,12 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! { match #id { ::core::result::Result::Ok(#bodyid) => { - let mut #retid = alloc::vec::Vec::from(u8::to_ne_bytes(0)); + let mut #retid = alloc::vec::Vec::from(u8::to_le_bytes(0)); #vtun1 #retid }, ::core::result::Result::Err(#bodyid) => { - let mut #retid = alloc::vec::Vec::from(u8::to_ne_bytes(1)); + let mut #retid = alloc::vec::Vec::from(u8::to_le_bytes(1)); #vtun2 #retid }, @@ -646,14 +645,14 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! {} }; quote! { - alloc::vec::Vec::from(u32::to_ne_bytes(#id.rep #call)) + alloc::vec::Vec::from(u32::to_le_bytes(#id.rep #call)) } } else { let rid = format_ident!("resource{}", vi); quote! { let i = rts.#rid.len(); rts.#rid.push_back(::hyperlight_common::resource::ResourceEntry::give(#id)); - alloc::vec::Vec::from(u32::to_ne_bytes(i as u32)) + alloc::vec::Vec::from(u32::to_le_bytes(i as u32)) } } } @@ -667,7 +666,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea quote! {} }; quote! { - alloc::vec::Vec::from(u32::to_ne_bytes(#id.rep #call)) + alloc::vec::Vec::from(u32::to_le_bytes(#id.rep #call)) } } else { let rid = format_ident!("resource{}", vi); @@ -676,7 +675,7 @@ pub fn emit_hl_marshal_value(s: &mut State, id: Ident, vt: &Value) -> TokenStrea let (lrg, re) = ::hyperlight_common::resource::ResourceEntry::lend(#id); to_cleanup.push(Box::new(lrg)); rts.#rid.push_back(re); - alloc::vec::Vec::from(u32::to_ne_bytes(i as u32)) + alloc::vec::Vec::from(u32::to_le_bytes(i as u32)) } } } diff --git a/src/hyperlight_component_util/src/host.rs b/src/hyperlight_component_util/src/host.rs index 69bd1d13a..9c3ba0cb4 100644 --- a/src/hyperlight_component_util/src/host.rs +++ b/src/hyperlight_component_util/src/host.rs @@ -101,7 +101,7 @@ fn emit_export_extern_decl<'a, 'b, 'c>( /// declaration that implements this exported instance in terms of /// hyperlight guest calls fn emit_export_instance<'a, 'b, 'c>(s: &'c mut State<'a, 'b>, wn: WitName, it: &'c Instance<'b>) { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); let exports = it @@ -311,7 +311,7 @@ fn emit_import_instance<'a, 'b, 'c>( wn: WitName, it: &'c Instance<'b>, ) -> TokenStream { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); s.cur_trait = Some(kebab_to_type(wn.name)); diff --git a/src/hyperlight_component_util/src/util.rs b/src/hyperlight_component_util/src/util.rs index a013564a9..50904386d 100644 --- a/src/hyperlight_component_util/src/util.rs +++ b/src/hyperlight_component_util/src/util.rs @@ -89,9 +89,13 @@ impl WitSource { } } +/// Resolve `path` against the crate being built, or the working +/// directory outside a cargo build. fn manifest_path(path: &std::path::Path) -> std::path::PathBuf { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); - std::path::Path::new(&manifest_dir).join(path) + match std::env::var_os("CARGO_MANIFEST_DIR") { + Some(dir) => std::path::Path::new(&dir).join(path), + None => path.to_path_buf(), + } } /// Read and parse a WIT type from a supported bindgen input. diff --git a/src/hyperlight_host/tests/component_wire_test.rs b/src/hyperlight_host/tests/component_wire_test.rs new file mode 100644 index 000000000..370706eb1 --- /dev/null +++ b/src/hyperlight_host/tests/component_wire_test.rs @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Checks the encoding produced by `host_bindgen!` against byte vectors +//! that the C# binding tests in `src/tests/component_wire/csharp` also +//! check. + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use hyperlight_common::component::Negative; +use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterValue, ReturnValue}; +use hyperlight_common::func::{ParameterTuple, SupportedReturnType}; +use hyperlight_host::func::{HostFunction, Registerable}; +use hyperlight_host::sandbox::Callable; + +extern crate alloc; +mod bindings { + hyperlight_component_macro::host_bindgen!(wit: "../tests/component_wire/wire.wit"); +} + +use bindings::Extra; +use bindings::test::wire::types::{ + Color, Everything, FullFlags, ManyFlags, Permissions, Point, Shape, +}; +use bindings::test::wire::{Echo, Host, Types, WireExports, WireImports}; + +/// A `FullFlags` with only the named flags set. +macro_rules! full_flags { + ($($set:ident),*) => {{ + #[allow(unused_mut)] + let mut flags = FullFlags { + h0: false, h1: false, h2: false, h3: false, h4: false, h5: false, h6: false, h7: false, h8: false, h9: false, h10: false, h11: false, h12: false, h13: false, h14: false, h15: false, h16: false, h17: false, h18: false, h19: false, h20: false, h21: false, h22: false, h23: false, h24: false, h25: false, h26: false, h27: false, h28: false, h29: false, h30: false, h31: false + }; + $(flags.$set = true;)* + flags + }}; +} + +struct Imports; + +impl Types for Imports {} + +impl Host for Imports { + fn log(&mut self, _message: String, _event: u8) {} + + fn add(&mut self, left: Point, right: Point) -> Point { + Point { + x: left.x + right.x, + y: left.y + right.y, + } + } +} + +impl WireImports for Imports { + type Types = Self; + fn types(&mut self) -> impl core::borrow::BorrowMut { + self + } + type Host = Self; + fn host(&mut self) -> impl core::borrow::BorrowMut { + self + } + fn now(&mut self) -> u64 { + 7 + } +} + +type ErasedHostFunction = Box>) -> Vec>; + +/// Host functions by wire name. +#[derive(Default)] +struct Registry(HashMap); + +impl Registerable for Registry { + fn register_host_function( + &mut self, + name: &str, + hf: impl Into>, + ) -> hyperlight_host::Result<()> { + let hf = hf.into(); + self.0.insert( + name.to_string(), + Box::new(move |args| { + let args = args.into_iter().map(ParameterValue::VecBytes).collect(); + let result = hf.call(Args::from_value(args).unwrap()).unwrap(); + match result.into_value() { + ReturnValue::VecBytes(bytes) => bytes, + other => panic!("unexpected result {other:?}"), + } + }), + ); + Ok(()) + } +} + +/// A guest whose exports return their arguments. +#[derive(Default)] +struct Guest { + args: Vec>, +} + +impl Callable for Guest { + fn call( + &mut self, + func_name: &str, + args: impl ParameterTuple, + ) -> hyperlight_host::Result { + self.args = args + .into_value() + .into_iter() + .map(|arg| match arg { + ParameterValue::VecBytes(bytes) => bytes, + other => panic!("unexpected argument {other:?}"), + }) + .collect(); + let result = match func_name { + "test:wire/echo@0.1.0#no-args" => 42u32.to_le_bytes().to_vec(), + "test:wire/echo@0.1.0#no-result" => Vec::new(), + // ok(input) + "run" => [&[0][..], &self.args[0]].concat(), + // The arguments concatenated are the encoding of their tuple. + _ => self.args.concat(), + }; + Ok(Output::from_value(ReturnValue::VecBytes(result))?) + } +} + +type Sandbox = bindings::WireSandbox; + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn string(s: &str) -> Vec { + [&(s.len() as u32).to_le_bytes()[..], s.as_bytes()].concat() +} + +fn point(x: i32, y: i32) -> Vec { + [x.to_le_bytes(), y.to_le_bytes()].concat() +} + +#[derive(Default)] +struct Vectors(BTreeMap); + +impl Vectors { + fn add(&mut self, name: &str, bytes: &[u8]) { + assert!( + self.0.insert(name.to_string(), hex(bytes)).is_none(), + "{name}" + ); + } + + fn add_args(&mut self, name: &str, args: &[Vec]) { + match args { + [arg] => self.add(name, arg), + _ => { + for (i, arg) in args.iter().enumerate() { + self.add(&format!("{name}.{i}"), arg); + } + } + } + } + + /// Call an export that returns its argument, check that decoding + /// and re-encoding the result reproduces the argument bytes, and + /// record them. + fn echo( + &mut self, + sb: &mut Sandbox, + name: &str, + call: impl Fn(&mut Sandbox, T) -> T, + value: T, + ) { + let result = call(sb, value); + let args = sb.sb.args.clone(); + call(sb, result); + assert_eq!(args, sb.sb.args, "{name}"); + self.add_args(name, &args); + } +} + +fn everything_full() -> Everything { + Everything { + b: true, + u8v: 0xab, + s8v: -2, + u16v: 0xbeef, + s16v: -300, + u32v: 0xdead_beef, + s32v: -70_000, + u64v: 0x0123_4567_89ab_cdef, + s64v: -5_000_000_000, + f32v: 1.5, + f64v: -2.25, + c: '🎈', + s: "héllo".into(), + bytes: vec![1, 2, 3], + points: vec![Point { x: 1, y: 2 }, Point { x: -3, y: 4 }], + nested: vec![vec!["a".into(), "bc".into()], vec![]], + fixed_bytes: [7, 8, 9], + fixed_points: [Point { x: 5, y: 6 }, Point { x: 7, y: 8 }], + maybe: Some("m".into()), + maybe_num: Some(9), + maybe_maybe: Some(Some("mm".into())), + res: Ok(Point { x: 10, y: 11 }), + res_unit: Ok(()), + res_empty: Ok(()), + tup: ("t".into(), 12, Point { x: 13, y: 14 }), + tup8: (1, 2, 3, 4, 5, 6, 7, 0x0809), + maybe_maybe_num: Some(Some(17)), + shape: Shape::Rect(Point { x: 15, y: 16 }), + color: Color::Green, + perms: Permissions { + read: true, + write: false, + exec: true, + }, + many: ManyFlags { + f0: true, + f1: false, + f2: false, + f3: false, + f4: false, + f5: false, + f6: false, + f7: false, + f8: false, + f9: true, + }, + full: full_flags!(h0, h31), + } +} + +fn everything_alt() -> Everything { + Everything { + b: false, + u8v: u8::MAX, + s8v: i8::MIN, + u16v: u16::MAX, + s16v: i16::MIN, + u32v: u32::MAX, + s32v: i32::MIN, + u64v: u64::MAX, + s64v: i64::MIN, + f32v: -0.0, + f64v: f64::MAX, + c: 'a', + s: String::new(), + bytes: vec![], + points: vec![], + nested: vec![], + fixed_bytes: [0, 0, 0], + fixed_points: [Point { x: 0, y: 0 }, Point { x: 0, y: 0 }], + maybe: None, + maybe_num: None, + maybe_maybe: Some(None), + res: Err("bad".into()), + res_unit: Err("e".into()), + res_empty: Err(()), + tup: (String::new(), 0, Point { x: 0, y: 0 }), + tup8: (u8::MAX, 0, 0, 0, 0, 0, 0, u16::MAX), + maybe_maybe_num: Some(None), + shape: Shape::Named("n".into()), + color: Color::Blue, + perms: Permissions { + read: false, + write: false, + exec: false, + }, + many: ManyFlags { + f0: false, + f1: false, + f2: false, + f3: false, + f4: false, + f5: false, + f6: false, + f7: false, + f8: true, + f9: false, + }, + full: full_flags!(), + } +} + +fn everything_none() -> Everything { + Everything { + maybe_maybe: None, + maybe_maybe_num: None, + full: full_flags!( + h0, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, h11, h12, h13, h14, h15, h16, h17, h18, + h19, h20, h21, h22, h23, h24, h25, h26, h27, h28, h29, h30, h31 + ), + shape: Shape::None, + color: Color::Red, + perms: Permissions { + read: false, + write: true, + exec: false, + }, + ..everything_alt() + } +} + +#[test] +fn rust_bindings_match_wire_vectors() { + let mut registry = Registry::default(); + let rt = bindings::register_host_functions(&mut registry, Imports).unwrap(); + let mut sb = Sandbox { + sb: Guest::default(), + rt, + }; + let mut v = Vectors::default(); + + let echo_everything = |sb: &mut Sandbox, x| sb.echo_everything(x).unwrap(); + v.echo( + &mut sb, + "everything-full", + echo_everything, + everything_full(), + ); + v.echo(&mut sb, "everything-alt", echo_everything, everything_alt()); + v.echo( + &mut sb, + "everything-none", + echo_everything, + everything_none(), + ); + + let echo_shape = |sb: &mut Sandbox, x| sb.echo_shape(x).unwrap(); + v.echo(&mut sb, "shape-none", echo_shape, Shape::None); + v.echo(&mut sb, "shape-circle", echo_shape, Shape::Circle(3)); + v.echo( + &mut sb, + "shape-rect", + echo_shape, + Shape::Rect(Point { x: 1, y: -1 }), + ); + v.echo(&mut sb, "shape-named", echo_shape, Shape::Named("x".into())); + v.echo( + &mut sb, + "shape-path", + echo_shape, + Shape::Path(vec![Point { x: 1, y: 2 }, Point { x: 3, y: 4 }]), + ); + + let ping = |sb: &mut Sandbox, x| sb.extra().ping(x).unwrap(); + v.echo(&mut sb, "extra-ping", ping, 0xa5); + + let echo_string = |sb: &mut Sandbox, x| sb.echo_string(x).unwrap(); + v.echo(&mut sb, "string", echo_string, "héllo, 世界 🎈".to_string()); + + let two_args = |sb: &mut Sandbox, (a, b)| sb.two_args(a, b).unwrap(); + v.echo(&mut sb, "two-args", two_args, (5, "five".to_string())); + + assert_eq!(sb.no_args().unwrap(), 42); + assert!(sb.sb.args.is_empty()); + + sb.no_result(9).unwrap(); + v.add_args("no-result", &sb.sb.args.clone()); + + assert_eq!(sb.run("go".into()).unwrap(), Ok("go".to_string())); + v.add_args("run", &sb.sb.args.clone()); + + let mut host = |name: &str, args: Vec>| (registry.0.get_mut(name).unwrap())(args); + let left = point(3, -4); + let right = point(10, 20); + let sum = host( + "test:wire/host@0.1.0#add", + vec![left.clone(), right.clone()], + ); + assert_eq!(sum, point(13, 16)); + v.add_args("host-add", &[left, right]); + v.add("host-add.result", &sum); + + let log = host("test:wire/host@0.1.0#log", vec![string("hi"), vec![2]]); + assert!(log.is_empty()); + let now = host("now", vec![]); + assert_eq!(now, 7u64.to_le_bytes()); + v.add("now.result", &now); + + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/component_wire/vectors.txt"); + let mut generated = String::from( + "# Encodings checked by src/hyperlight_host/tests/component_wire_test.rs and\n\ + # src/tests/component_wire/csharp. Regenerate with\n\ + # HYPERLIGHT_BLESS=1 cargo test -p hyperlight-host --test component_wire_test\n", + ); + for (name, bytes) in &v.0 { + generated.push_str(&format!("{name} {bytes}\n")); + } + if std::env::var_os("HYPERLIGHT_BLESS").is_some() { + std::fs::write(&path, &generated).unwrap(); + } + let committed = std::fs::read_to_string(&path) + .unwrap() + .replace("\r\n", "\n"); + assert!( + committed == generated, + "{} is out of date. Rerun with HYPERLIGHT_BLESS=1 to update it.", + path.display() + ); +} diff --git a/src/tests/component_wire/csharp/ComponentTests.csproj b/src/tests/component_wire/csharp/ComponentTests.csproj new file mode 100644 index 000000000..c3eaa496d --- /dev/null +++ b/src/tests/component_wire/csharp/ComponentTests.csproj @@ -0,0 +1,39 @@ + + + + Exe + net8.0 + Major + enable + enable + true + false + $([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '../../../..')) + + + + + + + + + + + + + + + + + + $(MSBuildProjectDirectory)/$(IntermediateOutputPath)generated/ + + + + + + + + + diff --git a/src/tests/component_wire/csharp/EdgeTests.cs b/src/tests/component_wire/csharp/EdgeTests.cs new file mode 100644 index 000000000..bc4901c11 --- /dev/null +++ b/src/tests/component_wire/csharp/EdgeTests.cs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Checks the naming and type cases in edge.wit. + +using System.Runtime.CompilerServices; +using Hyperlight.Tests.Edge; +using Hyperlight.Tests.Edge.Test.Edge.Types; +using Dep1 = Hyperlight.Tests.Edge.Test.Dep.V1_0_0; +using Dep2 = Hyperlight.Tests.Edge.Test.Dep.V2_0_0; + +static class EdgeTests +{ + public static void Run() + { + var guest = new Guest(); + var component = new EdgeComponent(guest); + + void Sent(string function, byte[] args, string what) => + Check.True(guest.Function == function && guest.Args.Length == 1 && guest.Args[0].SequenceEqual(args), what); + + // Fields named like their record or like object members get a suffix. + var r = new R { R_ = 1, ToString_ = 2 }; + Check.True(component.Ops.Keyword(@class: r) == r, "keyword round trip"); + Sent("test:edge/ops#keyword", [1, 2], "keyword encoding"); + Check.True(r.ToString().Contains("ToString_ = 2"), "record ToString"); + + foreach (var type in new[] { typeof(R), typeof(P), typeof(Lists) }) + { + Check.True(type.GetProperties().All(p => p.IsDefined(typeof(RequiredMemberAttribute), false)), $"{type.Name} required"); + } + + // Aliases resolve to the type they name. + IReadOnlyList

points = [new P { X = 1 }, new P { X = 2 }]; + Check.True(component.Ops.Alias(points).SequenceEqual(points), "alias round trip"); + Sent("test:edge/ops#alias", [2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0], "alias encoding"); + + // Lists inside records and variant cases compare by element. + Lists MakeLists(byte last) => new() { Id = 1, Names = ["a", "b"], Pair = (2, [3, last]) }; + var lists = MakeLists(4); + Check.True(lists == MakeLists(4) && lists.GetHashCode() == MakeLists(4).GetHashCode(), "lists equal"); + Check.True(lists != MakeLists(5), "tuple bytes differ"); + Check.True(lists != (lists with { Names = ["a"] }), "names differ"); + Check.True(component.Ops.Structural(lists) == lists, "lists round trip"); + V items = new V.Items([1, 2]); + Check.True(items == new V.Items([1, 2]) && items.GetHashCode() == new V.Items([1, 2]).GetHashCode(), "items equal"); + Check.True(items != new V.Items([1]), "items differ"); + + // Cases named like Match, Value or a keyword get escaped names and parameters. + string Describe(V v) => v.Match( + none: () => "none", + match: m => $"match {m}", + @class: c => $"class {c}", + items: i => $"items {i.Count}", + value: x => $"value {x}"); + V[] cases = [new V.None(), new V.Match_(7), new V.Class("c"), new V.Value_(9), items]; + string[] expected = ["none", "match 7", "class c", "value 9", "items 2"]; + for (var i = 0; i < cases.Length; i++) + { + Check.True(Describe(cases[i]) == expected[i], $"match {expected[i]}"); + var switched = ""; + cases[i].Switch( + none: () => switched = "none", + match: m => switched = $"match {m}", + @class: c => switched = $"class {c}", + items: i => switched = $"items {i.Count}", + value: x => switched = $"value {x}"); + Check.True(switched == expected[i], $"switch {expected[i]}"); + Check.True(component.Ops.Cases(cases[i]) == cases[i], $"{expected[i]} round trip"); + } + Sent("test:edge/ops#cases", [3, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0], "items encoding"); + + Check.True(component.Ops.Single(new ValueTuple(0x1234)).Item1 == 0x1234, "single tuple round trip"); + Sent("test:edge/ops#single", [0x34, 0x12], "single tuple encoding"); + + component.Other.G(); + Check.True(guest.Function == "other#g" && guest.Args.Length == 0, "inline interface call"); + + // Exports named like members of the component class get escaped names. + component.RegisterImports_.R(); + Check.True(guest.Function == "register-imports#r", "export named register-imports"); + component.EdgeComponent_.C(); + Check.True(guest.Function == "edge-component#c", "export named like the component class"); + + // Each version of an imported package has its own namespace and wire name. + var registry = new Registry(); + EdgeComponent.RegisterImports(registry, new DepV1(), new DepV2()); + Check.True(registry.Call("test:dep/t@1.0.0#f", [5, 0, 0, 0]).SequenceEqual(new byte[] { 105, 0, 0, 0 }), "dep 1.0.0"); + Check.True(registry.Call("test:dep/t@2.0.0#f", [5, 0, 0, 0]).SequenceEqual(new byte[] { 205, 0, 0, 0 }), "dep 2.0.0"); + } + + sealed class DepV1 : Dep1.IT + { + public uint F(Dep1.T.R x) => x.X + 100; + } + + sealed class DepV2 : Dep2.IT + { + public uint F(Dep2.T.R x) => x.X + 200; + } +} diff --git a/src/tests/component_wire/csharp/Fakes.cs b/src/tests/component_wire/csharp/Fakes.cs new file mode 100644 index 000000000..c0e985bdb --- /dev/null +++ b/src/tests/component_wire/csharp/Fakes.cs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +using Hyperlight.Component; + +///

+/// Records each call and returns the arguments concatenated, which echoes +/// a single argument. replaces the next result. +/// +sealed class Guest : IGuestCaller +{ + public string Function = ""; + + public byte[][] Args = []; + + public byte[]? Reply; + + public byte[] Call(string function, byte[][] args) + { + Function = function; + Args = args; + var result = Reply ?? args.SelectMany(a => a).ToArray(); + Reply = null; + return result; + } +} + +sealed class Registry : IHostFunctionRegistry +{ + private readonly Dictionary Function)> _functions = []; + + public void Register(string function, int parameterCount, Func implementation) => + _functions.Add(function, (parameterCount, implementation)); + + public byte[] Call(string function, params byte[][] args) + { + var (count, implementation) = _functions[function]; + if (count != args.Length) + { + throw new ArgumentException($"{function} takes {count} arguments"); + } + return implementation(args); + } +} diff --git a/src/tests/component_wire/csharp/Program.cs b/src/tests/component_wire/csharp/Program.cs new file mode 100644 index 000000000..15d0d3fb7 --- /dev/null +++ b/src/tests/component_wire/csharp/Program.cs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Tests C# bindings generated from wire.wit and edge.wit, and the +// Hyperlight.Component runtime they use. + +WireTests.Run(); +EdgeTests.Run(); +RuntimeTests.Run(); +return Check.Report(); + +/// Counts failed checks so one run reports all of them. +static class Check +{ + private static int s_failures; + + public static void True(bool ok, string what) + { + if (!ok) + { + s_failures++; + Console.Error.WriteLine($"FAIL: {what}"); + } + } + + public static void Throws(Action action, string what) where T : Exception + { + try + { + action(); + True(false, $"{what} did not throw"); + } + catch (T) + { + } + } + + public static int Report() + { + Console.WriteLine(s_failures == 0 ? "OK" : $"{s_failures} failures"); + return s_failures == 0 ? 0 : 1; + } +} diff --git a/src/tests/component_wire/csharp/RuntimeTests.cs b/src/tests/component_wire/csharp/RuntimeTests.cs new file mode 100644 index 000000000..0af0d6a33 --- /dev/null +++ b/src/tests/component_wire/csharp/RuntimeTests.cs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +using Hyperlight.Component; + +static class RuntimeTests +{ + public static void Run() + { + Check.Throws(() => new WireReader([1, 0]).U32(), "truncated u32"); + Check.Throws(() => new WireReader([5, 0, 0, 0, (byte)'a']).String(), "truncated string"); + Check.Throws(() => new WireReader([2, 0, 0, 0, 0xc3, 0x28]).String(), "invalid UTF-8"); + Check.Throws(() => new WireReader([0x00, 0xd8, 0, 0]).Char(), "surrogate char"); + Check.Throws(() => new WireReader([0xff, 0xff, 0xff, 0x7f]).Length(), "oversized list"); + Check.Throws(() => + { + var r = new WireReader([1, 2]); + r.U8(); + r.End(); + }, "trailing bytes"); + + Check.True(Option.Some([1]) == Option.Some([1]), "option compares lists"); + IReadOnlyList array = new byte[] { 1, 2 }; + IReadOnlyList list = new List { 1, 2 }; + Check.True( + Structural.Equal(array, list) && Structural.Hash(array) == Structural.Hash(list), + "equal byte collections hash alike"); + Check.True(Option.Some([1]) != Option.Some([2]), "option lists differ"); + Check.True(Option.Some(null) != Option.None, "some null differs from none"); + Check.True(Result.FromOk([1]) == Result.FromOk([1]), "result compares lists"); + Check.True(Result.FromOk("x") != Result.FromErr("x"), "ok differs from err"); + + var ok = Result.FromOk(7); + var err = Result.FromErr("e"); + Check.True(ok.Match(ok: v => v.ToString(), err: e => e) == "7", "result match ok"); + Check.True(err.Match(ok: v => v.ToString(), err: e => e) == "e", "result match err"); + var switched = ""; + err.Switch(ok: v => switched = "ok", err: e => switched = e); + Check.True(switched == "e", "result switch err"); + } +} diff --git a/src/tests/component_wire/csharp/WireTests.cs b/src/tests/component_wire/csharp/WireTests.cs new file mode 100644 index 000000000..fda501f0a --- /dev/null +++ b/src/tests/component_wire/csharp/WireTests.cs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Checks the C# bindings generated from wire.wit against the byte vectors +// that src/hyperlight_host/tests/component_wire_test.rs checks for the +// Rust bindings. + +using System.Text; +using Hyperlight.Component; +using Hyperlight.Tests.Wire; +using Hyperlight.Tests.Wire.Test.Wire; +using Hyperlight.Tests.Wire.Test.Wire.Types; + +sealed class WireTests +{ + private readonly Dictionary _vectors = File + .ReadLines(Path.Combine(AppContext.BaseDirectory, "vectors.txt")) + .Where(line => line.Length > 0 && !line.StartsWith('#')) + .Select(line => line.Split(' ')) + .ToDictionary(parts => parts[0], parts => Convert.FromHexString(parts[1])); + + private readonly HashSet _used = []; + private readonly Guest _guest = new(); + private readonly WireComponent _component; + + private WireTests() => _component = new WireComponent(_guest); + + public static void Run() + { + var t = new WireTests(); + t.Exports(); + t.Imports(); + t.InvalidValues(); + foreach (var name in t._vectors.Keys.Except(t._used)) + { + Check.True(false, $"vector {name} not checked"); + } + } + + private static readonly Everything Full = new() + { + B = true, + U8v = 0xab, + S8v = -2, + U16v = 0xbeef, + S16v = -300, + U32v = 0xdead_beef, + S32v = -70_000, + U64v = 0x0123_4567_89ab_cdef, + S64v = -5_000_000_000, + F32v = 1.5f, + F64v = -2.25, + C = new Rune(0x1F388), + S = "héllo", + Bytes = [1, 2, 3], + Points = [new Point { X = 1, Y = 2 }, new Point { X = -3, Y = 4 }], + Nested = [["a", "bc"], []], + FixedBytes = [7, 8, 9], + FixedPoints = [new Point { X = 5, Y = 6 }, new Point { X = 7, Y = 8 }], + Maybe = "m", + MaybeNum = 9, + MaybeMaybe = Option.Some("mm"), + Res = Result.FromOk(new Point { X = 10, Y = 11 }), + ResUnit = Result.FromOk(default), + ResEmpty = Result.FromOk(default), + Tup = ("t", 12, new Point { X = 13, Y = 14 }), + Shape = new Shape.Rect(new Point { X = 15, Y = 16 }), + Color = Color.Green, + Perms = Permissions.Read | Permissions.Exec, + Many = ManyFlags.F0 | ManyFlags.F9, + Tup8 = (1, 2, 3, 4, 5, 6, 7, 0x0809), + MaybeMaybeNum = Option.Some(17), + Full = FullFlags.H0 | FullFlags.H31, + }; + + private static readonly Everything Alt = new() + { + B = false, + U8v = byte.MaxValue, + S8v = sbyte.MinValue, + U16v = ushort.MaxValue, + S16v = short.MinValue, + U32v = uint.MaxValue, + S32v = int.MinValue, + U64v = ulong.MaxValue, + S64v = long.MinValue, + F32v = -0.0f, + F64v = double.MaxValue, + C = new Rune('a'), + S = "", + Bytes = [], + Points = [], + Nested = [], + FixedBytes = [0, 0, 0], + FixedPoints = [new Point { X = 0, Y = 0 }, new Point { X = 0, Y = 0 }], + Maybe = null, + MaybeNum = null, + MaybeMaybe = Option.Some(null), + Res = Result.FromErr("bad"), + ResUnit = Result.FromErr("e"), + ResEmpty = Result.FromErr(default), + Tup = ("", 0, new Point { X = 0, Y = 0 }), + Shape = new Shape.Named("n"), + Color = Color.Blue, + Perms = Permissions.None, + Many = ManyFlags.F8, + Tup8 = (byte.MaxValue, 0, 0, 0, 0, 0, 0, ushort.MaxValue), + MaybeMaybeNum = Option.Some(null), + Full = FullFlags.None, + }; + + private static readonly Everything None = Alt with + { + MaybeMaybe = Option.None, + MaybeMaybeNum = Option.None, + Full = (FullFlags)uint.MaxValue, + Shape = new Shape.None(), + Color = Color.Red, + Perms = Permissions.Write, + }; + + private void Exports() + { + Echo("everything-full", _component.Echo.EchoEverything, Full); + Echo("everything-alt", _component.Echo.EchoEverything, Alt); + Echo("everything-none", _component.Echo.EchoEverything, None); + Echo("shape-none", _component.Echo.EchoShape, new Shape.None()); + Echo("shape-circle", _component.Echo.EchoShape, new Shape.Circle(3)); + Echo("shape-rect", _component.Echo.EchoShape, new Shape.Rect(new Point { X = 1, Y = -1 })); + Echo("shape-named", _component.Echo.EchoShape, new Shape.Named("x")); + Echo("shape-path", _component.Echo.EchoShape, + new Shape.Path([new Point { X = 1, Y = 2 }, new Point { X = 3, Y = 4 }])); + Echo("extra-ping", _component.Extra.Ping, 0xa5); + Echo("string", _component.Echo.EchoString, "héllo, 世界 🎈"); + Echo<(uint, string)>("two-args", x => _component.Echo.TwoArgs(x.Item1, x.Item2), (5, "five")); + + _guest.Reply = [42, 0, 0, 0]; + Check.True(_component.Echo.NoArgs() == 42, "no-args result"); + Check.True(_guest.Args.Length == 0, "no-args arguments"); + + _guest.Reply = []; + _component.Echo.NoResult(9); + CheckArgs("no-result"); + + _guest.Reply = [0, .. Vector("run")]; + Check.True(_component.Run("go") == Result.FromOk("go"), "run result"); + CheckArgs("run"); + } + + private void Imports() + { + var registry = new Registry(); + WireComponent.RegisterImports(registry, new HostImpl(), new WorldImpl()); + var sum = registry.Call("test:wire/host@0.1.0#add", Vector("host-add.0"), Vector("host-add.1")); + Check.True(sum.AsSpan().SequenceEqual(Vector("host-add.result")), "host-add result"); + var log = registry.Call("test:wire/host@0.1.0#log", [2, 0, 0, 0, (byte)'h', (byte)'i'], [2]); + Check.True(log.Length == 0, "log result"); + Check.True(registry.Call("now").AsSpan().SequenceEqual(Vector("now.result")), "now result"); + } + + private void InvalidValues() + { + _guest.Reply = [4, 0, 0, 0]; + Check.Throws(() => _component.Echo.EchoShape(new Shape.None()), "invalid variant tag"); + _guest.Reply = [2]; + Check.Throws(() => _component.Run(""), "invalid result tag"); + + void Rejects(Everything value, string what) where T : Exception => + Check.Throws(() => _component.Echo.EchoEverything(value), what); + Rejects(Full with { FixedBytes = [1] }, "fixed bytes length"); + Rejects(Full with { FixedPoints = [] }, "fixed list length"); + Rejects(Full with { Color = (Color)3 }, "enum range"); + Rejects(Full with { Perms = (Permissions)8 }, "flags range"); + Rejects(Full with { Points = null! }, "null list"); + } + + private byte[] Vector(string name) + { + _used.Add(name); + return _vectors[name]; + } + + // A single argument uses the vector `name`. Several use `name.0`, `name.1` and so on. + private void CheckArgs(string name) + { + var args = _guest.Args; + if (args.Length == 1) + { + Check.True(args[0].AsSpan().SequenceEqual(Vector(name)), $"{name} encoding"); + return; + } + for (var i = 0; i < args.Length; i++) + { + Check.True(args[i].AsSpan().SequenceEqual(Vector($"{name}.{i}")), $"{name}.{i} encoding"); + } + } + + // The guest returns the encoded arguments, so the result decodes to the value passed in. + private void Echo(string name, Func call, T value) + { + var result = call(value); + CheckArgs(name); + Check.True(EqualityComparer.Default.Equals(value, result), $"{name} decoding"); + } + + private sealed class HostImpl : IHost + { + public void Log(string message, byte @event) + { + if (message != "hi" || @event != 2) + { + throw new InvalidOperationException("log decoded wrong arguments"); + } + } + + public Point Add(Point left, Point right) => new() { X = left.X + right.X, Y = left.Y + right.Y }; + } + + private sealed class WorldImpl : IWireImports + { + public ulong Now() => 7; + } +} diff --git a/src/tests/component_wire/csharp/edge.wit b/src/tests/component_wire/csharp/edge.wit new file mode 100644 index 000000000..8e8bfa8aa --- /dev/null +++ b/src/tests/component_wire/csharp/edge.wit @@ -0,0 +1,49 @@ +// C# naming and type cases checked by EdgeTests.cs. +package test:edge; + +interface types { + record r { r: u8, to-string: u8 } + record p { x: u32 } + type q = p; + type l = list; + record lists { id: u32, names: list, pair: tuple> } + variant v { none, match(u32), class(string), items(list), value(u8) } +} + +interface ops { + use types.{r, l, lists, v}; + keyword: func(class: r) -> r; + alias: func(x: l) -> l; + structural: func(x: lists) -> lists; + cases: func(x: v) -> v; + single: func(x: tuple) -> tuple; +} + +world edge { + import test:dep/t@1.0.0; + import test:dep/t@2.0.0; + export ops; + export other: interface { + g: func(); + } + export register-imports: interface { + r: func(); + } + export edge-component: interface { + c: func(); + } +} + +package test:dep@1.0.0 { + interface t { + record r { x: u32 } + f: func(x: r) -> u32; + } +} + +package test:dep@2.0.0 { + interface t { + record r { x: u32 } + f: func(x: r) -> u32; + } +} diff --git a/src/tests/component_wire/vectors.txt b/src/tests/component_wire/vectors.txt new file mode 100644 index 000000000..720492639 --- /dev/null +++ b/src/tests/component_wire/vectors.txt @@ -0,0 +1,21 @@ +# Encodings checked by src/hyperlight_host/tests/component_wire_test.rs and +# src/tests/component_wire/csharp. Regenerate with +# HYPERLIGHT_BLESS=1 cargo test -p hyperlight-host --test component_wire_test +everything-alt 00ff80ffff0080ffffffff00000080ffffffffffffffff000000000000008000000080ffffffffffffef7f6100000000000000000000000000000000000000000000000000000000000000000000000000000000010001030000006261640101000000650100000000000000000000000000ff000000000000ffff010003000000010000006e0200000000000100000000 +everything-full 01abfeefbed4feefbeadde90eefeffefcdab8967452301000efad5feffffff0000c03f00000000000002c088f301000600000068c3a96c6c6f03000000010203020000000100000002000000fdffffff0400000002000000020000000100000061020000006263000000000708090500000006000000070000000800000001010000006d01090000000101020000006d6d000a0000000b000000000001000000740c0d0000000e000000010203040506070908010111000000020000000f000000100000000100000005010201000080 +everything-none 00ff80ffff0080ffffffff00000080ffffffffffffffff000000000000008000000080ffffffffffffef7f61000000000000000000000000000000000000000000000000000000000000000000000000000000000001030000006261640101000000650100000000000000000000000000ff000000000000ffff000000000000000000020001ffffffff +extra-ping a5 +host-add.0 03000000fcffffff +host-add.1 0a00000014000000 +host-add.result 0d00000010000000 +no-result 09000000 +now.result 0700000000000000 +run 02000000676f +shape-circle 0100000003000000 +shape-named 030000000100000078 +shape-none 00000000 +shape-path 040000000200000001000000020000000300000004000000 +shape-rect 0200000001000000ffffffff +string 1300000068c3a96c6c6f2c20e4b896e7958c20f09f8e88 +two-args.0 05000000 +two-args.1 0400000066697665 diff --git a/src/tests/component_wire/wire.wit b/src/tests/component_wire/wire.wit new file mode 100644 index 000000000..3fd96f648 --- /dev/null +++ b/src/tests/component_wire/wire.wit @@ -0,0 +1,103 @@ +package test:wire@0.1.0; + +/// Exercises every value type supported by the component wire format. +world wire { + import host; + export echo; + import now: func() -> u64; + export run: func(input: string) -> result; + export extra: interface { + ping: func(x: u8) -> u8; + } +} + +interface types { + record point { + x: s32, + y: s32, + } + + variant shape { + none, + circle(u32), + rect(point), + named(string), + path(list), + } + + enum color { + red, + green, + blue, + } + + flags permissions { + read, + write, + exec, + } + + flags many-flags { + f0, f1, f2, f3, f4, f5, f6, f7, f8, f9, + } + + flags full-flags { + h0, h1, h2, h3, h4, h5, h6, h7, + h8, h9, h10, h11, h12, h13, h14, h15, + h16, h17, h18, h19, h20, h21, h22, h23, + h24, h25, h26, h27, h28, h29, h30, h31, + } + + record everything { + b: bool, + u8v: u8, + s8v: s8, + u16v: u16, + s16v: s16, + u32v: u32, + s32v: s32, + u64v: u64, + s64v: s64, + f32v: f32, + f64v: f64, + c: char, + s: string, + bytes: list, + points: list, + nested: list>, + fixed-bytes: list, + fixed-points: list, + maybe: option, + maybe-num: option, + maybe-maybe: option>, + res: result, + res-unit: result<_, string>, + res-empty: result, + tup: tuple, + tup8: tuple, + maybe-maybe-num: option>, + shape: shape, + color: color, + perms: permissions, + many: many-flags, + full: full-flags, + } +} + +interface echo { + use types.{everything, shape}; + + echo-everything: func(x: everything) -> everything; + echo-shape: func(x: shape) -> shape; + echo-string: func(x: string) -> string; + two-args: func(first: u32, second: string) -> tuple; + no-args: func() -> u32; + no-result: func(x: u32); +} + +interface host { + use types.{point}; + + log: func(message: string, event: u8); + add: func(left: point, right: point) -> point; +}