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..7a11e5267 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/XXXX ### 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/XXXX ### Removed diff --git a/Cargo.lock b/Cargo.lock index b39a56b86..d2b9319fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,6 +1655,15 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "hyperlight-component-csharp" +version = "0.18.0" +dependencies = [ + "hyperlight-component-util", + "proc-macro2", + "wit-parser", +] + [[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..39819bdd8 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 @@ -285,8 +285,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 +337,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 +419,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 +429,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 +554,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..a3eb6913e --- /dev/null +++ b/src/hyperlight_component_csharp/Cargo.toml @@ -0,0 +1,22 @@ +[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] +wit-parser = { version = "0.259.0" } + +[dev-dependencies] +hyperlight-component-util = { workspace = true } +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..50a71d3a7 --- /dev/null +++ b/src/hyperlight_component_csharp/README.md @@ -0,0 +1,40 @@ +# 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, needed if the package has several + "MyApp.Guest", // root C# namespace +)?; +std::fs::write("Bindings.g.cs", cs)?; +``` + +`generate` takes a parsed `wit_parser::Resolve` and `WorldId`. + +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..d8afbadbc --- /dev/null +++ b/src/hyperlight_component_csharp/src/Runtime.cs @@ -0,0 +1,480 @@ +// 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 byte[] bytes: + { + var hash = new HashCode(); + hash.AddBytes(bytes); + return hash.ToHashCode(); + } + 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..30b2757d0 --- /dev/null +++ b/src/hyperlight_component_csharp/src/codec.rs @@ -0,0 +1,681 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# types and encoding for WIT value types. + +use wit_parser::{Type, TypeDef, TypeDefKind, TypeId}; + +use crate::names::{Src, camel, case, member, pascal}; +use crate::{Gen, RT}; + +impl Gen<'_> { + /// Follow type aliases to the defining type. + fn def(&self, ty: &Type) -> Option<(TypeId, &TypeDef)> { + let mut ty = *ty; + loop { + let Type::Id(id) = ty else { + return None; + }; + let def = &self.resolve.types[id]; + match &def.kind { + TypeDefKind::Type(inner) => ty = *inner, + _ => return Some((id, def)), + } + } + } + + fn is_option(&self, ty: &Type) -> bool { + matches!(self.def(ty), Some((_, d)) if matches!(d.kind, TypeDefKind::Option(_))) + } + + /// Whether a value of `ty` 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(&self, ty: &Type) -> bool { + let Some((_, def)) = self.def(ty) else { + return false; + }; + match &def.kind { + TypeDefKind::List(_) | TypeDefKind::FixedLengthList(..) => true, + TypeDefKind::Option(inner) => self.contains_list(inner), + TypeDefKind::Result(r) => { + r.ok.as_ref().is_some_and(|t| self.contains_list(t)) + || r.err.as_ref().is_some_and(|t| self.contains_list(t)) + } + TypeDefKind::Tuple(t) => t.types.iter().any(|t| self.contains_list(t)), + _ => false, + } + } + + /// Whether the C# type of `ty` is a value type, which decides how + /// `option` maps to a nullable type. + fn is_value_type(&self, ty: &Type) -> bool { + match self.def(ty) { + None => !matches!(ty, Type::String), + Some((_, def)) => matches!( + def.kind, + TypeDefKind::Enum(_) + | TypeDefKind::Flags(_) + | TypeDefKind::Tuple(_) + | TypeDefKind::Option(_) + | TypeDefKind::Result(_) + ), + } + } + + /// The C# type of `ty`. + pub(super) fn ty(&self, ty: &Type) -> String { + let Some((id, def)) = self.def(ty) else { + return match ty { + Type::Bool => "bool", + Type::U8 => "byte", + Type::S8 => "sbyte", + Type::U16 => "ushort", + Type::S16 => "short", + Type::U32 => "uint", + Type::S32 => "int", + Type::U64 => "ulong", + Type::S64 => "long", + Type::F32 => "float", + Type::F64 => "double", + Type::Char => "global::System.Text.Rune", + Type::String => "string", + Type::ErrorContext | Type::Id(_) => unreachable!("rejected while collecting"), + } + .to_string(); + }; + match &def.kind { + TypeDefKind::Record(_) + | TypeDefKind::Variant(_) + | TypeDefKind::Enum(_) + | TypeDefKind::Flags(_) => self.type_name(id), + TypeDefKind::Tuple(t) if t.types.len() == 1 => { + format!("global::System.ValueTuple<{}>", self.ty(&t.types[0])) + } + TypeDefKind::Tuple(t) => { + let types = t.types.iter().map(|t| self.ty(t)).collect::>(); + format!("({})", types.join(", ")) + } + TypeDefKind::Option(inner) if self.is_option(inner) => { + format!("{RT}.Option<{}>", self.ty(inner)) + } + TypeDefKind::Option(inner) => format!("{}?", self.ty(inner)), + TypeDefKind::Result(r) => format!( + "{RT}.Result<{}, {}>", + self.ty_or_unit(r.ok.as_ref()), + self.ty_or_unit(r.err.as_ref()) + ), + TypeDefKind::List(Type::U8) | TypeDefKind::FixedLengthList(Type::U8, _) => { + "byte[]".to_string() + } + TypeDefKind::List(inner) | TypeDefKind::FixedLengthList(inner, _) => format!( + "global::System.Collections.Generic.IReadOnlyList<{}>", + self.ty(inner) + ), + _ => unreachable!("rejected while collecting"), + } + } + + fn ty_or_unit(&self, ty: Option<&Type>) -> String { + ty.map_or_else(|| format!("{RT}.Unit"), |t| self.ty(t)) + } + + /// Emit statements that encode `value` of type `ty` into the + /// `WireWriter` named `w`. + pub(super) fn write(&self, src: &mut Src, ty: &Type, value: &str, w: &str) { + let Some((id, def)) = self.def(ty) else { + let method = match ty { + Type::Bool => "Bool", + Type::U8 => "U8", + Type::S8 => "S8", + Type::U16 => "U16", + Type::S16 => "S16", + Type::U32 => "U32", + Type::S32 => "S32", + Type::U64 => "U64", + Type::S64 => "S64", + Type::F32 => "F32", + Type::F64 => "F64", + Type::Char => "Char", + Type::String => "String", + Type::ErrorContext | Type::Id(_) => unreachable!("rejected while collecting"), + }; + src.line(format!("{w}.{method}({value});")); + return; + }; + match &def.kind { + TypeDefKind::Record(_) + | TypeDefKind::Variant(_) + | TypeDefKind::Enum(_) + | TypeDefKind::Flags(_) => { + src.line(format!( + "{}.Write_{}({w}, {value});", + self.codec, self.mangled[&id] + )); + } + TypeDefKind::Tuple(t) => { + let v = self.temp(); + src.line(format!("var {v} = {value};")); + for (i, ty) in t.types.iter().enumerate() { + self.write(src, ty, &tuple_item(&v, i), w); + } + } + TypeDefKind::Option(inner) => { + let v = self.temp(); + src.line(format!("var {v} = {value};")); + let (has_value, payload) = if self.is_option(inner) || self.is_value_type(inner) { + (format!("{v}.HasValue"), format!("{v}.Value")) + } else { + (format!("{v} is not null"), v.clone()) + }; + src.line(format!("if ({has_value})")); + src.open(); + src.line(format!("{w}.U8(1);")); + self.write(src, inner, &payload, w); + src.close(); + src.line("else"); + src.open(); + src.line(format!("{w}.U8(0);")); + src.close(); + } + TypeDefKind::Result(r) => { + let v = self.temp(); + src.line(format!("var {v} = {value};")); + src.line(format!("if ({v}.IsOk)")); + src.open(); + src.line(format!("{w}.U8(0);")); + if let Some(ty) = &r.ok { + self.write(src, ty, &format!("{v}.Ok"), w); + } + src.close(); + src.line("else"); + src.open(); + src.line(format!("{w}.U8(1);")); + if let Some(ty) = &r.err { + self.write(src, ty, &format!("{v}.Err"), w); + } + src.close(); + } + TypeDefKind::List(Type::U8) => src.line(format!("{w}.Bytes({value});")), + TypeDefKind::FixedLengthList(Type::U8, n) => { + src.line(format!("{w}.FixedBytes({value}, {n});")); + } + TypeDefKind::List(inner) | TypeDefKind::FixedLengthList(inner, _) => { + let v = self.temp(); + src.line(format!("var {v} = {value};")); + match &def.kind { + TypeDefKind::FixedLengthList(_, n) => { + src.line(format!("{RT}.WireWriter.CheckFixedLength({v}, {n});")); + } + _ => { + src.line(format!( + "global::System.ArgumentNullException.ThrowIfNull({v});" + )); + src.line(format!("{w}.Length({v}.Count);")); + } + } + let e = self.temp(); + src.line(format!("foreach (var {e} in {v})")); + src.open(); + self.write(src, inner, &e, w); + src.close(); + } + _ => unreachable!("rejected while collecting"), + } + } + + /// Emit statements that decode a value of type `ty` from the + /// `WireReader` named `r`, and return the local holding it. + pub(super) fn read(&self, src: &mut Src, ty: &Type, r: &str) -> String { + let v = self.temp(); + let Some((id, def)) = self.def(ty) else { + let method = match ty { + Type::Bool => "Bool", + Type::U8 => "U8", + Type::S8 => "S8", + Type::U16 => "U16", + Type::S16 => "S16", + Type::U32 => "U32", + Type::S32 => "S32", + Type::U64 => "U64", + Type::S64 => "S64", + Type::F32 => "F32", + Type::F64 => "F64", + Type::Char => "Char", + Type::String => "String", + Type::ErrorContext | Type::Id(_) => unreachable!("rejected while collecting"), + }; + src.line(format!("var {v} = {r}.{method}();")); + return v; + }; + match &def.kind { + TypeDefKind::Record(_) + | TypeDefKind::Variant(_) + | TypeDefKind::Enum(_) + | TypeDefKind::Flags(_) => { + src.line(format!( + "var {v} = {}.Read_{}(ref {r});", + self.codec, self.mangled[&id] + )); + } + TypeDefKind::Tuple(t) => { + let items = t + .types + .iter() + .map(|ty| self.read(src, ty, r)) + .collect::>(); + if items.len() == 1 { + src.line(format!("var {v} = new {}({});", self.ty(ty), items[0])); + } else { + src.line(format!("var {v} = ({});", items.join(", "))); + } + } + TypeDefKind::Option(inner) => { + let (none, some): (String, fn(&str, &str) -> String) = if self.is_option(inner) { + (format!("{}.None", self.ty(ty)), |ty, x| { + format!("{ty}.Some({x})") + }) + } else { + ("null".to_string(), |_, x| x.to_string()) + }; + src.line(format!("{} {v};", self.ty(ty))); + src.line(format!("if ({r}.Tag8(2) == 1)")); + src.open(); + let x = self.read(src, inner, r); + src.line(format!("{v} = {};", some(&self.ty(ty), &x))); + src.close(); + src.line("else"); + src.open(); + src.line(format!("{v} = {none};")); + src.close(); + } + TypeDefKind::Result(res) => { + let t = self.ty(ty); + src.line(format!("{t} {v};")); + src.line(format!("if ({r}.Tag8(2) == 0)")); + src.open(); + let x = self.read_or_unit(src, res.ok.as_ref(), r); + src.line(format!("{v} = {t}.FromOk({x});")); + src.close(); + src.line("else"); + src.open(); + let x = self.read_or_unit(src, res.err.as_ref(), r); + src.line(format!("{v} = {t}.FromErr({x});")); + src.close(); + } + TypeDefKind::List(Type::U8) => src.line(format!("var {v} = {r}.Bytes();")), + TypeDefKind::FixedLengthList(Type::U8, n) => { + src.line(format!("var {v} = {r}.FixedBytes({n});")); + } + TypeDefKind::List(inner) | TypeDefKind::FixedLengthList(inner, _) => { + let n = self.temp(); + match &def.kind { + TypeDefKind::FixedLengthList(_, len) => src.line(format!("var {n} = {len};")), + _ => src.line(format!("var {n} = {r}.Length();")), + } + src.line(format!( + "var {v} = new global::System.Collections.Generic.List<{}>({n});", + self.ty(inner) + )); + let i = self.temp(); + src.line(format!("for (var {i} = 0; {i} < {n}; {i}++)")); + src.open(); + let x = self.read(src, inner, r); + src.line(format!("{v}.Add({x});")); + src.close(); + } + _ => unreachable!("rejected while collecting"), + } + v + } + + fn read_or_unit(&self, src: &mut Src, ty: Option<&Type>, r: &str) -> String { + match ty { + Some(ty) => self.read(src, ty, r), + None => format!("default({RT}.Unit)"), + } + } + + /// Declaration of a record, variant, enum or flags type. + pub(super) fn emit_decl(&self, id: TypeId) -> Src { + let def = &self.resolve.types[id]; + let name = pascal(def.name.as_deref().unwrap()); + let mut src = Src::default(); + src.docs(&def.docs); + match &def.kind { + TypeDefKind::Record(r) => { + src.line(format!("public sealed record {name}")); + src.open(); + for (i, f) in r.fields.iter().enumerate() { + if i > 0 { + src.line(""); + } + src.docs(&f.docs); + src.line(format!( + "public required {} {} {{ get; init; }}", + self.ty(&f.ty), + member(&f.name, &name) + )); + } + let fields = r + .fields + .iter() + .map(|f| (member(&f.name, &name), &f.ty)) + .collect::>(); + self.emit_structural_equality(&mut src, &name, &fields); + src.close(); + } + TypeDefKind::Variant(v) => { + src.line(format!("public abstract record {name}")); + src.open(); + src.line(format!("private {name}() {{ }}")); + for c in &v.cases { + let case_name = case(&c.name, &name); + src.line(""); + src.docs(&c.docs); + match &c.ty { + Some(ty) if self.contains_list(ty) => { + src.line(format!( + "public sealed record {case_name}({} Value) : {name}", + self.ty(ty) + )); + src.open(); + self.emit_structural_equality( + &mut src, + &case_name, + &[("Value".to_string(), ty)], + ); + src.close(); + } + Some(ty) => src.line(format!( + "public sealed record {case_name}({} Value) : {name};", + self.ty(ty) + )), + None => src.line(format!("public sealed record {case_name} : {name};")), + } + } + self.emit_match(&mut src, &name, v); + src.close(); + } + TypeDefKind::Enum(e) => { + src.line(format!("public enum {name} : uint")); + src.open(); + for (i, c) in e.cases.iter().enumerate() { + src.docs(&c.docs); + src.line(format!("{} = {i},", pascal(&c.name))); + } + src.close(); + } + TypeDefKind::Flags(f) => { + src.line("[global::System.Flags]"); + src.line(format!("public enum {name} : uint")); + src.open(); + if f.flags.iter().all(|flag| pascal(&flag.name) != "None") { + src.line("None = 0,"); + } + for (i, flag) in f.flags.iter().enumerate() { + src.docs(&flag.docs); + src.line(format!("{} = 1u << {i},", pascal(&flag.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(&self, src: &mut Src, name: &str, fields: &[(String, &Type)]) { + if !fields.iter().any(|(_, ty)| self.contains_list(ty)) { + return; + } + let eq = |field: &str, ty: &Type| { + if self.contains_list(ty) { + format!("{RT}.Structural.Equal({field}, other.{field})") + } else { + format!( + "global::System.Collections.Generic.EqualityComparer<{}>.Default.Equals({field}, other.{field})", + self.ty(ty) + ) + } + }; + src.line(""); + src.line(format!("public bool Equals({name}? other) =>")); + src.indent_lines(|src| { + src.line("other is not null"); + for (i, (field, ty)) in fields.iter().enumerate() { + let end = if i + 1 == fields.len() { ";" } else { "" }; + src.line(format!("&& {}{end}", eq(field, ty))); + } + }); + src.line(""); + src.line("public override int GetHashCode()"); + src.open(); + src.line("var hash = new global::System.HashCode();"); + for (field, ty) in fields { + if self.contains_list(ty) { + 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(&self, src: &mut Src, name: &str, v: &wit_parser::Variant) { + let handler = |c: &wit_parser::Case, result: Option<&str>| { + let payload = c.ty.as_ref().map(|ty| self.ty(ty)); + 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)) + }; + let call = |c: &wit_parser::Case| { + let case_name = case(&c.name, name); + match c.ty { + Some(_) => ( + format!("{case_name} __c"), + format!("{}(__c.Value)", camel(&c.name)), + ), + None => (case_name, format!("{}()", camel(&c.name))), + } + }; + + src.line(""); + src.line("/// Calls the function for this case and returns its result."); + let params = v.cases.iter().map(|c| handler(c, Some("TResult"))); + src.line(format!( + "public TResult Match({}) => this switch", + params.collect::>().join(", ") + )); + src.open(); + for c in &v.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 = v.cases.iter().map(|c| handler(c, None)); + src.line(format!( + "public void Switch({})", + params.collect::>().join(", ") + )); + src.open(); + src.line("switch (this)"); + src.open(); + for c in &v.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(); + } + + /// The static class with encode and decode methods for every + /// declared type. + pub(super) fn emit_codec(&self) -> Src { + let name = self.codec.rsplit('.').next().unwrap(); + let mut src = Src::default(); + src.line(format!("internal static class {name}")); + src.open(); + for (i, id) in self.decls.iter().enumerate() { + if i > 0 { + src.line(""); + } + self.emit_type_codec(&mut src, *id); + } + src.close(); + src + } + + fn emit_type_codec(&self, src: &mut Src, id: TypeId) { + let def = &self.resolve.types[id]; + let ty = self.type_name(id); + let name = pascal(def.name.as_deref().unwrap()); + let mangled = &self.mangled[&id]; + + src.line(format!( + "public static void Write_{mangled}({RT}.WireWriter w, {ty} v)" + )); + src.open(); + match &def.kind { + TypeDefKind::Record(r) => { + src.line("global::System.ArgumentNullException.ThrowIfNull(v);"); + for f in &r.fields { + self.write(src, &f.ty, &format!("v.{}", member(&f.name, &name)), "w"); + } + } + TypeDefKind::Variant(v) => { + src.line("switch (v)"); + src.open(); + for (i, c) in v.cases.iter().enumerate() { + let case_ty = format!("{ty}.{}", case(&c.name, &name)); + match &c.ty { + Some(payload) => { + let x = self.temp(); + src.line(format!("case {case_ty} {x}:")); + src.indent_lines(|src| { + src.line(format!("w.U32({i});")); + self.write(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(); + } + TypeDefKind::Enum(e) => { + src.line(format!("if ((uint)v >= {})", e.cases.len())); + src.open(); + src.line("throw new global::System.ArgumentOutOfRangeException(nameof(v));"); + src.close(); + src.line("w.U32((uint)v);"); + } + TypeDefKind::Flags(f) => { + let mask = flags_mask(f.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, {});", f.flags.len().div_ceil(8))); + } + _ => unreachable!("only named types are declared"), + } + src.close(); + + src.line(""); + src.line(format!( + "public static {ty} Read_{mangled}(ref {RT}.WireReader r)" + )); + src.open(); + match &def.kind { + TypeDefKind::Record(rec) => { + let fields = rec + .fields + .iter() + .map(|f| (member(&f.name, &name), self.read(src, &f.ty, "r"))) + .collect::>(); + src.line(format!("return new {ty}")); + src.open(); + for (field, value) in fields { + src.line(format!("{field} = {value},")); + } + src.close_with("};"); + } + TypeDefKind::Variant(v) => { + src.line(format!("switch (r.Tag32({}))", v.cases.len())); + src.open(); + for (i, c) in v.cases.iter().enumerate() { + let case_ty = format!("{ty}.{}", case(&c.name, &name)); + src.line(format!("case {i}:")); + src.open(); + match &c.ty { + Some(payload) => { + let x = self.read(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(); + } + TypeDefKind::Enum(e) => { + src.line(format!("return ({ty})r.Tag32({});", e.cases.len())); + } + TypeDefKind::Flags(f) => { + let mask = flags_mask(f.flags.len()); + src.line(format!( + "return ({ty})(r.Flags({}) & {mask}u);", + f.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/lib.rs b/src/hyperlight_component_csharp/src/lib.rs new file mode 100644 index 000000000..97c0e7dd5 --- /dev/null +++ b/src/hyperlight_component_csharp/src/lib.rs @@ -0,0 +1,703 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! C# bindings for the host side of a WIT world. +//! +//! 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 names; + +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::path::Path; + +use names::{Src, camel, member, pascal, string_literal}; +use wit_parser::{ + Function, FunctionKind, InterfaceId, Resolve, Type, TypeDefKind, TypeId, TypeOwner, WorldId, + WorldItem, WorldKey, +}; + +/// 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 package at `path`. +/// +/// `path` is a `.wit` file or a directory, parsed with +/// [`Resolve::push_path`]. `world` selects the world as +/// [`Resolve::select_world`] does. All generated types are placed +/// under `namespace`. +pub fn generate_from_path( + path: &Path, + world: Option<&str>, + namespace: &str, +) -> Result { + let mut resolve = Resolve::default(); + let (package, _) = resolve + .push_path(path) + .map_err(|e| error(format!("failed to parse '{}': {e:#}", path.display())))?; + let world = resolve + .select_world(&[package], world) + .map_err(|e| error(format!("{e:#}")))?; + generate(&resolve, world, namespace) +} + +/// Generate C# bindings for `world`, placing all generated types +/// under `namespace`. +pub fn generate(resolve: &Resolve, world: WorldId, namespace: &str) -> Result { + if namespace.is_empty() + || namespace + .split('.') + .any(|part| part.is_empty() || !part.chars().all(|c| c.is_alphanumeric() || c == '_')) + { + return Err(error(format!("invalid C# namespace '{namespace}'"))); + } + let mut g = Gen { + resolve, + world, + root: namespace.to_string(), + codec: String::new(), + interface_names: HashMap::new(), + decls: Vec::new(), + seen: HashSet::new(), + mangled: HashMap::new(), + imports: Vec::new(), + exports: Vec::new(), + temps: Cell::new(0), + }; + g.codec = format!( + "global::{}.{}Codec", + g.root, + pascal(&resolve.worlds[world].name) + ); + g.collect()?; + Ok(g.emit()) +} + +/// Functions of one imported or exported world item. +struct Group<'a> { + /// The interface, or `None` for functions of the world itself + interface: Option, + /// Instance name in wire function names + instance: Option, + functions: Vec<&'a Function>, +} + +struct Gen<'a> { + resolve: &'a Resolve, + world: WorldId, + /// Root C# namespace + root: String, + /// Fully qualified name of the static class holding codec methods + codec: String, + /// Names of interfaces, including anonymous ones named by their + /// world key + interface_names: HashMap, + /// Records, variants, enums and flags to declare, in WIT order + decls: Vec, + seen: HashSet, + /// Codec method suffix for each declared type + mangled: HashMap, + imports: Vec>, + exports: Vec>, + /// Counter for unique local variable names + temps: Cell, +} + +impl<'a> Gen<'a> { + fn collect(&mut self) -> Result<(), Error> { + let world = &self.resolve.worlds[self.world]; + for (key, item) in world.imports.iter().chain(world.exports.iter()) { + if let (WorldKey::Name(name), WorldItem::Interface { id, .. }) = (key, item) { + self.interface_names.insert(*id, name.clone()); + } + } + for (key, item) in &world.imports { + if let Some(group) = self.collect_item(key, item)? { + self.imports.push(group); + } + } + for (key, item) in &world.exports { + if let Some(group) = self.collect_item(key, item)? { + self.exports.push(group); + } + } + let world_functions = |groups: &mut Vec>| { + let functions = groups + .iter() + .filter(|g| g.interface.is_none()) + .flat_map(|g| g.functions.clone()) + .collect::>(); + groups.retain(|g| g.interface.is_some()); + if !functions.is_empty() { + groups.push(Group { + interface: None, + instance: None, + functions, + }); + } + }; + world_functions(&mut self.imports); + world_functions(&mut self.exports); + + // World types share the root namespace with the generated classes. + let world_name = pascal(&world.name); + let generated = [ + format!("{world_name}Component"), + format!("I{world_name}Imports"), + format!("{world_name}Codec"), + ]; + for id in &self.decls { + let def = &self.resolve.types[*id]; + let name = pascal(def.name.as_deref().unwrap()); + if matches!(def.owner, TypeOwner::World(_)) && generated.contains(&name) { + return Err(error(format!( + "type '{}' clashes with the generated class {name}", + def.name.as_deref().unwrap() + ))); + } + } + Ok(()) + } + + fn collect_item( + &mut self, + key: &WorldKey, + item: &'a WorldItem, + ) -> Result>, Error> { + match item { + WorldItem::Interface { id, .. } => { + let iface = &self.resolve.interfaces[*id]; + for ty in iface.types.values() { + self.visit(&Type::Id(*ty))?; + } + let functions = iface.functions.values().collect::>(); + for f in &functions { + self.visit_function(f)?; + } + Ok((!functions.is_empty()).then(|| Group { + interface: Some(*id), + instance: Some(self.resolve.name_world_key(key)), + functions, + })) + } + WorldItem::Function(f) => { + self.visit_function(f)?; + Ok(Some(Group { + interface: None, + instance: None, + functions: vec![f], + })) + } + WorldItem::Type { id, .. } => { + self.visit(&Type::Id(*id))?; + Ok(None) + } + } + } + + fn visit_function(&mut self, f: &Function) -> Result<(), Error> { + if !matches!(f.kind, FunctionKind::Freestanding) { + return Err(error(format!( + "function '{}': resources and async functions are not supported", + f.name + ))); + } + for p in &f.params { + self.visit(&p.ty)?; + } + if let Some(ty) = &f.result { + self.visit(ty)?; + } + Ok(()) + } + + /// Check that `ty` is supported and record every type it uses + /// that needs a declaration. + fn visit(&mut self, ty: &Type) -> Result<(), Error> { + let id = match ty { + Type::Id(id) => *id, + Type::ErrorContext => return Err(error("error-context is not supported")), + _ => return Ok(()), + }; + if !self.seen.insert(id) { + return Ok(()); + } + let def = &self.resolve.types[id]; + let unsupported = |what: &str| { + let name = def.name.as_deref().unwrap_or("anonymous type"); + Err(error(format!("type '{name}': {what} are not supported"))) + }; + match &def.kind { + TypeDefKind::Record(r) => { + self.declare(id)?; + for f in &r.fields { + self.visit(&f.ty)?; + } + } + TypeDefKind::Variant(v) => { + self.declare(id)?; + for c in &v.cases { + if let Some(ty) = &c.ty { + self.visit(ty)?; + } + } + } + TypeDefKind::Enum(_) => self.declare(id)?, + TypeDefKind::Flags(f) => { + if f.flags.len() > 32 { + return unsupported("flags with more than 32 members"); + } + self.declare(id)?; + } + TypeDefKind::Tuple(t) => { + for ty in &t.types { + self.visit(ty)?; + } + } + TypeDefKind::Option(ty) + | TypeDefKind::List(ty) + | TypeDefKind::FixedLengthList(ty, _) + | TypeDefKind::Type(ty) => self.visit(ty)?, + TypeDefKind::Result(r) => { + if let Some(ty) = &r.ok { + self.visit(ty)?; + } + if let Some(ty) = &r.err { + self.visit(ty)?; + } + } + TypeDefKind::Resource | TypeDefKind::Handle(_) => return unsupported("resources"), + TypeDefKind::Future(_) | TypeDefKind::Stream(_) => { + return unsupported("futures and streams"); + } + TypeDefKind::Map(..) => return unsupported("maps"), + TypeDefKind::Unknown => return unsupported("unresolved types"), + } + Ok(()) + } + + fn declare(&mut self, id: TypeId) -> Result<(), Error> { + let def = &self.resolve.types[id]; + let name = def + .name + .as_deref() + .ok_or_else(|| error("anonymous record, variant, enum or flags"))?; + let namespace = match def.owner { + TypeOwner::Interface(i) => self.interface_namespace(i).replace('.', "_"), + TypeOwner::World(_) => String::new(), + TypeOwner::None => return Err(error(format!("type '{name}' has no owner"))), + }; + let base = if namespace.is_empty() { + pascal(name) + } else { + format!("{namespace}_{}", pascal(name)) + }; + let mut mangled = base.clone(); + let mut n = 1; + while self.mangled.values().any(|m| *m == mangled) { + n += 1; + mangled = format!("{base}{n}"); + } + self.mangled.insert(id, mangled); + self.decls.push(id); + Ok(()) + } + + /// Namespace of the types of an interface, relative to the root + /// namespace, e.g. `Wasi.Http.Types`. + fn interface_namespace(&self, id: InterfaceId) -> String { + let name = pascal(&self.interface_name(id)); + match self.package_namespace(id) { + Some(pkg) => format!("{pkg}.{name}"), + None => name, + } + } + + /// Namespace of an interface's package relative to the root + /// namespace, e.g. `Wasi.Http`. The version is added, e.g. + /// `Wasi.Http.V0_2_0`, when the WIT has the package at several versions. + fn package_namespace(&self, id: InterfaceId) -> Option { + let pkg = self.resolve.interfaces[id].package?; + let name = &self.resolve.packages[pkg].name; + let mut ns = format!("{}.{}", pascal(&name.namespace), pascal(&name.name)); + let versions = self + .resolve + .packages + .iter() + .filter(|(_, p)| p.name.namespace == name.namespace && p.name.name == name.name) + .count(); + if let Some(version) = name.version.as_ref().filter(|_| versions > 1) { + let version = version + .to_string() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + ns.push_str(&format!(".V{version}")); + } + Some(ns) + } + + fn interface_name(&self, id: InterfaceId) -> String { + self.resolve.interfaces[id] + .name + .clone() + .or_else(|| self.interface_names.get(&id).cloned()) + .unwrap_or_else(|| format!("interface{}", id.index())) + } + + fn qualify(&self, relative: Option) -> String { + match relative { + Some(ns) => format!("{}.{ns}", self.root), + None => self.root.clone(), + } + } + + /// Fully qualified C# name of a declared type. + fn type_name(&self, id: TypeId) -> String { + let def = &self.resolve.types[id]; + let ns = match def.owner { + TypeOwner::Interface(i) => self.qualify(Some(self.interface_namespace(i))), + _ => self.root.clone(), + }; + format!("global::{ns}.{}", pascal(def.name.as_deref().unwrap())) + } + + fn temp(&self) -> String { + let n = self.temps.get(); + self.temps.set(n + 1); + format!("__t{n}") + } + + fn emit(&self) -> String { + let mut blocks: Vec<(String, Src)> = Vec::new(); + let mut block = |ns: String, src: Src| match blocks.iter_mut().find(|(n, _)| *n == ns) { + Some((_, existing)) => { + existing.line(""); + existing.append(src); + } + None => blocks.push((ns, src)), + }; + + for id in &self.decls { + let def = &self.resolve.types[*id]; + let ns = match def.owner { + TypeOwner::Interface(i) => self.qualify(Some(self.interface_namespace(i))), + _ => self.root.clone(), + }; + block(ns, self.emit_decl(*id)); + } + for group in &self.imports { + if let Some(id) = group.interface { + block( + self.qualify(self.package_namespace(id)), + self.emit_import_interface(&self.import_interface_name(group), group), + ); + } + } + for group in &self.exports { + if let Some(id) = group.interface { + block( + self.qualify(self.package_namespace(id)), + self.emit_export_class(&self.export_class_name(group), group), + ); + } + } + block(self.root.clone(), self.emit_component()); + block(self.root.clone(), self.emit_codec()); + + let world = &self.resolve.worlds[self.world]; + let world_id = match world.package { + Some(pkg) => format!("{}/{}", self.resolve.packages[pkg].name, world.name), + None => world.name.clone(), + }; + let mut out = Src::default(); + out.line("// "); + out.line(format!( + "// Generated by hyperlight-component-csharp from WIT world {world_id}." + )); + out.line("// "); + out.line(""); + out.line("#nullable enable"); + for (ns, src) in blocks { + out.line(""); + out.line(format!("namespace {ns}")); + out.open(); + out.append(src); + out.close(); + } + out.into_string() + } + + fn import_interface_name(&self, group: &Group) -> String { + match group.interface { + Some(id) => format!("I{}", pascal(&self.interface_name(id))), + None => format!("I{}Imports", pascal(&self.resolve.worlds[self.world].name)), + } + } + + fn export_class_name(&self, group: &Group) -> String { + format!( + "{}Exports", + pascal(&self.interface_name(group.interface.unwrap())) + ) + } + + fn qualified_group_type(&self, group: &Group, name: String) -> String { + let ns = match group.interface { + Some(id) => self.qualify(self.package_namespace(id)), + None => self.root.clone(), + }; + format!("global::{ns}.{name}") + } + + /// Member names for interfaces, qualified with their package when + /// two interfaces share a name. + fn group_member_names(&self, groups: &[Group]) -> Vec { + let bases = groups + .iter() + .map(|g| match g.interface { + Some(id) => self.interface_name(id), + None => "world".to_string(), + }) + .collect::>(); + groups + .iter() + .zip(&bases) + .map(|(g, base)| { + let collides = bases.iter().filter(|b| *b == base).count() > 1; + match (g.interface, collides) { + (Some(id), true) => format!( + "{}{}", + self.package_namespace(id) + .unwrap_or_default() + .replace('.', ""), + pascal(base) + ), + _ => pascal(base), + } + }) + .collect() + } + + fn signature(&self, f: &Function, name: &str) -> String { + let params = f + .params + .iter() + .map(|p| format!("{} {}", self.ty(&p.ty), camel(&p.name))) + .collect::>() + .join(", "); + let result = f.result.as_ref().map_or("void".into(), |t| self.ty(t)); + format!("{result} {name}({params})") + } + + fn wire_name(group: &Group, f: &Function) -> String { + match &group.instance { + Some(instance) => format!("{instance}#{}", f.name), + None => f.name.clone(), + } + } + + fn emit_import_interface(&self, name: &str, group: &Group) -> Src { + let mut src = Src::default(); + if let Some(id) = group.interface { + src.docs(&self.resolve.interfaces[id].docs); + } + src.line(format!("public interface {name}")); + src.open(); + for (i, f) in group.functions.iter().enumerate() { + if i > 0 { + src.line(""); + } + src.docs(&f.docs); + src.line(format!("{};", self.signature(f, &pascal(&f.name)))); + } + src.close(); + src + } + + fn emit_export_class(&self, name: &str, group: &Group) -> Src { + let mut src = Src::default(); + if let Some(id) = group.interface { + src.docs(&self.resolve.interfaces[id].docs); + } + 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(); + self.emit_export_methods(&mut src, name, group); + src.close(); + src + } + + fn emit_export_methods(&self, src: &mut Src, class: &str, group: &Group) { + for f in &group.functions { + src.line(""); + src.docs(&f.docs); + src.line(format!( + "public {}", + self.signature(f, &member(&f.name, class)) + )); + 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();")); + self.write(src, &p.ty, &camel(&p.name), "__w"); + src.line(format!("__args[{i}] = __w.ToArray();")); + src.close(); + } + let call = format!( + "_caller.Call({}, __args)", + string_literal(&Self::wire_name(group, f)) + ); + match &f.result { + Some(ty) => { + src.line(format!("var __r = new {RT}.WireReader({call});")); + let value = self.read(src, ty, "__r"); + src.line("__r.End();"); + src.line(format!("return {value};")); + } + None => src.line(format!("{call};")), + } + src.close(); + } + } + + fn emit_component(&self) -> Src { + let world = &self.resolve.worlds[self.world]; + let name = format!("{}Component", pascal(&world.name)); + let mut src = Src::default(); + + if let Some(group) = self.imports.iter().find(|g| g.interface.is_none()) { + src.append(self.emit_import_interface(&self.import_interface_name(group), group)); + src.line(""); + } + + src.docs(&world.docs); + 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 = self.group_member_names(&self.exports); + for (group, prop) in self.exports.iter().zip(&export_names) { + if group.interface.is_some() { + let class = self.qualified_group_type(group, self.export_class_name(group)); + src.line(format!("{prop} = new {class}(caller);")); + } + } + src.close(); + for (group, prop) in self.exports.iter().zip(&export_names) { + if let Some(id) = group.interface { + let class = self.qualified_group_type(group, self.export_class_name(group)); + src.line(""); + src.docs(&self.resolve.interfaces[id].docs); + src.line(format!("public {class} {prop} {{ get; }}")); + } + } + if let Some(group) = self.exports.iter().find(|g| g.interface.is_none()) { + self.emit_export_methods(&mut src, &name, group); + } + + let import_names = self.group_member_names(&self.imports); + let params = self + .imports + .iter() + .zip(&import_names) + .map(|(group, base)| { + let ty = self.qualified_group_type(group, self.import_interface_name(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 f in &group.functions { + self.emit_registration(&mut src, param, group, f); + } + } + src.close(); + src.close(); + src + } + + fn emit_registration(&self, src: &mut Src, target: &str, group: &Group, f: &Function) { + src.line(format!( + "registry.Register({}, {}, __args =>", + string_literal(&Self::wire_name(group, f)), + f.params.len() + )); + 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 = self.read(src, &p.ty, &reader); + src.line(format!("{reader}.End();")); + args.push(value); + } + let call = format!("{target}.{}({})", pascal(&f.name), args.join(", ")); + match &f.result { + Some(ty) => { + src.line(format!("var __ret = {call};")); + src.line(format!("var __w = new {RT}.WireWriter();")); + self.write(src, ty, "__ret", "__w"); + src.line("return __w.ToArray();"); + } + None => { + src.line(format!("{call};")); + src.line("return global::System.Array.Empty();"); + } + } + src.close_with("});"); + } +} + +#[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..aa8d178ef --- /dev/null +++ b/src/hyperlight_component_csharp/src/names.rs @@ -0,0 +1,252 @@ +// 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. +pub(super) fn case(kebab: &str, enclosing: &str) -> String { + let name = member(kebab, enclosing); + if name == "Match" || name == "Switch" { + format!("{name}_") + } else { + name + } +} + +/// 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; + } + + /// Emit WIT documentation as an XML doc comment. + pub(super) fn docs(&mut self, docs: &wit_parser::Docs) { + let Some(contents) = &docs.contents else { + return; + }; + self.line("/// "); + for line in contents.lines() { + let line = line + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + self.line(format!("/// {line}").trim_end()); + } + self.line("/// "); + } + + /// 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!(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..031fc8f90 --- /dev/null +++ b/src/hyperlight_component_csharp/src/tests.rs @@ -0,0 +1,140 @@ +// 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 { + let mut resolve = Resolve::default(); + let package = resolve.push_str("test.wit", wit).unwrap(); + let world = resolve.select_world(&[package], None).unwrap(); + generate(&resolve, world, "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_unsupported_types() { + 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")); + + let stream = generate_str("package a:b; world w { export f: func(x: stream); }"); + assert!(stream.unwrap_err().to_string().contains("streams")); + + let async_fn = generate_str("package a:b; world w { export f: async func(); }"); + assert!(async_fn.unwrap_err().to_string().contains("async")); +} + +#[test] +fn rejects_invalid_namespace() { + let mut resolve = Resolve::default(); + let package = resolve + .push_str("test.wit", "package a:b; world w {}") + .unwrap(); + let world = resolve.select_world(&[package], None).unwrap(); + for namespace in ["", "A..B", "A.B-C", "A B"] { + assert!(generate(&resolve, world, namespace).is_err(), "{namespace}"); + } + assert!(generate(&resolve, world, "A.B_c").is_ok()); +} + +#[test] +fn documents_record_fields() { + let cs = generate_str( + "package a:b; interface i { record r { /// The x.\n x: u32 } f: func(x: r); } \ + world w { export i; }", + ) + .unwrap(); + assert!(cs.contains( + "/// \n /// The x.\n /// \n public required uint X" + )); +} + +#[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_util/src/emit.rs b/src/hyperlight_component_util/src/emit.rs index 80faf14db..e8f080a55 100644 --- a/src/hyperlight_component_util/src/emit.rs +++ b/src/hyperlight_component_util/src/emit.rs @@ -414,6 +414,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 @@ -467,6 +471,7 @@ 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> { @@ -489,6 +494,7 @@ 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(), } } /// Obtain a reference to the [`Mod`] that we are currently @@ -524,6 +530,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 @@ -813,6 +826,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 +859,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..a91c57e7d 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. +/// 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, kebab: &str) -> String { - s.mod_cursor - .iter() - .map(|x| x.to_string()) - .chain(std::iter::once(kebab.to_string())) - .join("::") + 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_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..52b14fe51 --- /dev/null +++ b/src/tests/component_wire/csharp/EdgeTests.cs @@ -0,0 +1,93 @@ +// 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 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}"); + V[] cases = [new V.None(), new V.Match_(7), new V.Class("c"), items]; + string[] expected = ["none", "match 7", "class c", "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}"); + 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"); + + // 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..411284d6f --- /dev/null +++ b/src/tests/component_wire/csharp/RuntimeTests.cs @@ -0,0 +1,36 @@ +// 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"); + 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..a4f8cb789 --- /dev/null +++ b/src/tests/component_wire/csharp/edge.wit @@ -0,0 +1,43 @@ +// 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) } +} + +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(); + } +} + +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; +}