From 40449815887fe18c23aa209d72e55b62724a0509 Mon Sep 17 00:00:00 2001 From: Phillip Cao Date: Sun, 4 Oct 2026 14:56:57 +1300 Subject: [PATCH] Add char, variant and flags component values Support validated Unicode scalars and named variants and flags in the component value API. Marshal caller-owned arguments and Wasmtime-owned callback results with the correct native layouts and allocation ownership. Add boundary, nested-value, owned-allocation and native clone tests. --- src/Components/ComponentValue.cs | 107 ++++++++++++++++++-- src/Components/ComponentValueMarshaller.cs | 61 ++++++++++++ tests/ComponentExtendedValueTests.cs | 110 +++++++++++++++++++++ tests/ComponentValueLayoutTests.cs | 24 +++++ tests/ComponentValueTests.cs | 4 +- 5 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 tests/ComponentExtendedValueTests.cs diff --git a/src/Components/ComponentValue.cs b/src/Components/ComponentValue.cs index ed4e1bb9..b1b09c05 100644 --- a/src/Components/ComponentValue.cs +++ b/src/Components/ComponentValue.cs @@ -33,7 +33,7 @@ public enum ComponentValueKind : byte F32 = 9, /// A 64-bit IEEE-754 float. F64 = 10, - /// A Unicode scalar value. Not yet supported by this binding. + /// A Unicode scalar value. Char = 11, /// A UTF-8 string. String = 12, @@ -43,7 +43,7 @@ public enum ComponentValueKind : byte Record = 14, /// A tuple of positional values. Tuple = 15, - /// A variant. Not yet supported by this binding. + /// A named variant case with an optional payload. Variant = 16, /// An enumeration, identified by case name. Enum = 17, @@ -51,7 +51,7 @@ public enum ComponentValueKind : byte Option = 18, /// A result value. Result = 19, - /// A set of flags. Not yet supported by this binding. + /// A set of flag names. Flags = 20, /// A resource handle. Not yet supported by this binding. Resource = 21, @@ -66,8 +66,7 @@ public enum ComponentValueKind : byte /// /// Create values with the static factory methods and read them back with the typed accessors, /// each of which throws if the value has a different . The -/// , , -/// , and +/// and /// kinds are not yet supported. /// public sealed class ComponentValue @@ -94,6 +93,8 @@ private ComponentValue(ComponentValueKind kind) /// Elements of a list or tuple. internal IReadOnlyList Items { get; private set; } = Array.Empty(); + internal IReadOnlyList Names { get; private set; } = Array.Empty(); + /// Fields of a record, in declaration order. internal IReadOnlyList> Fields { get; private set; } = Array.Empty>(); @@ -103,7 +104,8 @@ private ComponentValue(ComponentValueKind kind) /// /// Gets the payload of an or - /// , or null when there is none. + /// or , + /// or null when there is none. /// public ComponentValue? Payload { get; private set; } @@ -183,6 +185,72 @@ public static ComponentValue F32(float value) => public static ComponentValue F64(double value) => new ComponentValue(ComponentValueKind.F64) { Real = value }; + /// Creates a Unicode scalar value, not a UTF-16 code unit. + /// A value from 0 to 0x10FFFF, excluding surrogates. + /// The component value. + /// Thrown for an invalid scalar. + public static ComponentValue Char(uint value) + { + if (value > 0x10FFFF || (value >= 0xD800 && value <= 0xDFFF)) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Expected a Unicode scalar value."); + } + + return new ComponentValue(ComponentValueKind.Char) { Integer = value }; + } + + /// Creates a Unicode scalar value from a UTF-16 code unit. + /// A UTF-16 code unit that is not a surrogate. + /// The component value. + /// Thrown for a surrogate code unit. + public static ComponentValue Char(char value) => Char((uint)value); + +#if NETCOREAPP3_0_OR_GREATER + /// Creates a Unicode scalar value. + /// The value. + /// The component value. + public static ComponentValue Char(System.Text.Rune value) => + new ComponentValue(ComponentValueKind.Char) { Integer = value.Value }; +#endif + + /// Creates a named variant case. + /// The WIT case name. + /// The optional payload. + /// The component value. + public static ComponentValue Variant(string caseName, ComponentValue? value = null) + { + if (caseName is null) + { + throw new ArgumentNullException(nameof(caseName)); + } + + return new ComponentValue(ComponentValueKind.Variant) { Text = caseName, Payload = value }; + } + + /// Creates a set of flag names, copying the input. + /// The enabled WIT flag names, with no nulls or duplicates. + /// The component value. + /// Thrown for a null or duplicate name. + public static ComponentValue Flags(IReadOnlyList names) + { + if (names is null) + { + throw new ArgumentNullException(nameof(names)); + } + + var copy = Copy(names); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var name in copy) + { + if (name is null || !seen.Add(name)) + { + throw new ArgumentException("Flag names must not be null or duplicated.", nameof(names)); + } + } + + return new ComponentValue(ComponentValueKind.Flags) { Names = Array.AsReadOnly(copy) }; + } + /// Creates a string value. /// The value. /// The component value. @@ -355,6 +423,24 @@ public static ComponentValue Err(ComponentValue? value = null) => /// The value. public double AsF64() => Expect(ComponentValueKind.F64, Real); + /// Gets the Unicode scalar value. + /// The scalar, including supplementary-plane values. + public uint AsChar() => Expect(ComponentValueKind.Char, (uint)Integer); + +#if NETCOREAPP3_0_OR_GREATER + /// Gets the Unicode scalar value. + /// The scalar. + public System.Text.Rune AsRune() => new System.Text.Rune(AsChar()); +#endif + + /// Gets the WIT variant case name. + /// The case name. + public string AsVariant() => Expect(ComponentValueKind.Variant, Text!); + + /// Gets the enabled WIT flag names. + /// The read-only set of names in input order. + public IReadOnlyList AsFlags() => Expect(ComponentValueKind.Flags, Names); + /// Gets the value as a string. /// The value. public string AsString() => Expect(ComponentValueKind.String, Text ?? string.Empty); @@ -440,6 +526,12 @@ public override string ToString() return unchecked((ulong)Integer).ToString(CultureInfo.InvariantCulture); case ComponentValueKind.String: return $"\"{Text}\""; + case ComponentValueKind.Char: + return char.ConvertFromUtf32((int)Integer); + case ComponentValueKind.Variant: + return Payload is null ? Text! : $"{Text}({Payload})"; + case ComponentValueKind.Flags: + return $"{{{string.Join(", ", Names)}}}"; case ComponentValueKind.Enum: return Text ?? string.Empty; case ComponentValueKind.List: @@ -477,6 +569,9 @@ internal static class ComponentValueNative [DllImport(Engine.LibraryName)] public static extern void wasmtime_component_valtuple_new_uninit(IntPtr value, nuint size); + [DllImport(Engine.LibraryName)] + public static extern void wasmtime_component_valflags_new_uninit(IntPtr value, nuint size); + [DllImport(Engine.LibraryName)] public static extern IntPtr wasmtime_component_val_new(IntPtr value); diff --git a/src/Components/ComponentValueMarshaller.cs b/src/Components/ComponentValueMarshaller.cs index c132928b..183d3a32 100644 --- a/src/Components/ComponentValueMarshaller.cs +++ b/src/Components/ComponentValueMarshaller.cs @@ -39,6 +39,7 @@ internal static class ComponentValueMarshaller /// Offset of the data pointer within a vec or a wasm_name_t. private const int VectorDataOffset = 8; + private const int NameSize = 16; /// /// Tracks the native allocations made while writing arguments, so they can be released @@ -127,6 +128,22 @@ public static void Write(ComponentValue value, IntPtr destination, AllocationSco WriteRecord(value.Fields, payload, scope); break; + case ComponentValueKind.Variant: + WriteName(value.Text!, payload, scope); + Marshal.WriteIntPtr(payload + NameSize, + value.Payload is null ? IntPtr.Zero : WriteBoxed(value.Payload, scope)); + break; + + case ComponentValueKind.Flags: + var names = scope.Allocate(Math.Max(checked(value.Names.Count * NameSize), 1)); + for (var i = 0; i < value.Names.Count; i++) + { + WriteName(value.Names[i], names + i * NameSize, scope); + } + Marshal.WriteIntPtr(payload, (IntPtr)value.Names.Count); + Marshal.WriteIntPtr(payload + VectorDataOffset, names); + break; + case ComponentValueKind.Option: Marshal.WriteIntPtr(payload, value.Flag ? WriteBoxed(value.Payload!, scope) : IntPtr.Zero); break; @@ -194,6 +211,7 @@ private static bool WritePrimitive(ComponentValue value, IntPtr destination) case ComponentValueKind.S32: case ComponentValueKind.U32: + case ComponentValueKind.Char: Marshal.WriteInt32(payload, unchecked((int)value.Integer)); break; @@ -239,6 +257,31 @@ private static void WriteOwnedHeapValue(ComponentValue value, IntPtr destination WriteOwnedRecord(value.Fields, destination); break; + case ComponentValueKind.Variant: + Marshal.WriteByte(destination, (byte)value.Kind); + WriteOwnedName(value.Text!, payload); + if (value.Payload is not null) + { + Marshal.WriteIntPtr(payload + NameSize, WriteOwnedBoxed(value.Payload)); + } + break; + + case ComponentValueKind.Flags: + Marshal.WriteByte(destination, (byte)value.Kind); + var count = value.Names.Count; + _ = checked(count * NameSize); + ComponentValueNative.wasmtime_component_valflags_new_uninit(payload, (nuint)count); + var names = Marshal.ReadIntPtr(payload + VectorDataOffset); + unsafe + { + new Span((void*)names, count * NameSize).Clear(); + } + for (var i = 0; i < count; i++) + { + WriteOwnedName(value.Names[i], names + i * NameSize); + } + break; + case ComponentValueKind.Option: Marshal.WriteByte(destination, (byte)value.Kind); if (value.Flag) @@ -413,6 +456,9 @@ public static ComponentValue Read(IntPtr source) case ComponentValueKind.U32: return ComponentValue.U32(unchecked((uint)Marshal.ReadInt32(payload))); + case ComponentValueKind.Char: + return ComponentValue.Char(unchecked((uint)Marshal.ReadInt32(payload))); + case ComponentValueKind.S64: return ComponentValue.S64(Marshal.ReadInt64(payload)); @@ -440,6 +486,21 @@ public static ComponentValue Read(IntPtr source) case ComponentValueKind.Record: return ComponentValue.OwnedRecord(ReadRecord(payload)); + case ComponentValueKind.Variant: + var variant = Marshal.ReadIntPtr(payload + NameSize); + return ComponentValue.Variant(ReadName(payload), + variant == IntPtr.Zero ? null : Read(variant)); + + case ComponentValueKind.Flags: + var count = checked((int)Marshal.ReadIntPtr(payload).ToInt64()); + var names = Marshal.ReadIntPtr(payload + VectorDataOffset); + var flags = new string[count]; + for (var i = 0; i < count; i++) + { + flags[i] = ReadName(names + checked(i * NameSize)); + } + return ComponentValue.Flags(flags); + case ComponentValueKind.Option: var some = Marshal.ReadIntPtr(payload); return some == IntPtr.Zero ? ComponentValue.None() : ComponentValue.Some(Read(some)); diff --git a/tests/ComponentExtendedValueTests.cs b/tests/ComponentExtendedValueTests.cs new file mode 100644 index 00000000..17db7ca6 --- /dev/null +++ b/tests/ComponentExtendedValueTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using FluentAssertions; +using Wasmtime.Components; +using Xunit; + +namespace Wasmtime.Tests; + +public sealed class ComponentExtendedValueTests +{ + private static ComponentValue RoundTrip(ComponentValue value, bool owned) + { + using var scope = new ComponentValueMarshaller.AllocationScope(); + var pointer = scope.Allocate(ComponentValueMarshaller.ValueSize); + if (owned) ComponentValueMarshaller.WriteOwned(value, pointer); + else ComponentValueMarshaller.Write(value, pointer, scope); + try { return ComponentValueMarshaller.Read(pointer); } + finally { if (owned) ComponentValueNative.wasmtime_component_val_delete(pointer); } + } + + [Theory] + [InlineData(0u)] + [InlineData(0xD7FFu)] + [InlineData(0xE000u)] + [InlineData(0xFFFFu)] + [InlineData(0x10000u)] + [InlineData(0x1F600u)] + [InlineData(0x10FFFFu)] + public void ItRoundTripsUnicodeScalars(uint scalar) + { + var rune = new Rune(scalar); + foreach (var owned in new[] { false, true }) + { + RoundTrip(ComponentValue.Char(scalar), owned).AsChar().Should().Be(scalar); + RoundTrip(ComponentValue.Char(rune), owned).AsRune().Should().Be(rune); + if (scalar <= char.MaxValue) + RoundTrip(ComponentValue.Char((char)scalar), owned).AsChar().Should().Be(scalar); + } + } + + [Theory] + [InlineData(0xD800u)] + [InlineData(0xDFFFu)] + [InlineData(0x110000u)] + [InlineData(uint.MaxValue)] + public void ItRejectsInvalidUnicodeScalars(uint scalar) + { + Action create = () => ComponentValue.Char(scalar); + create.Should().Throw(); + if (scalar <= char.MaxValue) + { + Action createFromChar = () => ComponentValue.Char((char)scalar); + createFromChar.Should().Throw(); + } + using var scope = new ComponentValueMarshaller.AllocationScope(); + var pointer = scope.Allocate(ComponentValueMarshaller.ValueSize); + Marshal.WriteByte(pointer, (byte)ComponentValueKind.Char); + Marshal.WriteInt32(pointer + ComponentValueMarshaller.ValuePayloadOffset, unchecked((int)scalar)); + Action read = () => ComponentValueMarshaller.Read(pointer); + read.Should().Throw(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ItRoundTripsNestedVariantsAndArbitraryFlagCounts(bool owned) + { + var flags = Enumerable.Range(0, 100).Select(i => "flag-" + i).ToArray(); + var value = ComponentValue.Variant("outer", + ComponentValue.Some(ComponentValue.List(new[] + { + ComponentValue.Variant("empty"), + ComponentValue.Variant("scalar", ComponentValue.Char(0x1F600)), + ComponentValue.Variant("access", ComponentValue.Flags(flags)), + }))); + var result = RoundTrip(value, owned); + result.AsVariant().Should().Be("outer"); + var items = result.Payload!.Payload!.AsList(); + items[0].AsVariant().Should().Be("empty"); + items[0].Payload.Should().BeNull(); + items[1].Payload!.AsChar().Should().Be(0x1F600); + items[2].Payload!.AsFlags().Should().Equal(flags); + RoundTrip(ComponentValue.Flags(Array.Empty()), owned).AsFlags().Should().BeEmpty(); + } + + [Fact] + public void ItCopiesFlagsAndRejectsInvalidInputsAndWrongAccessors() + { + var names = new[] { "first" }; + var flags = ComponentValue.Flags(names); + names[0] = "changed"; + flags.AsFlags().Should().Equal("first"); + Action duplicates = () => ComponentValue.Flags(new[] { "first", "first" }); + duplicates.Should().Throw(); + Action nullName = () => ComponentValue.Flags(new[] { (string)null! }); + nullName.Should().Throw(); + Action nullNames = () => ComponentValue.Flags(null!); + nullNames.Should().Throw(); + Action nullCase = () => ComponentValue.Variant(null!); + nullCase.Should().Throw(); + Action wrongChar = () => flags.AsChar(); + wrongChar.Should().Throw(); + Action wrongVariant = () => flags.AsVariant(); + wrongVariant.Should().Throw(); + Action wrongFlags = () => ComponentValue.Char(0).AsFlags(); + wrongFlags.Should().Throw(); + } +} diff --git a/tests/ComponentValueLayoutTests.cs b/tests/ComponentValueLayoutTests.cs index 5e63895a..a5770dc0 100644 --- a/tests/ComponentValueLayoutTests.cs +++ b/tests/ComponentValueLayoutTests.cs @@ -79,6 +79,7 @@ private static void AssertEquivalent(ComponentValue expected, ComponentValue act case ComponentValueKind.U32: case ComponentValueKind.S64: case ComponentValueKind.U64: + case ComponentValueKind.Char: actual.Integer.Should().Be(expected.Integer, "value at {0}", path); break; @@ -117,6 +118,8 @@ private static void AssertEquivalent(ComponentValue expected, ComponentValue act case ComponentValueKind.Option: case ComponentValueKind.Result: + case ComponentValueKind.Variant: + actual.Text.Should().Be(expected.Text, "case name at {0}", path); actual.Flag.Should().Be(expected.Flag, "discriminant at {0}", path); if (expected.Payload is null) { @@ -130,11 +133,32 @@ private static void AssertEquivalent(ComponentValue expected, ComponentValue act break; + case ComponentValueKind.Flags: + actual.AsFlags().Should().Equal(expected.AsFlags(), "flags at {0}", path); + break; + default: throw new NotSupportedException($"No comparison for kind {expected.Kind}."); } } + [Fact] + public void ExtendedValuesSurviveACloneThroughWasmtime() + { + Survives(ComponentValue.Char(0)); + Survives(ComponentValue.Char(0x1F600)); + Survives(ComponentValue.Char(0x10FFFF)); + Survives(ComponentValue.Flags(Array.Empty())); + Survives(ComponentValue.Flags(new[] { "read", "write", "f69" })); + Survives(ComponentValue.Variant("empty")); + Survives(ComponentValue.Variant("nested", ComponentValue.Some(ComponentValue.List(new[] + { + ComponentValue.Variant("scalar", ComponentValue.Char(0x1F600)), + ComponentValue.Variant("text", ComponentValue.String("payload")), + ComponentValue.Variant("flags", ComponentValue.Flags(new[] { "read", "write" })), + })))); + } + [Fact] public void ScalarsSurviveACloneThroughWasmtime() { diff --git a/tests/ComponentValueTests.cs b/tests/ComponentValueTests.cs index 70f0cb46..d3e2f5ac 100644 --- a/tests/ComponentValueTests.cs +++ b/tests/ComponentValueTests.cs @@ -345,11 +345,11 @@ public void ItThrowsReadingAnUnsupportedKind() { using var scope = new ComponentValueMarshaller.AllocationScope(); var pointer = scope.Allocate(ComponentValueMarshaller.ValueSize); - System.Runtime.InteropServices.Marshal.WriteByte(pointer, (byte)ComponentValueKind.Char); + System.Runtime.InteropServices.Marshal.WriteByte(pointer, (byte)ComponentValueKind.Resource); var act = () => ComponentValueMarshaller.Read(pointer); - act.Should().Throw().WithMessage("*Char*"); + act.Should().Throw().WithMessage("*Resource*"); } ///