diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d1c13a88..c10d806a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -56,6 +56,7 @@ export default defineConfig({ { text: "Interop Modules", link: "/guide/interop-modules" }, { text: "Interop Instances", link: "/guide/interop-instances" }, { text: "Preferences", link: "/guide/preferences" }, + { text: "Specialization", link: "/guide/specialization" }, { text: "Build Configuration", link: "/guide/build-config" }, { text: "Sideloading", link: "/guide/sideloading" }, { text: "NativeAOT-LLVM", link: "/guide/llvm" } diff --git a/docs/guide/specialization.md b/docs/guide/specialization.md new file mode 100644 index 00000000..29197dc6 --- /dev/null +++ b/docs/guide/specialization.md @@ -0,0 +1,111 @@ +# Specialization + +Bootsharp marshals every type automatically based on the convention: types with immutable/value semantics are [serialized by value](/guide/serialization), and others are [passed by reference](/guide/interop-instances). + +It's possible to customize the behaviour with **specialization** are redefine how a particular CLR type crosses the interop boundary and what surface it exposes on the other side. + +## How It Works + +A specialization is a pair of classes describing a custom interop surface for a specific CLR type — one for each direction: + +- **Export** (C# → JS) — a class annotated with `[SpecializeExport(typeof(T))]` and inherited from `SpecializedExport`. Bootsharp wraps an exported instance of the specialized type into this class before it crosses to JavaScript. +- **Import** (JS → C#) — an abstract class annotated with `[SpecializeImport(typeof(T))]` and inherited from `SpecializedImport`. Bootsharp uses it as the base of the generated interop proxy and treats its abstract members as the interop surface wired to JavaScript. + +The two halves are paired: the export half implements every abstract member declared on the import half, so the same shape is exposed in both directions. + +To override how Bootsharp marshals a type declare a specialization pair with the same attributes. Here's an example for `IComparer`: + +```csharp +[SpecializeImport(typeof(IComparer<>))] +public abstract class ComparerImport (int id) + : SpecializedImport(id), IComparer +{ + public abstract int Compare (T? x, T? y); +} + +[SpecializeExport(typeof(IComparer<>))] +public class ComparerExport (IComparer cmp) + : SpecializedExport(cmp) +{ + public int Compare (T? x, T? y) => cmp.Compare(x, y); +} +``` + +The import half declares the interop surface (`Compare`) as abstract members; Bootsharp generates a proxy that forwards them to JavaScript and, since the class implements `IComparer`, the proxy is usable as one on the C# side. + +On the JavaScript side a comparer is just an object matching the declared surface: + +```ts +import { Program } from "bootsharp"; + +Program.provideComparer = () => ({ + compare: (x, y) => x < y ? -1 : x > y ? 1 : 0 +}); + +const comparer = Program.getComparer(); +comparer.compare("a", "b"); // -1 +``` + +## Injecting JavaScript + +The `[SpecializeImport]` attribute accepts optional `JS` and `Decl` snippets that are spliced verbatim into the generated JavaScript proxy class and its TypeScript declaration. This lets the imported proxy satisfy JS-side contracts that aren't expressible through the C# abstract members alone — for example, injecting an iterator: + +```csharp +[SpecializeImport(typeof(ICustomCollection<>), + JS: "[Symbol.iterator]() { return this.copy()[Symbol.iterator](); }", + Decl: "[Symbol.iterator](): IterableIterator;")] +``` + +## Unwrapping + +An import specializer normally *is* the value handed to C# — the generated proxy implements the specialized interface, so it can stand in for it directly (the way `ComparerImport` above serves as an `IComparer`). + +That doesn't work when the specialized type can't be implemented by a proxy, such as a value type like `CancellationToken`. In that case the proxy exposes the JavaScript-side surface as abstract members and overrides `SpecializedImport.Unwrap()` to build the concrete value from them: + +```csharp +[SpecializeImport(typeof(CancellationToken))] +public abstract class CancellationTokenImport (int id) : SpecializedImport(id) +{ + public abstract bool IsCancellationRequested { get; } + public abstract event Action OnCancellationRequested; + + private CancellationTokenSource? src; + + protected internal override object Unwrap () + { + if (src != null) return src.Token; + src = new(); + if (IsCancellationRequested) src.Cancel(); + else OnCancellationRequested += src.Cancel; + return src.Token; + } +} + +[SpecializeExport(typeof(CancellationToken))] +public sealed class CancellationTokenExport : SpecializedExport +{ + public bool IsCancellationRequested => ct.IsCancellationRequested; + public event Action? OnCancellationRequested; + + private readonly CancellationToken ct; + + public CancellationTokenExport (CancellationToken ct) : base(ct) + { + this.ct = ct; + ct.Register(() => OnCancellationRequested?.Invoke()); + } +} +``` + +Bootsharp calls `Unwrap()` to obtain the value passed to C# — here a real `CancellationToken` backed by a source that's cancelled whenever the JavaScript token reports cancellation. The paired JavaScript class signals through the same surface: + +```ts +import { CancellationToken } from "bootsharp"; + +const token = new CancellationToken(); +token.cancel(); // fires onCancellationRequested +``` + +## Reference + +The built-in specializations live in [`Specialized.cs`](https://github.com/elringus/bootsharp/blob/main/src/cs/Bootsharp.Common/Specialization/Specialized.cs); their JavaScript counterparts are the modules under [`src/js/src/bcl`](https://github.com/elringus/bootsharp/tree/main/src/js/src/bcl). Use them as a template when authoring your own. diff --git a/src/cs/Bootsharp.Common.Test/InstancesTest.cs b/src/cs/Bootsharp.Common.Test/InstancesTest.cs index fc612ac6..b4ea1a0f 100644 --- a/src/cs/Bootsharp.Common.Test/InstancesTest.cs +++ b/src/cs/Bootsharp.Common.Test/InstancesTest.cs @@ -9,6 +9,12 @@ private interface IBar; private class Foo : IFoo; private class Bar : IBar; private class Proxy (int id) : JSProxy(id); + private class SpecializedExport (object it) : Bootsharp.SpecializedExport(it); + + private class SpecializedImport (int id, object it) : Bootsharp.SpecializedImport(id) + { + protected internal override object Unwrap () => it; + } private class DelegateProxy (int id) : JSProxy(id) { @@ -40,13 +46,25 @@ public void ShortCircuitsRegisteredExports () [Fact] public void ShortCircuitsImportedProxies () { - Assert.Equal(42, Export(new Proxy(42))); + Assert.Equal(Export(new Proxy(1)), Export(new Proxy(1))); + } + + [Fact] + public void ShortCircuitsSpecializedExportsWrappingImportedProxy () + { + Assert.Equal(Export(new SpecializedExport(new Proxy(1))), Export(new SpecializedExport(new Proxy(1)))); + } + + [Fact] + public void GeneratesUniqueIdsForSpecializedExportsNotWrappingImportedProxy () + { + Assert.NotEqual(Export(new SpecializedExport(new object())), Export(new SpecializedExport(new object()))); } [Fact] public void ShortCircuitsImportedDelegates () { - Assert.Equal(42, Export(new DelegateProxy(42).Invoke)); + Assert.Equal(Export(new DelegateProxy(1).Invoke), Export(new DelegateProxy(1).Invoke)); } [Fact] @@ -95,6 +113,7 @@ public void ShortCircuitsRegisteredImportsUntilDisposed () DisposeImported(42); // Now we exercise the factory and register the new instance as '42'. Assert.NotSame(imported, Resolve(42)); + DisposeImported(42); } [Fact] @@ -105,7 +124,32 @@ public void ShortCircuitsImportedExports () } [Fact] - public void ImportsNullWhenInstanceIsZero () + public void UnwrapsResolvedSpecializedImportsOfRefType () + { + var imported = new Bar(); + RegisterImport(typeof(IBar), id => new SpecializedImport(id, imported)); + Assert.Same(imported, Resolve(1)); + DisposeImported(1); + } + + [Fact] + public void UnwrapsResolvedSpecializedImportsOfValueType () + { + var imported = DateTime.Now; + RegisterImport(typeof(DateTime), id => new SpecializedImport(id, imported)); + Assert.Equal(imported, Resolve(1)); + DisposeImported(1); + } + + [Fact] + public void UnwrapsResolvedSpecializedExports () + { + var exported = new Foo(); + Assert.Same(exported, Resolve(Export(new SpecializedExport(exported)))); + } + + [Fact] + public void ResolvesNullWhenIdIsZero () { Assert.Null(Resolve(0)); } diff --git a/src/cs/Bootsharp.Common.Test/TypesTest.cs b/src/cs/Bootsharp.Common.Test/TypesTest.cs index 538153e3..36950743 100644 --- a/src/cs/Bootsharp.Common.Test/TypesTest.cs +++ b/src/cs/Bootsharp.Common.Test/TypesTest.cs @@ -8,6 +8,7 @@ namespace Bootsharp.Common.Test; public class TypesTest { + private class SpecializedImport (int id) : Bootsharp.SpecializedImport(id); private readonly CustomAttributeData export = GetMockExportAttribute(); private readonly CustomAttributeData import = GetMockImportAttribute(); @@ -16,7 +17,11 @@ public void TypesAreAssigned () { Assert.Equal([typeof(IBackend)], new ExportAttribute(typeof(IBackend)).Types); Assert.Equal([typeof(IFrontend)], new ImportAttribute(typeof(IFrontend)).Types); - Assert.Equal("Space", (new PreferencesAttribute { Space = ["Space"] }).Space[0]); + Assert.Equal(typeof(IBackend), new SpecializeExportAttribute(typeof(IBackend)).Clr); + Assert.Equal(typeof(IFrontend), new SpecializeImportAttribute(typeof(IFrontend)).Clr); + Assert.Equal("JS", new SpecializeImportAttribute(typeof(IFrontend), JS: "JS").JS); + Assert.Equal("Decl", new SpecializeImportAttribute(typeof(IFrontend), Decl: "Decl").Decl); + Assert.Equal("Space", new PreferencesAttribute { Space = ["Space"] }.Space[0]); } [Fact] @@ -35,6 +40,13 @@ public void ImportParametersEqualArguments () .Select(a => a.Value)); } + [Fact] + public void SpecializedImportUnwrapsToItself () + { + var imported = new SpecializedImport(1); + Assert.Same(imported, imported.Unwrap()); + } + private static CustomAttributeData GetMockExportAttribute () => typeof(TypesTest).Assembly.CustomAttributes .First(a => a.AttributeType == typeof(ExportAttribute)); diff --git a/src/cs/Bootsharp.Common/Attributes/SpecializeExportAttribute.cs b/src/cs/Bootsharp.Common/Attributes/SpecializeExportAttribute.cs new file mode 100644 index 00000000..f09258fc --- /dev/null +++ b/src/cs/Bootsharp.Common/Attributes/SpecializeExportAttribute.cs @@ -0,0 +1,17 @@ +namespace Bootsharp; + +/// +/// Allows customizing the way Bootsharp treats specified CLR type on the export direction (C# -> JS). +/// When applied to a class inherited from , Bootsharp wraps exported +/// instances of the specialized type into instances of this class before crossing the interop boundary. +/// The exported specialization is expected to be paired with the +/// counterpart and contain implementations for all the abstract members defined on the imported specialization. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class SpecializeExportAttribute (Type clr) : Attribute +{ + /// + /// The CLR type to specialize the export for. + /// + public Type Clr { get; } = clr; +} diff --git a/src/cs/Bootsharp.Common/Attributes/SpecializeImportAttribute.cs b/src/cs/Bootsharp.Common/Attributes/SpecializeImportAttribute.cs new file mode 100644 index 00000000..8325ead4 --- /dev/null +++ b/src/cs/Bootsharp.Common/Attributes/SpecializeImportAttribute.cs @@ -0,0 +1,25 @@ +namespace Bootsharp; + +/// +/// Allows customizing the way Bootsharp treats specified CLR type on the import direction (JS -> C#). +/// When applied to an abstract class inherited from , Bootsharp will use +/// the class as the base for the generated interop proxy and treat the abstract members of the class as +/// the actual interop surface. All the abstract members are expected to be implemented on the paired export +/// specialization of the class annotated with . +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class SpecializeImportAttribute (Type clr, string? JS = null, string? Decl = null) : Attribute +{ + /// + /// The CLR type to specialize the import for. + /// + public Type Clr { get; } = clr; + /// + /// Raw snippet spliced into the generated JavaScript proxy class. + /// + public string? JS { get; } = JS; + /// + /// Raw snippet spliced into the generated TypeScript declaration. + /// + public string? Decl { get; } = Decl; +} diff --git a/src/cs/Bootsharp.Common/Interop/Instances.cs b/src/cs/Bootsharp.Common/Interop/Instances.cs index 791f0981..55454b97 100644 --- a/src/cs/Bootsharp.Common/Interop/Instances.cs +++ b/src/cs/Bootsharp.Common/Interop/Instances.cs @@ -31,32 +31,36 @@ public static class Instances /// Resolves a registered instance associated with the specified ID, or uses a factory that /// was registered with to register a new imported instance. /// - public static T Resolve (int id) where T : class + public static T? Resolve (int id) { - if (id == 0) return null!; - if (id < 0) return (T)exportedById[id]; - if (importedById.GetValueOrDefault(id) is { } weak) return (T)weak.Target!; - var instance = (T)importers[typeof(T)](id); - importedById[id] = new(instance); - return instance; + if (id == 0) return default; + if (id < 0) return UnwrapExport(exportedById[id]); + if (importedById.GetValueOrDefault(id) is { } weak) return UnwrapImport(weak.Target!); + var it = importers[typeof(T)](id); + importedById[id] = new(it); + return UnwrapImport(it); + + static T UnwrapImport (object o) => o is T t ? t : (T)((SpecializedImport)o).Unwrap(); + static T UnwrapExport (object o) => o is T t ? t : (T)((SpecializedExport)o)._it; } /// /// Registers specified exported (C#) instance and returns the associated unique ID. /// Short-circuits already registered exported and imported instances. /// - /// The instance to register. + /// The instance to register. /// Callback to invoke when registering and disposing the instance. /// Unique ID associated with the registered instance. - public static int Export (T? instance, ExportCallback? cb = null) where T : class + public static int Export (T? it, ExportCallback? cb = null) where T : class { - if (instance is null) return 0; - if (instance is JSProxy imp) return imp._id; - if (instance is Delegate { Target: JSProxy del }) return del._id; - if (idByExported.TryGetValue(instance, out var id)) return id; + if (it is null) return 0; + if (it is JSProxy js) return js._id; + if (it is Delegate { Target: JSProxy del }) return del._id; + if (it is SpecializedExport { _it: JSProxy ejs }) return ejs._id; + if (idByExported.TryGetValue(it, out var id)) return id; id = idPool.Count > 0 ? idPool.Dequeue() : nextId++; - exportedById[idByExported[instance] = id] = instance; - if (cb != null) onDisposeById[id] = cb(id, instance); + exportedById[idByExported[it] = id] = it; + if (cb != null) onDisposeById[id] = cb(id, it); return id; } diff --git a/src/cs/Bootsharp.Common/Specialization/Specialized.cs b/src/cs/Bootsharp.Common/Specialization/Specialized.cs new file mode 100644 index 00000000..d68c26bb --- /dev/null +++ b/src/cs/Bootsharp.Common/Specialization/Specialized.cs @@ -0,0 +1,195 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Bootsharp; + +/// +/// Built-in specializations Bootsharp ships by default; backed by JS implementations under src/bcl. +/// User can add custom specializations using the same attributes. +/// +[ExcludeFromCodeCoverage(Justification = "Not practical due to coupling with the generated code; covered in E2E.")] +public static class Specialized +{ + private const string iterJS = "[Symbol.iterator]() { return this.copy()[Symbol.iterator](); }"; + private const string iterDecl = "[Symbol.iterator](): IterableIterator;"; + + [SpecializeImport(typeof(ICollection<>), JS: iterJS, Decl: iterDecl)] + public abstract class CollectionImport (int id) : SpecializedImport(id), ICollection + { + public abstract int Count { get; } + public bool IsReadOnly => false; + + public abstract void Add (T item); + public abstract bool Remove (T item); + public abstract void Clear (); + public abstract bool Contains (T item); + public abstract T[] Copy (); + + public void CopyTo (T[] array, int arrayIndex) + { + foreach (var item in Copy()) + array[arrayIndex++] = item; + } + + public IEnumerator GetEnumerator () + { + foreach (var item in Copy()) + yield return item; + } + + IEnumerator IEnumerable.GetEnumerator () => GetEnumerator(); + } + + [SpecializeExport(typeof(ICollection<>))] + public class CollectionExport (ICollection cl) : SpecializedExport(cl) + { + public int Count => cl.Count; + public void Add (T item) => cl.Add(item); + public bool Remove (T item) => cl.Remove(item); + public void Clear () => cl.Clear(); + public bool Contains (T item) => cl.Contains(item); + public T[] Copy () => cl.ToArray(); + } + + [SpecializeImport(typeof(IList<>), JS: iterJS, Decl: iterDecl)] + public abstract class ListImport (int id) : CollectionImport(id), IList + { + public T this [int index] { get => GetAt(index); set => SetAt(index, value); } + public abstract int IndexOf (T item); + public abstract void Insert (int index, T item); + public abstract void RemoveAt (int index); + public abstract T GetAt (int index); + public abstract void SetAt (int index, T item); + } + + [SpecializeExport(typeof(IList<>))] + public class ListExport (IList list) : CollectionExport(list) + { + public int IndexOf (T item) => list.IndexOf(item); + public void Insert (int index, T item) => list.Insert(index, item); + public void RemoveAt (int index) => list.RemoveAt(index); + public T GetAt (int index) => list[index]; + public void SetAt (int index, T item) => list[index] = item; + } + + [SpecializeImport( + typeof(IDictionary<,>), + JS: """ + [Symbol.iterator]() { + const keys = this.getKeys(), values = this.getValues(); + return keys.map((key, i) => [key, values[i]])[Symbol.iterator](); + } + """, + Decl: "[Symbol.iterator](): IterableIterator<[TKey, TValue]>;")] + public abstract class DictionaryImport (int id) : SpecializedImport(id), IDictionary + { + public abstract int Count { get; } + public bool IsReadOnly => false; + public ICollection Keys => GetKeys(); + public ICollection Values => GetValues(); + public TValue this [TKey key] { get => GetAt(key); set => SetAt(key, value); } + + public abstract void Add (TKey key, TValue value); + public abstract bool ContainsKey (TKey key); + public abstract bool Remove (TKey key); + public abstract void Clear (); + public abstract TValue GetAt (TKey key); + public abstract void SetAt (TKey key, TValue value); + public abstract TKey[] GetKeys (); + public abstract TValue[] GetValues (); + + public bool TryGetValue (TKey key, [MaybeNullWhen(false)] out TValue value) + { + if (!ContainsKey(key)) + { + value = default; + return false; + } + value = GetAt(key); + return true; + } + + void ICollection>.Add (KeyValuePair kv) => Add(kv.Key, kv.Value); + bool ICollection>.Contains (KeyValuePair kv) => ContainsKey(kv.Key); + bool ICollection>.Remove (KeyValuePair kv) => Remove(kv.Key); + + void ICollection>.CopyTo (KeyValuePair[] array, int arrayIndex) + { + foreach (var pair in this) + array[arrayIndex++] = pair; + } + + public IEnumerator> GetEnumerator () + { + var keys = GetKeys(); + var values = GetValues(); + for (var i = 0; i < keys.Length; i++) + yield return new(keys[i], values[i]); + } + + IEnumerator IEnumerable.GetEnumerator () => GetEnumerator(); + } + + [SpecializeExport(typeof(IDictionary<,>))] + public class DictionaryExport (IDictionary dic) : SpecializedExport(dic) + { + public int Count => dic.Count; + public void Add (TKey key, TValue value) => dic.Add(key, value); + public bool ContainsKey (TKey key) => dic.ContainsKey(key); + public bool Remove (TKey key) => dic.Remove(key); + public void Clear () => dic.Clear(); + public TValue GetAt (TKey key) => dic[key]; + public void SetAt (TKey key, TValue value) => dic[key] = value; + public TKey[] GetKeys () => dic.Keys.ToArray(); + public TValue[] GetValues () => dic.Values.ToArray(); + } + + [SpecializeImport(typeof(CancellationToken))] + public abstract class CancellationTokenImport (int id) : SpecializedImport(id) + { + public abstract event Action OnCancellationRequested; + public abstract bool IsCancellationRequested { get; } + + internal static readonly ConditionalWeakTable + ImportBySrc = new(); + private CancellationTokenSource? src; + + protected internal override object Unwrap () + { + if (src == null) + { + ImportBySrc.Add(src = new(), this); + if (IsCancellationRequested) src.Cancel(); + else OnCancellationRequested += src.Cancel; + } + return src.Token; + } + } + + [SpecializeExport(typeof(CancellationToken))] + public sealed class CancellationTokenExport : SpecializedExport + { + public event Action? OnCancellationRequested; + public bool IsCancellationRequested => ct.IsCancellationRequested; + + private readonly CancellationToken ct; + + public CancellationTokenExport (CancellationToken ct) : base(Resolve(ct)) + { + this.ct = ct; + if (ct.CanBeCanceled && !ct.IsCancellationRequested) + ct.Register(() => OnCancellationRequested?.Invoke()); + } + + private static object Resolve (CancellationToken ct) + { + // Preserving the imported token's cancellation sources identity + // when they round trip via an exported interop API. + if (GetSource(ref ct) is not { } src) return ct; + return CancellationTokenImport.ImportBySrc.TryGetValue(src, out var i) ? i : ct; + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_source")] + static extern ref CancellationTokenSource? GetSource (ref CancellationToken ct); + } + } +} diff --git a/src/cs/Bootsharp.Common/Specialization/SpecializedExport.cs b/src/cs/Bootsharp.Common/Specialization/SpecializedExport.cs new file mode 100644 index 00000000..8bd141fc --- /dev/null +++ b/src/cs/Bootsharp.Common/Specialization/SpecializedExport.cs @@ -0,0 +1,14 @@ +namespace Bootsharp; + +/// +/// Base class for specialized exports annotated with . +/// +/// /// +/// Export specializer (the class inherited from this base) is expected to implement all the abstract +/// members specified on the import specializer. +/// +/// An instance of the specialized exported type. +public abstract class SpecializedExport (object it) +{ + protected internal readonly object _it = it; +} diff --git a/src/cs/Bootsharp.Common/Specialization/SpecializedImport.cs b/src/cs/Bootsharp.Common/Specialization/SpecializedImport.cs new file mode 100644 index 00000000..7cce810f --- /dev/null +++ b/src/cs/Bootsharp.Common/Specialization/SpecializedImport.cs @@ -0,0 +1,20 @@ +namespace Bootsharp; + +/// +/// Base class for specialized imports annotated with . +/// +/// +/// Import specializer (the class inherited from this base) is expected to either implement the specialized +/// interface, or (when the specialized type is not an interface) override to return +/// the instance or value (in case of value types) of the expected concrete type. +/// +/// Unique identifier of the specialized imported type instance. +public abstract class SpecializedImport (int id) : JSProxy(id) +{ + /// + /// Returns the instance or value of the specialized CLR type. + /// By default, it's the proxy itself (which implements the specialized CLR type), however + /// non-interface specializations override this to produce the object of the expected concrete type. + /// + protected internal virtual object Unwrap () => this; +} diff --git a/src/cs/Bootsharp.Generate.Test/GeneratorTest.cs b/src/cs/Bootsharp.Generate.Test/GeneratorTest.cs index f466f0ff..19fa4cc4 100644 --- a/src/cs/Bootsharp.Generate.Test/GeneratorTest.cs +++ b/src/cs/Bootsharp.Generate.Test/GeneratorTest.cs @@ -114,7 +114,7 @@ private static void IncludeBootsharpSources (SourceFileList sources) global using System.Collections.Generic; global using System.IO; global using System.Linq; - global using System.Threading.Tasks; + global using System.Threading; global using Bootsharp; """)); IncludeBootsharpSources(sources); diff --git a/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInstanceTest.cs b/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInstanceTest.cs index 34b0e617..16649ead 100644 --- a/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInstanceTest.cs +++ b/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInstanceTest.cs @@ -31,7 +31,7 @@ public class Class Execute(); Contains( """ - public class JS_Import_IImported (int id) : global::Bootsharp.JSProxy(id), global::IImported + public sealed class JS_Import_IImported (int id) : global::Bootsharp.JSProxy(id), global::IImported { ~JS_Import_IImported() => Instances.DisposeImported(_id); @@ -120,7 +120,7 @@ internal static int Export (global::IExported it) => Export(it, static (_id, it) it.Changed -= HandleChanged; }; - void HandleChanged (global::Record arg1, global::IExported arg2) => Interop.IExported_BroadcastChanged_Serialized(_id, Serializer.Serialize(arg1, SerializerContext.Record), Instances.Export(arg2)); + void HandleChanged (global::Record arg1, global::IExported arg2) => Interop.JS_Export_IExported_BroadcastChanged_Serialized(_id, Serializer.Serialize(arg1, SerializerContext.Record), Instances.Export(arg2)); }); """); } @@ -157,7 +157,7 @@ public interface IInstanced; public interface IModule { IInstanced Item { get; set; } } """)); Execute(); - Contains("public class JS_Import_IInstanced (int id) : global::Bootsharp.JSProxy(id), global::IInstanced"); + Contains("public sealed class JS_Import_IInstanced (int id) : global::Bootsharp.JSProxy(id), global::IInstanced"); } [Fact] @@ -216,11 +216,141 @@ public void DoesNotGenerateProxyForExportedDelegates () } [Fact] - public void ReclassifiesImportedClassesAsExports () + public void GeneratesForCustomSpecialization () { - // it's impossible to import a concrete C# class, so it's either a user error in the authored interop + AddAssembly(With( + """ + public class Custom + { + public event Action ErasedEvent; + public string ErasedProperty { get; set; } + public string ErasedMethod () => ""; + } + + [SpecializeImport(typeof(Custom))] + public abstract class CustomImport (int id) : SpecializedImport(id) + { + public abstract event Action AddedEvent; + public abstract string AddedProperty { get; set; } + public abstract string AddedMethod (); + } + + [SpecializeExport(typeof(Custom))] + public sealed class CustomExport (Custom it) : SpecializedExport(it) + { + public event Action AddedEvent; + public string AddedProperty { get; set; } + public string AddedMethod () => ""; + } + + public class Class + { + [Export] public static Custom Foo (Custom it) => default!; + } + """)); + Execute(); + Contains("Instances.RegisterImport(typeof(global::Custom), static id => new global::Bootsharp.Generated.JS_Import_Custom(id));"); + Contains( + """ + public sealed class JS_Import_Custom (int id) : global::CustomImport(id) + { + ~JS_Import_Custom() => Instances.DisposeImported(_id); + + public override event global::System.Action AddedEvent; + internal void InvokeAddedEvent () => AddedEvent?.Invoke(); + public override global::System.String AddedProperty + { + get => global::Bootsharp.Generated.Interop.JS_Import_Custom_GetAddedProperty(_id); + set => global::Bootsharp.Generated.Interop.JS_Import_Custom_SetAddedProperty(_id, value); + } + public override global::System.String AddedMethod () => global::Bootsharp.Generated.Interop.JS_Import_Custom_AddedMethod(_id); + } + """); + Contains( + """ + internal static int Export (global::Custom it) => Export(new global::CustomExport(it), static (_id, it) => { + it.AddedEvent += HandleAddedEvent; + return () => { + it.AddedEvent -= HandleAddedEvent; + }; + + void HandleAddedEvent () => Interop.JS_Export_Custom_BroadcastAddedEvent_Serialized(_id); + }); + """); + } + + [Fact] + public void GeneratesForBuiltInSpecializations () + { + AddAssembly(WithClass( + """ + [Export] public static ICollection Foo (ICollection col) => default!; + [Export] public static IList Baz (IList list) => default!; + [Export] public static IDictionary Dic (IDictionary dic) => default!; + [Export] public static CancellationToken Bar (CancellationToken ct) => default!; + """)); + Execute(); + Contains("int Export (global::System.Collections.Generic.ICollection it) => Export(new global::Bootsharp.Specialized.CollectionExport(it)"); + Contains("int Export (global::System.Collections.Generic.IList it) => Export(new global::Bootsharp.Specialized.ListExport(it)"); + Contains("int Export (global::System.Collections.Generic.IDictionary it) => Export(new global::Bootsharp.Specialized.DictionaryExport(it)"); + Contains("int Export (global::System.Threading.CancellationToken it) => Export(new global::Bootsharp.Specialized.CancellationTokenExport(it)"); + Contains("class JS_Import_System_Collections_Generic_ICollection_Of_System_Int32 (int id) : global::Bootsharp.Specialized.CollectionImport"); + Contains("class JS_Import_System_Collections_Generic_IList_Of_System_Int32 (int id) : global::Bootsharp.Specialized.ListImport"); + Contains("class JS_Import_System_Collections_Generic_IDictionary_Of_System_Int32_And_System_String (int id) : global::Bootsharp.Specialized.DictionaryImport"); + Contains("class JS_Import_System_Threading_CancellationToken (int id) : global::Bootsharp.Specialized.CancellationTokenImport(id)"); + } + + [Fact] + public void IgnoresTopLevelNullity () + { + // Top-level nullity (IFoo? vs IFoo, IFoo? vs IFoo) must not split the generated proxies, + // because it does not affect the marshalling/interop shape of the surface members. + AddAssembly(With( + """ + public interface IFoo; + public interface IBar; + + public class Class + { + [Import] public static IFoo GetFoo () => default!; + [Import] public static IFoo? GetFooNullable () => default!; + [Import] public static IBar GetBar () => default!; + [Import] public static IBar? GetBarNullable () => default!; + } + """)); + Execute(); + Once("class JS_Import_IFoo "); + Once("class JS_Import_IBar_Of_System_String "); + DoesNotContain("class JS_Import_IFooOrNull "); + DoesNotContain("class JS_Import_IBar_Of_System_StringOrNull "); + } + + [Fact] + public void DiscriminatesGenericArgNullity () + { + // Generic-arg nullity (IBar vs IBar) however does affect surface member signatures, + // so distinct proxies must be generated per nullity variant. + AddAssembly(With( + """ + public interface IBar; + + public class Class + { + [Import] public static IBar GetBar () => default!; + [Import] public static IBar GetBarNullableArg () => default!; + } + """)); + Execute(); + Once("class JS_Import_IBar_Of_System_String "); + Once("class JS_Import_IBar_Of_System_StringOrNull "); + } + + [Fact] + public void ReclassifiesImportedNonInterfaceAsExports () + { + // It's impossible to import a concrete C# type, so it's either a user error in the authored interop // surface or the intention is to pass back previously exported instance — we assume the latter in the - // implementation and reclassify to export direction in such cases + // implementation and reclassify to export direction in such cases. AddAssembly(With( """ public class Exported; @@ -238,4 +368,14 @@ public static Exported CreateExported (Func factory = null) DoesNotContain("JS_Import_Exported"); DoesNotContain("RegisterImport(typeof(global::Exported)"); } + + [Fact] + public void DoesNotReclassifySpecializedNonInterfaceImports () + { + // Any specialized type is expected to have hand-rolled exporter and importer proxies, + // so they're safe to import even when the type is not an interface. + AddAssembly(WithClass("[Export] public static void Foo (CancellationToken ct) {}")); + Execute(); + Contains("Instances.RegisterImport(typeof(global::System.Threading.CancellationToken),"); + } } diff --git a/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInteropTest.cs b/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInteropTest.cs index 918fc314..a65b49ed 100644 --- a/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInteropTest.cs +++ b/src/cs/Bootsharp.Publish.Test/GenerateCS/CSInteropTest.cs @@ -212,16 +212,16 @@ public interface IImported Execute(); Contains("[JSExport] [return: JSMarshalAs] internal static long JS_Export_Space_IExported_GetState () => Serializer.Serialize(global::Bootsharp.Generated.JS_Export_Space_IExported.GetState(), SerializerContext.Space_Info);"); Contains("[JSExport] internal static void JS_Export_Space_IExported_SetState ([JSMarshalAs] long value) => global::Bootsharp.Generated.JS_Export_Space_IExported.SetState(Serializer.Deserialize(value, SerializerContext.Space_Info));"); - Contains("""[JSImport("IImported.getStateSerialized", "space")] [return: JSMarshalAs] internal static partial long Space_IImported_GetState_Serialized ();"""); - Contains("public static global::Space.Info JS_Import_Space_IImported_GetState() => Serializer.Deserialize(Space_IImported_GetState_Serialized(), SerializerContext.Space_Info);"); - Contains("""[JSImport("IImported.setStateSerialized", "space")] internal static partial void Space_IImported_SetState_Serialized ([JSMarshalAs] long value);"""); - Contains("public static void JS_Import_Space_IImported_SetState(global::Space.Info value) => Space_IImported_SetState_Serialized(Serializer.Serialize(value, SerializerContext.Space_Info));"); + Contains("""[JSImport("IImported.getStateSerialized", "space")] [return: JSMarshalAs] internal static partial long JS_Import_Space_IImported_GetState_Serialized ();"""); + Contains("public static global::Space.Info JS_Import_Space_IImported_GetState() => Serializer.Deserialize(JS_Import_Space_IImported_GetState_Serialized(), SerializerContext.Space_Info);"); + Contains("""[JSImport("IImported.setStateSerialized", "space")] internal static partial void JS_Import_Space_IImported_SetState_Serialized ([JSMarshalAs] long value);"""); + Contains("public static void JS_Import_Space_IImported_SetState(global::Space.Info value) => JS_Import_Space_IImported_SetState_Serialized(Serializer.Serialize(value, SerializerContext.Space_Info));"); Contains("[JSExport] internal static global::System.Boolean JS_Export_Space_IExported_GetActive () => global::Bootsharp.Generated.JS_Export_Space_IExported.GetActive();"); - Contains("""[JSImport("IImported.getActiveSerialized", "space")] internal static partial global::System.Boolean Space_IImported_GetActive_Serialized ();"""); - Contains("public static global::System.Boolean JS_Import_Space_IImported_GetActive() => Space_IImported_GetActive_Serialized();"); + Contains("""[JSImport("IImported.getActiveSerialized", "space")] internal static partial global::System.Boolean JS_Import_Space_IImported_GetActive_Serialized ();"""); + Contains("public static global::System.Boolean JS_Import_Space_IImported_GetActive() => JS_Import_Space_IImported_GetActive_Serialized();"); Contains("[JSExport] internal static void JS_Export_Space_IExported_SetCount (global::System.Int32 value) => global::Bootsharp.Generated.JS_Export_Space_IExported.SetCount(value);"); - Contains("""[JSImport("IImported.setCountSerialized", "space")] internal static partial void Space_IImported_SetCount_Serialized (global::System.Int32 value);"""); - Contains("public static void JS_Import_Space_IImported_SetCount(global::System.Int32 value) => Space_IImported_SetCount_Serialized(value);"); + Contains("""[JSImport("IImported.setCountSerialized", "space")] internal static partial void JS_Import_Space_IImported_SetCount_Serialized (global::System.Int32 value);"""); + Contains("public static void JS_Import_Space_IImported_SetCount(global::System.Int32 value) => JS_Import_Space_IImported_SetCount_Serialized(value);"); } [Fact] @@ -254,13 +254,13 @@ public class Class Execute(); Contains("[JSExport] [return: JSMarshalAs] internal static long JS_Export_IExported_GetState (int _id) => Serializer.Serialize(Instances.Exported(_id).State, SerializerContext.Info);"); Contains("[JSExport] internal static void JS_Export_IExported_SetState (int _id, [JSMarshalAs] long value) => Instances.Exported(_id).State = Serializer.Deserialize(value, SerializerContext.Info);"); - Contains("""[JSImport("IImported.getStateSerialized", "index")] [return: JSMarshalAs] internal static partial long IImported_GetState_Serialized (int _id);"""); + Contains("""[JSImport("IImported.getStateSerialized", "index")] [return: JSMarshalAs] internal static partial long JS_Import_IImported_GetState_Serialized (int _id);"""); Contains("[JSExport] internal static int JS_Export_IExported_GetExported (int _id) => Instances.Export(Instances.Exported(_id).Exported);"); Contains("[JSExport] internal static void JS_Export_IExported_SetImported (int _id, int value) => Instances.Exported(_id).Imported = Instances.Resolve(value);"); - Contains("""[JSImport("IImported.getImportedSerialized", "index")] internal static partial int IImported_GetImported_Serialized (int _id);"""); - Contains("public static global::IImported JS_Import_IImported_GetImported(int _id) => Instances.Resolve(IImported_GetImported_Serialized(_id));"); - Contains("""[JSImport("IImported.setExportedSerialized", "index")] internal static partial void IImported_SetExported_Serialized (int _id, int value);"""); - Contains("public static void JS_Import_IImported_SetExported(int _id, global::IExported value) => IImported_SetExported_Serialized(_id, Instances.Export(value));"); + Contains("""[JSImport("IImported.getImportedSerialized", "index")] internal static partial int JS_Import_IImported_GetImported_Serialized (int _id);"""); + Contains("public static global::IImported JS_Import_IImported_GetImported(int _id) => Instances.Resolve(JS_Import_IImported_GetImported_Serialized(_id));"); + Contains("""[JSImport("IImported.setExportedSerialized", "index")] internal static partial void JS_Import_IImported_SetExported_Serialized (int _id, int value);"""); + Contains("public static void JS_Import_IImported_SetExported(int _id, global::IExported value) => JS_Import_IImported_SetExported_Serialized(_id, Instances.Export(value));"); } [Fact] @@ -287,8 +287,8 @@ internal static unsafe void Initialize () global::Bootsharp.Generated.JS_Export_Space_IExported.Evt += Handle_JS_Export_Space_IExported_Evt; } """); - Contains("void Handle_JS_Export_Space_IExported_Evt (global::Space.Info obj) => Space_IExported_BroadcastEvt_Serialized(Serializer.Serialize(obj, SerializerContext.Space_Info));"); - Contains("""[JSImport("IExported.broadcastEvtSerialized", "space")] internal static partial void Space_IExported_BroadcastEvt_Serialized ([JSMarshalAs] long obj);"""); + Contains("void Handle_JS_Export_Space_IExported_Evt (global::Space.Info obj) => JS_Export_Space_IExported_BroadcastEvt_Serialized(Serializer.Serialize(obj, SerializerContext.Space_Info));"); + Contains("""[JSImport("IExported.broadcastEvtSerialized", "space")] internal static partial void JS_Export_Space_IExported_BroadcastEvt_Serialized ([JSMarshalAs] long obj);"""); Contains("[JSExport] internal static void JS_Import_Space_IImported_InvokeEvt ([JSMarshalAs] long obj) => ((global::Bootsharp.Generated.JS_Import_Space_IImported)Modules.Imports[typeof(global::Space.IImported)].Instance).InvokeEvt(Serializer.Deserialize(obj, SerializerContext.Space_Info));"); } @@ -309,7 +309,7 @@ public partial class Class } """)); Execute(); - Contains("""[JSImport("IExported.broadcastChangedSerialized", "index")] internal static partial void IExported_BroadcastChanged_Serialized (int _id, [JSMarshalAs] long arg1, int arg2);"""); + Contains("""[JSImport("IExported.broadcastChangedSerialized", "index")] internal static partial void JS_Export_IExported_BroadcastChanged_Serialized (int _id, [JSMarshalAs] long arg1, int arg2);"""); Contains("[JSExport] internal static void JS_Import_IImported_InvokeChanged (int _id, [JSMarshalAs] long arg1, int arg2) => ((global::Bootsharp.Generated.JS_Import_IImported)Instances.Resolve(_id)).InvokeChanged(Serializer.Deserialize(arg1, SerializerContext.Record), Instances.Resolve(arg2));"); } diff --git a/src/cs/Bootsharp.Publish.Test/GenerateJS/DeclarationTest.cs b/src/cs/Bootsharp.Publish.Test/GenerateJS/DeclarationTest.cs index bb803de1..84770ac6 100644 --- a/src/cs/Bootsharp.Publish.Test/GenerateJS/DeclarationTest.cs +++ b/src/cs/Bootsharp.Publish.Test/GenerateJS/DeclarationTest.cs @@ -304,47 +304,6 @@ public void DateTimeTranslatedToDate () Contains("doo(time: Date): void"); } - [Fact] - public void ListAndArrayTranslatedToArray () - { - AddAssembly(WithClass("[Export] public static List Goo (DateTime[] d) => default;")); - Execute(); - Contains("goo(d: Array): Array"); - } - - [Fact] - public void JaggedArrayAndListOfListsTranslatedToArrayOfArrays () - { - AddAssembly(WithClass("[Export] public static List> Goo (DateTime[][] d) => default;")); - Execute(); - Contains("goo(d: Array>): Array>"); - } - - [Fact] - public void IntArraysTranslatedToRelatedTypes () - { - AddAssembly( - WithClass("[Export] public static void Uint8 (byte[] foo) {}"), - WithClass("[Export] public static void Int8 (sbyte[] foo) {}"), - WithClass("[Export] public static void Uint16 (ushort[] foo) {}"), - WithClass("[Export] public static void Int16 (short[] foo) {}"), - WithClass("[Export] public static void Uint32 (uint[] foo) {}"), - WithClass("[Export] public static void Int32 (int[] foo) {}"), - WithClass("[Export] public static void BigInt64 (long[] foo) {}"), - WithClass("[Export] public static void Float32 (float[] foo) {}"), - WithClass("[Export] public static void Float64 (double[] foo) {}")); - Execute(); - Contains("uint8(foo: Uint8Array): void"); - Contains("int8(foo: Int8Array): void"); - Contains("uint16(foo: Uint16Array): void"); - Contains("int16(foo: Int16Array): void"); - Contains("uint32(foo: Uint32Array): void"); - Contains("int32(foo: Int32Array): void"); - Contains("bigInt64(foo: BigInt64Array): void"); - Contains("float32(foo: Float32Array): void"); - Contains("float64(foo: Float64Array): void"); - } - [Fact] public void OtherTypesAreTranslatedToAny () { @@ -400,150 +359,88 @@ export interface Interface { } [Fact] - public void GeneratedForTypeWithListProperty () - { - AddAssembly( - With("public interface Item { }"), - With("public class Container { public List Items { get; } }"), - WithClass("[Export] public static Container Combine (List items) => default;")); - Execute(); - Contains( - """ - export namespace Class { - export function combine(items: Array): Container; - } - export interface Item { - } - export interface Container { - readonly items: Array; - } - """); - } - - [Fact] - public void GeneratedForTypeWithJaggedArrayProperty () + public void GeneratedForSpecializedTypes () { - AddAssembly( - With("public interface Item { }"), - With("public class Container { public Item[][] Items { get; } }"), - WithClass("[Export] public static Container Get () => default;")); - Execute(); - Contains( - """ - export namespace Class { - export function get(): Container; - } - export interface Container { - readonly items: Array>; - } - export interface Item { - } - """); - } - - [Fact] - public void GeneratedForTypeWithReadOnlyListProperty () - { - AddAssembly( - With("public interface Item { }"), - With("public class Container { public IReadOnlyList Items { get; } }"), - WithClass("[Export] public static Container Combine (IReadOnlyList items) => default;")); - Execute(); - Contains( + AddAssembly(With( """ - export namespace Class { - export function combine(items: Array): Container; - } - export interface Item { - } - export interface Container { - readonly items: Array; + public interface Item { } + public class Container + { + public IList List { get; } + public ICollection Collection { get; } + public CancellationToken Token { get; } } - """); - } - - [Fact] - public void GeneratedForTypeWithDictionaryProperty () - { - AddAssembly( - With("public interface Item { }"), - With("public class Container { public Dictionary Items { get; } }"), - WithClass("[Export] public static Container Combine (Dictionary items) => default;")); + public class Class { [Export] public static Container Get () => default; } + """)); Execute(); Contains( """ - export namespace Class { - export function combine(items: Map): Container; - } - export interface Item { - } export interface Container { - readonly items: Map; + readonly list: system_collections_generic.IList; + readonly collection: system_collections_generic.ICollection; + readonly token: system_threading.CancellationToken; } """); } [Fact] - public void GeneratedForTypeWithReadOnlyDictionaryProperty () + public void GeneratedForCollections () { - AddAssembly( - With("public interface Item { }"), - With("public class Container { public IReadOnlyDictionary Items { get; } }"), - WithClass("[Export] public static Container Combine (IReadOnlyDictionary items) => default;")); - Execute(); - Contains( + AddAssembly(With( """ - export namespace Class { - export function combine(items: Map): Container; - } - export interface Item { - } - export interface Container { - readonly items: Map; - } - """); - } - - [Fact] - public void GeneratedForTypeWithCollectionProperty () - { - AddAssembly( - With("public interface Item { }"), - With("public class Container { public ICollection Items { get; } }"), - WithClass("[Export] public static Container Combine (ICollection items) => default;")); + public interface Item { } + public class Container + { + public Item[] Array { get; } + public Item[][] JaggedArray { get; } + public List List { get; } + public IReadOnlyList ReadOnlyList { get; } + public IReadOnlyList> NestedReadOnlyList { get; } + public IReadOnlyCollection ReadOnlyCollection { get; } + public Dictionary Dictionary { get; } + public IReadOnlyDictionary ReadOnlyDictionary { get; } + } + public class Class { [Export] public static Container Get () => default; } + """)); Execute(); Contains( """ - export namespace Class { - export function combine(items: Array): Container; - } - export interface Item { - } export interface Container { - readonly items: Array; + readonly array: Array; + readonly jaggedArray: Array>; + readonly list: Array; + readonly readOnlyList: Array; + readonly nestedReadOnlyList: Array>; + readonly readOnlyCollection: Array; + readonly dictionary: Map; + readonly readOnlyDictionary: Map; } """); } [Fact] - public void GeneratedForTypeWithReadOnlyCollectionProperty () + public void IntArraysTranslatedToRelatedTypes () { AddAssembly( - With("public interface Item { }"), - With("public class Container { public IReadOnlyCollection Items { get; } }"), - WithClass("[Export] public static Container Combine (IReadOnlyCollection items) => default;")); + WithClass("[Export] public static void Uint8 (byte[] foo) {}"), + WithClass("[Export] public static void Int8 (sbyte[] foo) {}"), + WithClass("[Export] public static void Uint16 (ushort[] foo) {}"), + WithClass("[Export] public static void Int16 (short[] foo) {}"), + WithClass("[Export] public static void Uint32 (uint[] foo) {}"), + WithClass("[Export] public static void Int32 (int[] foo) {}"), + WithClass("[Export] public static void BigInt64 (long[] foo) {}"), + WithClass("[Export] public static void Float32 (float[] foo) {}"), + WithClass("[Export] public static void Float64 (double[] foo) {}")); Execute(); - Contains( - """ - export namespace Class { - export function combine(items: Array): Container; - } - export interface Item { - } - export interface Container { - readonly items: Array; - } - """); + Contains("uint8(foo: Uint8Array): void"); + Contains("int8(foo: Int8Array): void"); + Contains("uint16(foo: Uint16Array): void"); + Contains("int16(foo: Int16Array): void"); + Contains("uint32(foo: Uint32Array): void"); + Contains("int32(foo: Int32Array): void"); + Contains("bigInt64(foo: BigInt64Array): void"); + Contains("float32(foo: Float32Array): void"); + Contains("float64(foo: Float64Array): void"); } [Fact] @@ -595,16 +492,17 @@ export namespace Class { public void GeneratedForGenericInterface () { AddAssembly( - With("public interface IGenericInterface { public T Value { get; set; } }"), - WithClass("[Export] public static IGenericInterface Method () => default;")); + With("public interface IGenericInterface { public T? X { get; set; } public T Y { get; } }"), + WithClass("[Export] public static IGenericInterface Method () => default;")); Execute(); Contains( """ export namespace Class { - export function method(): IGenericInterface; + export function method(): IGenericInterface; } export interface IGenericInterface { - value?: T; + x?: T; + readonly y: T; } """); } @@ -638,8 +536,8 @@ export namespace Class { export function method(p: GenericClass2): void; } export interface GenericClass2 { - key?: T1; - value?: T2; + key: T1; + value: T2; } """); } @@ -1216,6 +1114,45 @@ export enum Foo { """); } + [Fact] + public void NullityResolvedForUnconstrainedGenericParameters () + { + AddAssembly(With( + """ + public interface INotAnno { T A { get; } T B { get; } T? C { get; } } + public interface IAnno { T A { get; } T? B { get; } T? C { get; } } + public delegate void Notify(T msg); + #nullable disable + public interface IObv { T Value { get; } } + #nullable restore + public partial class Class + { + [Export] public static INotAnno A () => default; + [Export] public static IAnno B () => default; + [Export] public static Notify C () => default; + [Export] public static IObv D () => default; + } + """)); + Execute(); + Contains( + """ + export interface INotAnno { + readonly a: T; + readonly b: T; + readonly c?: T; + } + export interface IAnno { + readonly a: T; + readonly b?: T; + readonly c?: T; + } + export type Notify = (msg: T) => void; + export interface IObv { + readonly value: T; + } + """); + } + [Fact] public void DeeplyNestedEnumIsDeclared () { diff --git a/src/cs/Bootsharp.Publish.Test/GenerateJS/InspectionTest.cs b/src/cs/Bootsharp.Publish.Test/GenerateJS/InspectionTest.cs index 9e0fb828..4bd7d413 100644 --- a/src/cs/Bootsharp.Publish.Test/GenerateJS/InspectionTest.cs +++ b/src/cs/Bootsharp.Publish.Test/GenerateJS/InspectionTest.cs @@ -6,14 +6,14 @@ public class InspectionTest : GenerateJSTest public void AllAssembliesAreInspected () { AddAssembly("foo.dll", - WithClass("[Export] public static void Inv () {}") + WithClass("Foo", "[Export] public static void Inv () {}") ); Execute(); - Assert.Contains(Engine.Messages, w => w.Contains("foo")); + Assert.True(File.Exists($"{Task.BuildDirectory}/generated/modules/foo.g.mjs")); } [Fact] - public void WhenAssemblyInspectionFailsWarningIsLogged () + public void WarnsWhenAssemblyFailedToLoad () { AddAssembly("foo.dll", WithClass("[Export] public static void InvFoo () {}") @@ -26,6 +26,19 @@ public void WhenAssemblyInspectionFailsWarningIsLogged () Assert.Contains(Engine.Warnings, w => w.Contains("Failed to inspect 'foo.dll' assembly")); } + [Fact] + public void WarnsWhenMissingSpecializationPair () + { + AddAssembly(With( + """ + public class Custom; + [SpecializeExport(typeof(Custom))] + public sealed class CustomExport (Custom it) : SpecializedExport(it); + """)); + Execute(); + Assert.Contains(Engine.Warnings, w => w.Contains("missing the paired import")); + } + [Fact] public void IgnoresAssembliesNotPresentInBuildDirectory () { @@ -38,15 +51,15 @@ public void IgnoresAssembliesNotPresentInBuildDirectory () File.WriteAllText($"{buildDir}/{Path.GetFileName(file)}", File.ReadAllText(file)); AddAssembly("foo.dll", - WithClass("[Export] public static void InvFoo () {}") + WithClass("Foo", "[Export] public static void InvFoo () {}") ); AddAssembly("bar.dll", - WithClass("[Export] public static void InvBar () {}") + WithClass("Bar", "[Export] public static void InvBar () {}") ); Execute(); - Assert.Contains(Engine.Messages, w => w.Contains("foo")); - Assert.DoesNotContain(Engine.Messages, w => w.Contains("bar")); + Assert.True(File.Exists($"{Task.BuildDirectory}/generated/modules/foo.g.mjs")); + Assert.False(File.Exists($"{Task.BuildDirectory}/generated/modules/bar.g.mjs")); } [Fact] @@ -63,14 +76,14 @@ public void DoesntIgnoreAssembliesWhenLLVM () File.WriteAllText($"{buildDir}/{Path.GetFileName(file)}", File.ReadAllText(file)); AddAssembly("foo.dll", - WithClass("[Export] public static void InvFoo () {}") + WithClass("Foo", "[Export] public static void InvFoo () {}") ); AddAssembly("bar.dll", - WithClass("[Export] public static void InvBar () {}") + WithClass("Bar", "[Export] public static void InvBar () {}") ); Execute(); - Assert.Contains(Engine.Messages, w => w.Contains("foo")); - Assert.Contains(Engine.Messages, w => w.Contains("bar")); + Assert.True(File.Exists($"{Task.BuildDirectory}/generated/modules/foo.g.mjs")); + Assert.True(File.Exists($"{Task.BuildDirectory}/generated/modules/bar.g.mjs")); } } diff --git a/src/cs/Bootsharp.Publish.Test/Mock/MockCompiler.cs b/src/cs/Bootsharp.Publish.Test/Mock/MockCompiler.cs index f3ee2472..8d83f50e 100644 --- a/src/cs/Bootsharp.Publish.Test/Mock/MockCompiler.cs +++ b/src/cs/Bootsharp.Publish.Test/Mock/MockCompiler.cs @@ -8,6 +8,7 @@ public class MockCompiler private static readonly string[] defaultUsings = [ "System", "System.Collections.Generic", + "System.Threading", "System.Threading.Tasks", "Bootsharp" ]; diff --git a/src/cs/Bootsharp.Publish.Test/Mock/MockProject.cs b/src/cs/Bootsharp.Publish.Test/Mock/MockProject.cs index 798c0bf1..13e563bb 100644 --- a/src/cs/Bootsharp.Publish.Test/Mock/MockProject.cs +++ b/src/cs/Bootsharp.Publish.Test/Mock/MockProject.cs @@ -46,6 +46,7 @@ private void CreateBuildResources () { foreach (var path in GetReferencePaths()) File.Copy(path, Path.Combine(Root, Path.GetFileName(path)), true); + File.WriteAllText(Path.Combine(Root, "Bootsharp.Common.wasm"), ""); } private static string[] GetReferencePaths () diff --git a/src/cs/Bootsharp.Publish/Common/Global/GlobalInspection.cs b/src/cs/Bootsharp.Publish/Common/Global/GlobalInspection.cs index 3dbb5edc..c58e8835 100644 --- a/src/cs/Bootsharp.Publish/Common/Global/GlobalInspection.cs +++ b/src/cs/Bootsharp.Publish/Common/Global/GlobalInspection.cs @@ -8,7 +8,7 @@ namespace Bootsharp.Publish; internal static class GlobalInspection { - public static Preferences Pref => PreferencesResolver.Resolved.Value!; + public static Preferences Pref => PreferencesResolver.Resolved.Value ?? new(); private static readonly HashSet csKeywords = [ "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", @@ -51,10 +51,22 @@ public static bool IsUserAssembly (string assemblyName) => public static bool IsUserType (Type type) { + if (SpecializationResolver.IsSpecialized(type)) return true; + if (IsDelegate(type)) return true; if (type.IsArray) return false; return IsUserAssembly(type.Assembly.FullName!); } + public static bool IsAttribute (CustomAttributeData attr) where T : Attribute + { + return attr.AttributeType.FullName == typeof(T).FullName; + } + + public static T? GetAttributeArg (CustomAttributeData attr, int idx = 0) where T : class + { + return attr.ConstructorArguments.ElementAtOrDefault(idx).Value as T; + } + public static bool IsAutoProperty (PropertyInfo prop) { var backingFieldName = $"<{prop.Name}>k__BackingField"; diff --git a/src/cs/Bootsharp.Publish/Common/Global/GlobalType.cs b/src/cs/Bootsharp.Publish/Common/Global/GlobalType.cs index fda0458c..1bb70f4e 100644 --- a/src/cs/Bootsharp.Publish/Common/Global/GlobalType.cs +++ b/src/cs/Bootsharp.Publish/Common/Global/GlobalType.cs @@ -1,6 +1,7 @@ global using static Bootsharp.Publish.GlobalType; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; @@ -76,8 +77,10 @@ static bool IsDictionary (Type type) => type.GetGenericTypeDefinition().FullName == typeof(IReadOnlyDictionary<,>).FullName); } - public static NullabilityInfo GetNullity (PropertyInfo prop) => new NullabilityInfoContext().Create(prop); - public static NullabilityInfo GetNullity (ParameterInfo param) => new NullabilityInfoContext().Create(param); + public static NullabilityInfo GetNullity (PropertyInfo prop) => + FixNullity(new NullabilityInfoContext().Create(prop), prop.CustomAttributes, prop.DeclaringType); + public static NullabilityInfo GetNullity (ParameterInfo param) => + FixNullity(new NullabilityInfoContext().Create(param), param.CustomAttributes, param.Member); public static NullabilityInfo GetNullity (EventInfo evt) => new NullabilityInfoContext().Create(evt); public static NullabilityInfo GetNullity (EventInfo evt, ParameterInfo param) { @@ -98,8 +101,7 @@ public static bool IsNullable (Type type, NullabilityInfo? info, [NotNullWhen(tr { if (type.IsGenericType && type.Name.Contains("Nullable`") && type.GenericTypeArguments.Length == 1) value = type.GenericTypeArguments[0]; - else if (IsNullable(info) && (!type.IsGenericTypeParameter || IsUserType(type))) - value = type; + else if (IsNullable(info)) value = type; else value = null; return value != null; } @@ -110,10 +112,10 @@ public static string PrependIdArg (string args) return $"_id, {args}"; } - public static string BuildId (Type type, bool full = true, char separator = '_') + public static string BuildId (Type type, NullabilityInfo? nul = null, bool full = true, char separator = '_') { var sb = new StringBuilder(); - foreach (var c in BuildSyntax(type, full: full).Replace("global::", "")) + foreach (var c in BuildSyntax(type, nul, full: full).Replace("global::", "")) if (char.IsLetterOrDigit(c) || c == separator) sb.Append(c); else if (c == '.') sb.Append(separator); else if (c == '?') sb.Append("OrNull"); @@ -154,18 +156,18 @@ public static string TrimGeneric (string typeName) return Regex.Replace(typeName, @"`\d+(\[\[.*\]\])?", ""); } - public static string Export (ArgumentMeta arg) => Export(arg.Value, arg.Name); - public static string Export (ValueMeta value, string exp) => Export(value.Type, exp); - public static string Export (TypeMeta type, string exp) + public static string ExportCS (ArgumentMeta arg) => ExportCS(arg.Value, arg.Name); + public static string ExportCS (ValueMeta value, string exp) => ExportCS(value.Type, exp); + public static string ExportCS (TypeMeta type, string exp) { if (type is InstanceMeta) return $"Instances.Export({exp})"; if (type is SerializedMeta sm) return $"Serializer.Serialize({exp}, SerializerContext.{sm.Id})"; return exp; } - public static string Import (ArgumentMeta arg) => Import(arg.Value, arg.Name); - public static string Import (ValueMeta value, string exp) => Import(value.Type, exp); - public static string Import (TypeMeta type, string exp) + public static string ImportCS (ArgumentMeta arg) => ImportCS(arg.Value, arg.Name); + public static string ImportCS (ValueMeta value, string exp) => ImportCS(value.Type, exp); + public static string ImportCS (TypeMeta type, string exp) { if (type is InstanceMeta it) return $"Instances.Resolve<{it.Syntax}>({exp})"; if (type is SerializedMeta sm) return $"Serializer.Deserialize({exp}, SerializerContext.{sm.Id})"; @@ -191,4 +193,34 @@ public static string ImportJS (TypeMeta type, string exp) if (type is SerializedMeta sm) return $"serialize({exp}, $s.{sm.Id})"; return exp; } + + private static NullabilityInfo FixNullity (NullabilityInfo nul, + IEnumerable attrs, MemberInfo? scope) + { + // Nullity is conservatively reported as nullable for unconstrained generic parameters; this workaround + // resolves actual nullability via a compiler heuristic. https://github.com/dotnet/runtime/issues/115014 + + if (!nul.Type.IsGenericTypeParameter) return nul; + var state = IsAnnotated() ? NullabilityState.Nullable : NullabilityState.NotNull; + SetReadState(nul, state); + SetWriteState(nul, state); + return nul; + + bool IsAnnotated () + { + foreach (var attr in attrs) + if (attr.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute") + return (byte)attr.ConstructorArguments[0].Value! == 2; + for (var type = scope; type != null; type = type.DeclaringType) + foreach (var attr in type.CustomAttributes) + if (attr.AttributeType.FullName == "System.Runtime.CompilerServices.NullableContextAttribute") + return (byte)attr.ConstructorArguments[0].Value! == 2; + return false; + } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_ReadState")] + static extern void SetReadState (NullabilityInfo info, NullabilityState value); + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_WriteState")] + static extern void SetWriteState (NullabilityInfo info, NullabilityState value); + } } diff --git a/src/cs/Bootsharp.Publish/Common/Inspection/InspectionReporter.cs b/src/cs/Bootsharp.Publish/Common/Inspection/InspectionReporter.cs deleted file mode 100644 index 54b8d86b..00000000 --- a/src/cs/Bootsharp.Publish/Common/Inspection/InspectionReporter.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -namespace Bootsharp.Publish; - -internal sealed class InspectionReporter (TaskLoggingHelper logger) -{ - public void Report (SolutionInspection spec) - { - logger.LogMessage(MessageImportance.Normal, "Bootsharp assembly inspection result:"); - logger.LogMessage(MessageImportance.Normal, Fmt("Discovered assemblies:", - Fmt(GetDiscoveredAssemblies(spec)))); - logger.LogMessage(MessageImportance.Normal, Fmt("Discovered interop members:", - Fmt(GetDiscoveredMembers(spec)))); - foreach (var warning in spec.Warnings) - logger.LogWarning(warning); - } - - private HashSet GetDiscoveredAssemblies (SolutionInspection spec) - { - return spec.Types.OfType().SelectMany(s => s.Members) - .Select(m => m.Info.DeclaringType!.Assembly.GetName().Name!) - .ToHashSet(); - } - - private HashSet GetDiscoveredMembers (SolutionInspection spec) - { - return spec.Types.OfType().SelectMany(s => s.Members) - .Select(m => m.ToString()) - .ToHashSet(); - } -} diff --git a/src/cs/Bootsharp.Publish/Common/Inspection/Meta/SurfaceMeta.cs b/src/cs/Bootsharp.Publish/Common/Inspection/Meta/SurfaceMeta.cs index 30cdaaf7..4e0d723e 100644 --- a/src/cs/Bootsharp.Publish/Common/Inspection/Meta/SurfaceMeta.cs +++ b/src/cs/Bootsharp.Publish/Common/Inspection/Meta/SurfaceMeta.cs @@ -1,5 +1,3 @@ -using System.Text; - namespace Bootsharp.Publish; /// @@ -17,7 +15,7 @@ internal abstract record SurfaceMeta (Type Clr) : TypeMeta(Clr) /// Describes an interop surface encompassing static interop members specified via class-level /// or attributes. /// -internal record StaticMeta (Type Clr) : SurfaceMeta(Clr); +internal sealed record StaticMeta (Type Clr) : SurfaceMeta(Clr); /// /// Describes an interop surface that uses a generated proxy to bind with the source. @@ -38,7 +36,7 @@ internal abstract record ProxyMeta (Type Clr) : SurfaceMeta(Clr) /// Describes an interop surface specified via assembly-level or /// attributes. /// -internal record ModuleMeta (Type Clr) : ProxyMeta(Clr); +internal sealed record ModuleMeta (Type Clr) : ProxyMeta(Clr); /// /// Describes an interop surface projected from an instanced type. @@ -70,11 +68,10 @@ internal sealed record DelegateMeta (Type Clr) : InstanceMeta(Clr) /// Describes the "Invoke" method of the delegate. /// public MethodMeta Invoker => (MethodMeta)Members.First(); - protected override bool PrintMembers (StringBuilder builder) => base.PrintMembers(builder); // w/a C# bug } /// -/// Describes the generated proxy used by . +/// Describes a generated proxy used by . /// public record SurfaceProxy { @@ -87,3 +84,23 @@ public record SurfaceProxy /// public required string Syntax { get; init; } } + +/// +/// Describes a proxy authored by user for a type specialized with +/// and attributes. +/// +internal sealed record SpecializedProxy : SurfaceProxy +{ + /// + /// The import proxy type annotated with . + /// + public required TypeMeta Import { get; init; } + /// + /// The export proxy type annotated with . + /// + public required TypeMeta Export { get; init; } + /// + public string? JS { get; init; } + /// + public string? Decl { get; init; } +} diff --git a/src/cs/Bootsharp.Publish/Common/Inspection/Meta/TypeMeta.cs b/src/cs/Bootsharp.Publish/Common/Inspection/Meta/TypeMeta.cs index 9047856d..cf23369e 100644 --- a/src/cs/Bootsharp.Publish/Common/Inspection/Meta/TypeMeta.cs +++ b/src/cs/Bootsharp.Publish/Common/Inspection/Meta/TypeMeta.cs @@ -35,6 +35,6 @@ private static string BuildModule (Type clr) private static string BuildNode (Type clr) { - return WithPref(Pref.Name, clr.Name, BuildId(clr, false, '.')); + return WithPref(Pref.Name, clr.Name, BuildId(clr, full: false, separator: '.')); } } diff --git a/src/cs/Bootsharp.Publish/Common/Inspection/SerializedInspector.cs b/src/cs/Bootsharp.Publish/Common/Inspection/SerializedInspector.cs index dbf4d327..b2618e6f 100644 --- a/src/cs/Bootsharp.Publish/Common/Inspection/SerializedInspector.cs +++ b/src/cs/Bootsharp.Publish/Common/Inspection/SerializedInspector.cs @@ -56,9 +56,9 @@ private SerializedMeta Build (Type type) type.IsEnum ? new SerializedEnumMeta(type) : IsPrimitive(type) ? new SerializedPrimitiveMeta(type) : type.IsArray ? new SerializedArrayMeta(type, Build(type.GetElementType()!)) : + inspectInstanced(type, ik, null) is { } it ? new SerializedInstanceMeta(it) : IsList(type, out var element) ? new SerializedListMeta(type, Build(element)) : IsDictionary(type, out var k, out var v) ? new SerializedDictionaryMeta(type, Build(k), Build(v)) : - inspectInstanced(type, ik) is { } it ? new SerializedInstanceMeta(it) : BuildObject(type); cycle.Remove(type); return meta; diff --git a/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspection.cs b/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspection.cs index f1f224fe..8fd605e8 100644 --- a/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspection.cs +++ b/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspection.cs @@ -21,10 +21,6 @@ internal sealed class SolutionInspection (MetadataLoadContext ctx) : IDisposable /// C# XML documentation for the inspected assemblies. /// public required IReadOnlyCollection Docs { get; init; } - /// - /// Warnings logged while inspecting the solution. - /// - public required IReadOnlyCollection Warnings { get; init; } public void Dispose () => ctx.Dispose(); } diff --git a/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspector.cs b/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspector.cs index a1e1c1ac..04721df0 100644 --- a/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspector.cs +++ b/src/cs/Bootsharp.Publish/Common/Inspection/SolutionInspector.cs @@ -1,13 +1,14 @@ using System.Reflection; using System.Xml.Linq; +using Microsoft.Build.Utilities; namespace Bootsharp.Publish; -internal sealed class SolutionInspector +internal sealed class SolutionInspector (string entryAssemblyName, TaskLoggingHelper logger) { + private readonly List asses = []; private readonly TypeInspector types = new(); private readonly List docs = []; - private readonly List warns = []; /// /// Inspects specified solution assembly paths in the output directory. @@ -17,35 +18,35 @@ internal sealed class SolutionInspector public SolutionInspection Inspect (string directory, IEnumerable paths) { var ctx = CreateLoadContext(directory); - foreach (var assemblyPath in paths) - try { InspectAssembly(assemblyPath, ctx); } - catch (Exception e) { AddSkippedWarning(assemblyPath, e); } - return new(ctx) { - Types = types.Collect(), - Docs = docs.ToArray(), - Warnings = warns.ToArray() - }; + foreach (var pth in paths) LoadAssembly(pth, ctx); + foreach (var ass in asses) ResolvePreferences(ass); + foreach (var ass in asses) types.Inspect(ass); + foreach (var ass in asses) InspectDocs(ass); + return new(ctx) { Types = types.Collect(), Docs = docs.ToArray() }; } - private void InspectAssembly (string assemblyPath, MetadataLoadContext ctx) + private void LoadAssembly (string path, MetadataLoadContext ctx) { - var name = Path.GetFileNameWithoutExtension(assemblyPath); - if (!IsUserAssembly(name)) return; - types.Inspect(ctx.LoadFromAssemblyPath(assemblyPath)); - InspectDocs(assemblyPath, name); + if (!IsUserAssembly(Path.GetFileNameWithoutExtension(path))) return; + try { asses.Add(ctx.LoadFromAssemblyPath(path)); } + catch (Exception e) { Warn(path, e); } } - private void AddSkippedWarning (string assemblyPath, Exception exception) + private void ResolvePreferences (Assembly ass) { - var fileName = Path.GetFileName(assemblyPath); - var message = $"Failed to inspect '{fileName}' assembly; " + - $"affected interop members won't be available in JavaScript. Error: {exception.Message}"; - warns.Add(message); + if (ass.GetName().Name == Path.GetFileNameWithoutExtension(entryAssemblyName)) + PreferencesResolver.Resolve(ass); + try { SpecializationResolver.Resolve(ass); } + catch (Exception e) { Warn(ass.Location, e); } } - private void InspectDocs (string assemblyPath, string assemblyName) + private void InspectDocs (Assembly ass) { - var xmlPath = Path.ChangeExtension(assemblyPath, ".xml"); - if (File.Exists(xmlPath)) docs.Add(new(assemblyName, XDocument.Load(xmlPath))); + var xmlPath = Path.ChangeExtension(ass.Location, ".xml"); + var name = Path.GetFileNameWithoutExtension(ass.Location); + if (File.Exists(xmlPath)) docs.Add(new(name, XDocument.Load(xmlPath))); } + + private void Warn (string path, Exception ex) => logger.LogWarning( + $"Failed to inspect '{Path.GetFileName(path)}' assembly. Error: {ex}"); } diff --git a/src/cs/Bootsharp.Publish/Common/Inspection/TypeInspector.cs b/src/cs/Bootsharp.Publish/Common/Inspection/TypeInspector.cs index 15e36d30..180be470 100644 --- a/src/cs/Bootsharp.Publish/Common/Inspection/TypeInspector.cs +++ b/src/cs/Bootsharp.Publish/Common/Inspection/TypeInspector.cs @@ -4,9 +4,9 @@ namespace Bootsharp.Publish; internal sealed class TypeInspector { - internal delegate InstanceMeta? InspectInstanced (Type type, InteropKind ik); + internal delegate InstanceMeta? InspectInstanced (Type type, InteropKind ik, NullabilityInfo? nul); - private readonly Dictionary<(Type, InteropKind), InstanceMeta> its = []; + private readonly Dictionary<(string Syntax, InteropKind IK), InstanceMeta> its = []; private readonly Dictionary crawled = []; private readonly HashSet inspectedModuleTypes = []; private readonly List surfaces = []; @@ -59,77 +59,91 @@ public IReadOnlyCollection Collect () { if (!inspectedModuleTypes.Add(type) || IsStatic(type) || (ik == InteropKind.Import && !type.IsInterface)) return null; - var md = new ModuleMeta(type) { - IK = ik, - Proxy = BuildProxy(type, ik), - Members = new List() - }; - return InspectMembers(md, ik); + var proxy = BuildProxy(type, BuildId(type), ik); + var members = new List(); + return InspectMembers(new ModuleMeta(type) { IK = ik, Proxy = proxy, Members = members }, ik); } - private InstanceMeta? InspectInstance (Type type, InteropKind ik) + private InstanceMeta? InspectInstance (Type type, InteropKind ik, NullabilityInfo? nul) { - if (its.TryGetValue((type, ik), out var it)) return it; - if (IsTaskWithResult(type, out var result)) return InspectInstance(result, ik); - if (IsDelegate(type)) return its[(type, ik)] = InspectDelegate(type, ik); + var id = BuildId(type, type.IsGenericType ? nul : null); // nullity only matter for generic args + var key = (id, ik); + if (its.TryGetValue(key, out var it)) return it; + if (IsTaskWithResult(type, out var result)) return InspectInstance(result, ik, nul!.GenericTypeArguments[0]); if (!IsInstanced(type)) return null; - if (ik == InteropKind.Import && !type.IsInterface) // likely passing back an exported instance — reclassify - return InspectInstance(type, InteropKind.Export)!; - var special = type.GetEvents().Length > 0; // instances with events need specialized registrars to un-/sub - it = its[(type, ik)] = new(type) { + if (IsDelegate(type)) return its[key] = InspectDelegate(type, ik); + if (!SpecializationResolver.IsSpecialized(type, out var sp) && ik == InteropKind.Import && !type.IsInterface) + return InspectInstance(type, InteropKind.Export, nul)!; // likely passing back an exported instance + return InspectMembers(its[key] = new(type) { IK = ik, - Proxy = BuildProxy(type, ik), + Proxy = BuildProxy(type, id, ik, sp), Members = new List(), - Exporter = special && ik == InteropKind.Export ? "Export" : null, // discriminated by types on C# - Importer = special && ik == InteropKind.Import ? $"import_{BuildId(type)}" : null, - }; - return InspectMembers(it, ik); + Exporter = ResolveExporter(), + Importer = ResolveImporter(), + }, ik); static bool IsInstanced (Type type) { // Instanced types are mutable user types that are passed by reference when crossing the // interop boundary (as opposed to serialized immutable types, which are copied by value). if (!IsUserType(type)) return false; - if (type.IsInterface) return true; + if (type.IsInterface || SpecializationResolver.IsSpecialized(type)) return true; return type.IsClass && !IsStatic(type) && !IsRecord(type); // records are immutable by convention } + + string? ResolveExporter () + { + if (ik != InteropKind.Export) return null; + if (sp != null || type.GetEvents().Length > 0) return "Export"; + return null; + } + + string? ResolveImporter () + { + if (ik != InteropKind.Import) return null; + if ((sp?.For(type, ik) ?? type).GetEvents().Length > 0) return $"import_{id}"; + return null; + } } private DelegateMeta InspectDelegate (Type type, InteropKind ik) { var members = new List(); - var del = new DelegateMeta(type) { IK = ik, Proxy = BuildProxy(type, ik), Members = members }; + var proxy = BuildProxy(type, BuildId(type), ik); + var del = new DelegateMeta(type) { IK = ik, Proxy = proxy, Members = members }; members.Add(InspectMethod(type.GetMethod("Invoke")!, ik, del)); return del; } - private T InspectMembers (T surf, InteropKind ik) where T : SurfaceMeta + private T InspectMembers (T surf, InteropKind ik) where T : ProxyMeta { var members = (ICollection)surf.Members; - foreach (var evt in surf.Clr.GetEvents()) + var sp = surf.Proxy as SpecializedProxy; + var clr = sp?.Import.Clr ?? surf.Clr; + foreach (var evt in clr.GetEvents()) members.Add(InspectEvent(evt, ik, surf)); - foreach (var prop in surf.Clr.GetProperties()) + foreach (var prop in clr.GetProperties()) if (ShouldInspectProperty(prop)) members.Add(InspectProperty(prop, ik, surf)); - foreach (var method in surf.Clr.GetMethods()) + foreach (var method in clr.GetMethods()) if (ShouldInspectMethod(method)) members.Add(InspectMethod(method, ik, surf)); return surf; - static bool ShouldInspectProperty (PropertyInfo prop) + bool ShouldInspectProperty (PropertyInfo prop) { if (prop.GetIndexParameters().Length != 0) return false; - if (prop.DeclaringType!.IsInterface) + if (sp != null || prop.DeclaringType!.IsInterface) return prop.GetMethod?.IsAbstract == true || prop.SetMethod?.IsAbstract == true; return true; } - static bool ShouldInspectMethod (MethodInfo method) + bool ShouldInspectMethod (MethodInfo method) { if (method.IsSpecialName) return false; if (method.DeclaringType!.FullName == typeof(object).FullName) return false; - if (method.DeclaringType!.IsInterface) return method.IsAbstract; + if (sp != null || method.DeclaringType!.IsInterface) return method.IsAbstract; return !method.IsStatic; } } @@ -165,30 +179,38 @@ static bool ShouldInspectMethod (MethodInfo method) Async = IsTaskLike(method.ReturnParameter.ParameterType) }; - private ArgumentMeta InspectArg (ParameterInfo param, NullabilityInfo nil, InteropKind ik) => new(param) { + private ArgumentMeta InspectArg (ParameterInfo param, NullabilityInfo nul, InteropKind ik) => new(param) { Name = BuildCSName(param.Name!), JSName = BuildJSName(param.Name!), - Value = InspectValue(param.ParameterType, nil, ik) + Value = InspectValue(param.ParameterType, nul, ik) }; - private ValueMeta InspectValue (Type type, NullabilityInfo nil, InteropKind ik) => new() { - Type = InspectType(type, ik), - TypeSyntax = BuildSyntax(type, nil), - Nullable = IsNullable(type, nil) + private ValueMeta InspectValue (Type type, NullabilityInfo nul, InteropKind ik) => new() { + Type = InspectType(type, ik, nul), + TypeSyntax = BuildSyntax(type, nul), + Nullable = IsNullable(type, nul) }; - private TypeMeta InspectType (Type type, InteropKind ik) + private TypeMeta InspectType (Type type, InteropKind ik, NullabilityInfo? nul = null) { for (var clr = type; clr.IsNested && IsUserType(clr.DeclaringType!); clr = clr.DeclaringType!) crawled.TryAdd(clr.DeclaringType!, new(clr.DeclaringType!)); - return InspectInstance(type, ik) ?? srd.Inspect(type, ik) ?? new TypeMeta(type); + return InspectInstance(type, ik, nul) ?? srd.Inspect(type, ik) ?? new TypeMeta(type); } - private SurfaceProxy BuildProxy (Type type, InteropKind ik) + private SurfaceProxy BuildProxy (Type type, string typeId, InteropKind ik, Specialization? sp = null) { - var id = "JS_" + (ik == InteropKind.Export ? "Export_" : "Import_") + BuildId(type); + var id = "JS_" + (ik == InteropKind.Export ? "Export_" : "Import_") + typeId; var stx = $"global::Bootsharp.Generated.{id}"; - return new SurfaceProxy { Id = id, Syntax = stx }; + if (sp == null) return new() { Id = id, Syntax = stx }; + return new SpecializedProxy { + Id = id, + Syntax = stx, + Import = new(sp.For(type, InteropKind.Import)), + Export = new(sp.For(type, InteropKind.Export)), + JS = sp.JS, + Decl = sp.Decl + }; } private InteropKind? ResolveIK (MemberInfo info) diff --git a/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs b/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs index 82395635..a6f0f8fc 100644 --- a/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs +++ b/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs @@ -9,11 +9,8 @@ internal static class PreferencesResolver /// internal static AsyncLocal Resolved { get; } = new(); - public static void Resolve (string entryAssemblyName, string outDir) + public static void Resolve (Assembly assembly) { - using var ctx = CreateLoadContext(outDir); - var assemblyPath = Path.Combine(outDir, entryAssemblyName); - var assembly = ctx.LoadFromAssemblyPath(assemblyPath); var attribute = FindPreferencesAttribute(assembly); Resolved.Value = CreatePreferences(attribute); } @@ -21,7 +18,7 @@ public static void Resolve (string entryAssemblyName, string outDir) private static CustomAttributeData? FindPreferencesAttribute (Assembly assembly) { foreach (var attr in assembly.CustomAttributes) - if (attr.AttributeType.FullName == typeof(PreferencesAttribute).FullName) + if (IsAttribute(attr)) return attr; return null; } diff --git a/src/cs/Bootsharp.Publish/Common/Preferences/Specialization.cs b/src/cs/Bootsharp.Publish/Common/Preferences/Specialization.cs new file mode 100644 index 00000000..601dd240 --- /dev/null +++ b/src/cs/Bootsharp.Publish/Common/Preferences/Specialization.cs @@ -0,0 +1,16 @@ +namespace Bootsharp.Publish; + +internal sealed record Specialization +{ + public required Type Import { get; init; } + public required Type Export { get; init; } + public string? JS { get; init; } + public string? Decl { get; init; } + + public Type For (Type specialized, InteropKind ik) + { + var specializer = ik == InteropKind.Import ? Import : Export; + if (!specializer.IsGenericTypeDefinition) return specializer; + return specializer.MakeGenericType(specialized.GenericTypeArguments); + } +} diff --git a/src/cs/Bootsharp.Publish/Common/Preferences/SpecializationResolver.cs b/src/cs/Bootsharp.Publish/Common/Preferences/SpecializationResolver.cs new file mode 100644 index 00000000..271f744c --- /dev/null +++ b/src/cs/Bootsharp.Publish/Common/Preferences/SpecializationResolver.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Bootsharp.Publish; + +internal static class SpecializationResolver +{ + private static AsyncLocal> async { get; } = new(); + private static Dictionary map => async.Value ??= []; + + public static void Resolve (Assembly ass) + { + var imports = new Dictionary(); + var exports = new List<(Type Clr, Type Type)>(); + foreach (var type in ass.GetExportedTypes()) + foreach (var attr in type.CustomAttributes) + if (IsAttribute(attr)) + imports[GetAttributeArg(attr)!] = + (type, GetAttributeArg(attr, 1), GetAttributeArg(attr, 2)); + else if (IsAttribute(attr)) + exports.Add((GetAttributeArg(attr)!, type)); + foreach (var (clr, export) in exports) + map[clr] = imports.TryGetValue(clr, out var import) + ? new() { Import = import.Type, Export = export, JS = import.JS, Decl = import.Decl } + : throw new Error($"Specialized export '{export.FullName}' is missing the paired import."); + } + + public static bool IsSpecialized (Type type) => IsSpecialized(type, out _); + public static bool IsSpecialized (Type type, [NotNullWhen(true)] out Specialization? sp) + { + var key = type.IsGenericType ? type.GetGenericTypeDefinition() : type; + return (sp = map.GetValueOrDefault(key)) != null; + } +} diff --git a/src/cs/Bootsharp.Publish/GenerateCS/CSInstanceGenerator.cs b/src/cs/Bootsharp.Publish/GenerateCS/CSInstanceGenerator.cs index 31f055c4..7b69208d 100644 --- a/src/cs/Bootsharp.Publish/GenerateCS/CSInstanceGenerator.cs +++ b/src/cs/Bootsharp.Publish/GenerateCS/CSInstanceGenerator.cs @@ -22,7 +22,7 @@ public static partial class Instances { internal static int Export (T? it, Bootsharp.Instances.ExportCallback? cb = null) where T : class => Bootsharp.Instances.Export(it, cb); internal static T Exported (int id) where T : class => Bootsharp.Instances.Exported(id); - internal static T Resolve (int id) where T : class => Bootsharp.Instances.Resolve(id); + internal static T? Resolve (int id) => Bootsharp.Instances.Resolve(id); internal static void DisposeImported (int id) { @@ -56,9 +56,11 @@ private static string EmitImporter (InstanceMeta it) private static string EmitExporter (InstanceMeta it) { var evt = it.Members.OfType().ToArray(); + var arg = it.Proxy is SpecializedProxy sp ? $"new {sp.Export.Syntax}(it)" : "it"; + if (evt.Length == 0) return $"internal static int {it.Exporter} ({it.Syntax} it) => Export({arg});"; return $$""" - internal static int {{it.Exporter}} ({{it.Syntax}} it) => Export(it, static (_id, it) => { + internal static int {{it.Exporter}} ({{it.Syntax}} it) => Export({{arg}}, static (_id, it) => { {{Fmt(evt.Select(e => $"it.{e.Name} += Handle{e.Name};"))}} return () => { {{Fmt(evt.Select(e => $"it.{e.Name} -= Handle{e.Name};"), 2)}} @@ -66,8 +68,8 @@ private static string EmitExporter (InstanceMeta it) {{Fmt(evt.Select(e => { var args = string.Join(", ", e.Args.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); - var invArgs = PrependIdArg(string.Join(", ", e.Args.Select(Export))); - var name = $"{it.Id}_Broadcast{e.Name}_Serialized"; + var invArgs = PrependIdArg(string.Join(", ", e.Args.Select(ExportCS))); + var name = $"{it.Proxy.Id}_Broadcast{e.Name}_Serialized"; return $"void Handle{e.Name} ({args}) => Interop.{name}({invArgs});"; }))}} }); @@ -76,12 +78,23 @@ private static string EmitExporter (InstanceMeta it) private string EmitProxy (InstanceMeta it) => (this.it = it) switch { DelegateMeta del => EmitDelegateProxy(del), + { Proxy: SpecializedProxy sp } => EmitSpecializedProxy(it, sp), _ => EmitOpaqueProxy(it) }; private string EmitOpaqueProxy (InstanceMeta it) => $$""" - public class {{it.Proxy.Id}} (int id) : global::Bootsharp.JSProxy(id), {{it.Syntax}} + public sealed class {{it.Proxy.Id}} (int id) : global::Bootsharp.JSProxy(id), {{it.Syntax}} + { + ~{{it.Proxy.Id}}() => Instances.DisposeImported(_id); + + {{Fmt(it.Members.Select(EmitMemberImport))}} + } + """; + + private string EmitSpecializedProxy (InstanceMeta it, SpecializedProxy sp) => + $$""" + public sealed class {{it.Proxy.Id}} (int id) : {{sp.Import.Syntax}}(id) { ~{{it.Proxy.Id}}() => Instances.DisposeImported(_id); @@ -116,8 +129,9 @@ private string EmitEventImport (EventMeta evt) { var args = string.Join(", ", evt.Args.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); var callArgs = string.Join(", ", evt.Args.Select(a => a.Name)); + var mod = it.Proxy is SpecializedProxy ? "public override" : "public"; return Fmt(0, - $"public event {evt.TypeSyntax} {evt.Name};", + $"{mod} event {evt.TypeSyntax} {evt.Name};", $"internal void Invoke{evt.Name} ({args}) => {evt.Name}?.Invoke({callArgs});" ); } @@ -127,9 +141,12 @@ private string EmitPropertyImport (PropertyMeta prop) var space = $"global::Bootsharp.Generated.Interop.{it.Proxy.Id}"; var getArgs = PrependIdArg(""); var setArgs = PrependIdArg("value"); + var head = it.Proxy is SpecializedProxy + ? $"public override {prop.TypeSyntax} {prop.Name}" + : $"{prop.TypeSyntax} {it.Syntax}.{prop.Name}"; return $$""" - {{prop.TypeSyntax}} {{it.Syntax}}.{{prop.Name}} + {{head}} { {{Fmt( prop.CanGet ? $"get => {space}_Get{prop.Name}({getArgs});" : null, @@ -144,7 +161,9 @@ private string EmitMethodImport (MethodMeta method) var args = string.Join(", ", method.Args.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); var callArgs = PrependIdArg(string.Join(", ", method.Args.Select(a => a.Name))); var name = $"{it.Proxy.Id}_{method.Name}"; - return $"{method.Return.TypeSyntax} {it.Syntax}.{method.Name} ({args}) => " + - $"global::Bootsharp.Generated.Interop.{name}({callArgs});"; + var head = it.Proxy is SpecializedProxy + ? $"public override {method.Return.TypeSyntax} {method.Name}" + : $"{method.Return.TypeSyntax} {it.Syntax}.{method.Name}"; + return $"{head} ({args}) => global::Bootsharp.Generated.Interop.{name}({callArgs});"; } } diff --git a/src/cs/Bootsharp.Publish/GenerateCS/CSInteropGenerator.cs b/src/cs/Bootsharp.Publish/GenerateCS/CSInteropGenerator.cs index 0d8139e6..cba39481 100644 --- a/src/cs/Bootsharp.Publish/GenerateCS/CSInteropGenerator.cs +++ b/src/cs/Bootsharp.Publish/GenerateCS/CSInteropGenerator.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace Bootsharp.Publish; /// @@ -8,15 +6,11 @@ namespace Bootsharp.Publish; /// internal sealed class CSInteropGenerator { - [MemberNotNullWhen(true, nameof(it))] private bool isIt => srf is InstanceMeta; - [MemberNotNullWhen(true, nameof(md))] private bool isMd => srf is ModuleMeta; - private string id = null!, stx = null!; private SurfaceMeta srf = null!; - private InstanceMeta? it => srf as InstanceMeta; - private ModuleMeta? md => srf as ModuleMeta; + private string id = null!, stx = null!, key = null!; public string Generate (IReadOnlyCollection srf) => $$""" @@ -49,11 +43,11 @@ private static IEnumerable EmitInitializers (SurfaceMeta srf) yield return $"{stx}.{evt.Name} += Handle_{id}_{evt.Name};"; if (srf is not StaticMeta) yield break; foreach (var mem in srf.Members.OfType().Where(m => m.IK == InteropKind.Import)) - yield return $"{srf.Syntax}.Bootsharp_{mem.Name} = &{srf.Id}_{mem.Name};"; + yield return $"{stx}.Bootsharp_{mem.Name} = &{id}_{mem.Name};"; foreach (var p in srf.Members.OfType().Where(p => p.IK == InteropKind.Import)) { - if (p.CanGet) yield return $"{srf.Syntax}.Bootsharp_Get{p.Name} = &{srf.Id}_Get{p.Name};"; - if (p.CanSet) yield return $"{srf.Syntax}.Bootsharp_Set{p.Name} = &{srf.Id}_Set{p.Name};"; + if (p.CanGet) yield return $"{stx}.Bootsharp_Get{p.Name} = &{id}_Get{p.Name};"; + if (p.CanSet) yield return $"{stx}.Bootsharp_Set{p.Name} = &{id}_Set{p.Name};"; } } @@ -62,6 +56,9 @@ private static IEnumerable EmitInitializers (SurfaceMeta srf) this.srf = srf; id = (srf as ProxyMeta)?.Proxy.Id ?? srf.Id; stx = (srf as ProxyMeta)?.Proxy.Syntax ?? srf.Syntax; + key = srf is InstanceMeta { Proxy: SpecializedProxy sp } it + ? (it.IK == InteropKind.Import ? sp.Import.Syntax : sp.Export.Syntax) + : srf.Syntax; return member switch { EventMeta { IK: InteropKind.Export } e => EmitEventExport(e), EventMeta { IK: InteropKind.Import } e => EmitEventImport(e), @@ -75,7 +72,7 @@ private static IEnumerable EmitInitializers (SurfaceMeta srf) private IEnumerable EmitEventExport (EventMeta evt) { var attr = $"""[JSImport("{srf.JSNode}.broadcast{evt.Name}Serialized", "{srf.JSModule}")] """; - var name = $"{srf.Id}_Broadcast{evt.Name}_Serialized"; + var name = $"{id}_Broadcast{evt.Name}_Serialized"; var args = string.Join(", ", evt.Args.Select(a => BuildParameter(a.Value, a.Name))); if (isIt) args = $"int {PrependIdArg(args)}"; yield return $"{attr}internal static partial void {name} ({args});"; @@ -83,7 +80,7 @@ private static IEnumerable EmitInitializers (SurfaceMeta srf) if (isIt) yield break; // instance event handlers are emitted by InstanceGenerator var handler = $"Handle_{id}_{evt.Name}"; var sigArgs = string.Join(", ", evt.Args.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); - var invArgs = string.Join(", ", evt.Args.Select(Export)); + var invArgs = string.Join(", ", evt.Args.Select(ExportCS)); yield return $"private static void {handler} ({sigArgs}) => {name}({invArgs});"; } @@ -92,10 +89,10 @@ private IEnumerable EmitEventImport (EventMeta evt) var name = $"{id}_Invoke{evt.Name}"; var args = string.Join(", ", evt.Args.Select(a => BuildParameter(a.Value, a.Name))); if (isIt) args = $"int {PrependIdArg(args)}"; - var invName = isIt ? $"(({it.Proxy.Syntax})Instances.Resolve<{it.Syntax}>(_id)).Invoke{evt.Name}" - : isMd ? $"(({md.Proxy.Syntax})Modules.Imports[typeof({md.Syntax})].Instance).Invoke{evt.Name}" - : $"{srf.Syntax}.Bootsharp_Invoke_{evt.Name}"; - var invArgs = string.Join(", ", evt.Args.Select(Import)); + var invName = isIt ? $"(({stx})Instances.Resolve<{key}>(_id)).Invoke{evt.Name}" + : isMd ? $"(({stx})Modules.Imports[typeof({key})].Instance).Invoke{evt.Name}" + : $"{stx}.Bootsharp_Invoke_{evt.Name}"; + var invArgs = string.Join(", ", evt.Args.Select(ImportCS)); yield return $"[JSExport] internal static void {name} ({args}) => {invName}({invArgs});"; } @@ -106,7 +103,7 @@ private IEnumerable EmitPropertyExport (PropertyMeta prop) var attr = $"[JSExport] {MarshalAmbiguous(prop.Get, true)}"; var name = $"{id}_Get{prop.Name}"; var args = isIt ? "int _id" : ""; - var body = Export(prop.Get, isIt ? $"Instances.Exported<{it.Syntax}>(_id).{prop.Name}" + var body = ExportCS(prop.Get, isIt ? $"Instances.Exported<{key}>(_id).{prop.Name}" : isMd ? $"{stx}.Get{prop.Name}()" : $"{stx}.{prop.Name}"); yield return $"{attr}internal static {BuildValueSyntax(prop.Get)} {name} ({args}) => {body};"; @@ -116,8 +113,8 @@ private IEnumerable EmitPropertyExport (PropertyMeta prop) var name = $"{id}_Set{prop.Name}"; var args = BuildParameter(prop.Set, "value"); if (isIt) args = $"int {PrependIdArg(args)}"; - var value = Import(prop.Set, "value"); - var body = isIt ? $"Instances.Exported<{it.Syntax}>(_id).{prop.Name} = {value}" + var value = ImportCS(prop.Set, "value"); + var body = isIt ? $"Instances.Exported<{key}>(_id).{prop.Name} = {value}" : isMd ? $"{stx}.Set{prop.Name}({value})" : $"{stx}.{prop.Name} = {value}"; yield return $"[JSExport] internal static void {name} ({args}) => {body};"; @@ -130,18 +127,18 @@ private IEnumerable EmitPropertyImport (PropertyMeta prop) { var endpoint = $"""("{srf.JSNode}.get{prop.Name}Serialized", "{srf.JSModule}")"""; var attr = $"[JSImport{endpoint}] {MarshalAmbiguous(prop.Get, true)}"; - var srdName = $"{srf.Id}_Get{prop.Name}_Serialized"; + var srdName = $"{id}_Get{prop.Name}_Serialized"; var args = isIt ? "int _id" : ""; yield return $"{attr}internal static partial {BuildValueSyntax(prop.Get)} {srdName} ({args});"; var name = $"{id}_Get{prop.Name}"; - var body = Import(prop.Get, isIt ? $"{srdName}(_id)" : $"{srdName}()"); + var body = ImportCS(prop.Get, isIt ? $"{srdName}(_id)" : $"{srdName}()"); yield return $"public static {prop.Get.TypeSyntax} {name}({args}) => {body};"; } if (prop.CanSet) { var attr = $"""[JSImport("{srf.JSNode}.set{prop.Name}Serialized", "{srf.JSModule}")]"""; - var srdName = $"{srf.Id}_Set{prop.Name}_Serialized"; + var srdName = $"{id}_Set{prop.Name}_Serialized"; var srdArgs = BuildParameter(prop.Set, "value"); if (isIt) srdArgs = $"int {PrependIdArg(srdArgs)}"; yield return $"{attr} internal static partial void {srdName} ({srdArgs});"; @@ -149,7 +146,7 @@ private IEnumerable EmitPropertyImport (PropertyMeta prop) var name = $"{id}_Set{prop.Name}"; var args = $"{prop.Set.TypeSyntax} value"; if (isIt) args = $"int {PrependIdArg(args)}"; - var value = Export(prop.Set, "value"); + var value = ExportCS(prop.Set, "value"); var body = isIt ? $"{srdName}(_id, {value})" : $"{srdName}({value})"; yield return $"public static void {name}({args}) => {body};"; } @@ -164,11 +161,11 @@ private IEnumerable EmitMethodExport (MethodMeta method) if (wait) @return = $"async global::System.Threading.Tasks.Task<{@return}>"; var sigArgs = string.Join(", ", method.Args.Select(a => BuildParameter(a.Value, a.Name))); if (isIt) sigArgs = $"int {PrependIdArg(sigArgs)}"; - var invArgs = string.Join(", ", method.Args.Select(Import)); + var invArgs = string.Join(", ", method.Args.Select(ImportCS)); var invName = isIt - ? $"Instances.Exported<{it.Syntax}>(_id).{method.Name}" + ? $"Instances.Exported<{key}>(_id).{method.Name}" : $"{stx}.{method.Name}"; - var body = Export(method.Return, $"{(wait ? "await " : "")}{invName}({invArgs})"); + var body = ExportCS(method.Return, $"{(wait ? "await " : "")}{invName}({invArgs})"); yield return $"{attr}internal static {@return} {name} ({sigArgs}) => {body};"; } @@ -187,9 +184,9 @@ private IEnumerable EmitMethodImport (MethodMeta method) @return = $"{(wait ? "async " : "")}{method.Return.TypeSyntax}"; var sigArgs = string.Join(", ", method.Args.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); if (isIt) sigArgs = $"int {PrependIdArg(sigArgs)}"; - var invArgs = string.Join(", ", method.Args.Select(Export)); + var invArgs = string.Join(", ", method.Args.Select(ExportCS)); if (isIt) invArgs = PrependIdArg(invArgs); - var body = Import(method.Return, $"{(wait ? "await " : "")}{name}_Serialized({invArgs})"); + var body = ImportCS(method.Return, $"{(wait ? "await " : "")}{name}_Serialized({invArgs})"); yield return $"public static {@return} {name} ({sigArgs}) => {body};"; } diff --git a/src/cs/Bootsharp.Publish/GenerateCS/CSSerializerGenerator.cs b/src/cs/Bootsharp.Publish/GenerateCS/CSSerializerGenerator.cs index ab2db518..829ffc39 100644 --- a/src/cs/Bootsharp.Publish/GenerateCS/CSSerializerGenerator.cs +++ b/src/cs/Bootsharp.Publish/GenerateCS/CSSerializerGenerator.cs @@ -46,12 +46,12 @@ private string EmitInstanced (SerializedInstanceMeta it) => $$""" private static void Write_{{it.Id}} (ref Writer writer, {{it.Syntax}} value) { - writer.WriteInt32({{Export(it.Instance, "value")}}); + writer.WriteInt32({{ExportCS(it.Instance, "value")}}); } private static {{it.Syntax}} Read_{{it.Id}} (ref Reader reader) { - return {{Import(it.Instance, "reader.ReadInt32()")}}; + return {{ImportCS(it.Instance, "reader.ReadInt32()")}}; } """; diff --git a/src/cs/Bootsharp.Publish/GenerateCS/GenerateCS.cs b/src/cs/Bootsharp.Publish/GenerateCS/GenerateCS.cs index 661c027a..362791bd 100644 --- a/src/cs/Bootsharp.Publish/GenerateCS/GenerateCS.cs +++ b/src/cs/Bootsharp.Publish/GenerateCS/GenerateCS.cs @@ -14,7 +14,6 @@ public sealed class GenerateCS : Microsoft.Build.Utilities.Task public override bool Execute () { - PreferencesResolver.Resolve(EntryAssemblyName, InspectedDirectory); using var spec = InspectSolution(); GenerateSerializer(spec); GenerateInstances(spec); @@ -25,11 +24,9 @@ public override bool Execute () private SolutionInspection InspectSolution () { - var inspector = new SolutionInspector(); + var inspector = new SolutionInspector(EntryAssemblyName, Log); var inspected = Directory.GetFiles(InspectedDirectory, "*.dll").Order(); - var inspection = inspector.Inspect(InspectedDirectory, inspected); - new InspectionReporter(Log).Report(inspection); - return inspection; + return inspector.Inspect(InspectedDirectory, inspected); } private void GenerateSerializer (SolutionInspection spec) diff --git a/src/cs/Bootsharp.Publish/GenerateJS/Declarations/DeclarationGenerator.cs b/src/cs/Bootsharp.Publish/GenerateJS/Declarations/DeclarationGenerator.cs index b56ac54e..e687e874 100644 --- a/src/cs/Bootsharp.Publish/GenerateJS/Declarations/DeclarationGenerator.cs +++ b/src/cs/Bootsharp.Publish/GenerateJS/Declarations/DeclarationGenerator.cs @@ -33,7 +33,7 @@ public string Generate (JSModule module) } private string EmitImports (JSModule md) => Fmt([ - $$"""import type { Event } from "{{md.To("event")}}";""", + $$"""import type { Event } from "{{md.To("bcl/event")}}";""", ..mds.GetImported(md).Select(imp => $"""import type * as {imp.Alias} from "{md.ToMd(imp.Path)}";""") ], 0); @@ -103,10 +103,12 @@ private void DeclareInstance (InstanceMeta it) if (member is EventMeta evt) DeclareEvent(evt); else if (member is PropertyMeta prop) DeclareProperty(prop); else if (member is MethodMeta method) DeclareMethod(method); + if (it.Proxy is SpecializedProxy { Decl: { } decl }) bld.Line(decl); bld.Exit("}"); string BuildExtensions () { + if (it.Proxy is SpecializedProxy) return ""; // specialized surfaces are self-contained var ext = it.Clr.GetInterfaces().Where(IsUserType).ToList(); if (spec.Types.HasBase(it.Clr, out var bs)) ext.Insert(0, bs.Clr); return ext.Count == 0 ? "" : $" extends {string.Join(", ", ext.Select(ts.BuildFullName))}"; diff --git a/src/cs/Bootsharp.Publish/GenerateJS/Declarations/TypeSyntaxBuilder.cs b/src/cs/Bootsharp.Publish/GenerateJS/Declarations/TypeSyntaxBuilder.cs index 3b5a5171..894af54d 100644 --- a/src/cs/Bootsharp.Publish/GenerateJS/Declarations/TypeSyntaxBuilder.cs +++ b/src/cs/Bootsharp.Publish/GenerateJS/Declarations/TypeSyntaxBuilder.cs @@ -75,9 +75,9 @@ private string Build (Type type, NullabilityInfo? nul) if (type.IsGenericTypeParameter) return type.Name; if (IsNullable(type, out var inner)) return Build(inner, EnterNullity(nul)); if (IsTaskLike(type)) return BuildTask(type, nul); + if (IsUserType(type)) return BuildUser(type, nul); if (IsList(type, out var element)) return BuildList(type, element, nul); if (IsDictionary(type, out var key, out var value)) return BuildDictionary(key, value, nul); - if (IsUserType(type) || IsDelegate(type)) return BuildUser(type, nul); return BuildPrimitive(type); } diff --git a/src/cs/Bootsharp.Publish/GenerateJS/GenerateJS.cs b/src/cs/Bootsharp.Publish/GenerateJS/GenerateJS.cs index 6e083431..1fd6d8f1 100644 --- a/src/cs/Bootsharp.Publish/GenerateJS/GenerateJS.cs +++ b/src/cs/Bootsharp.Publish/GenerateJS/GenerateJS.cs @@ -17,7 +17,6 @@ public sealed class GenerateJS : Microsoft.Build.Utilities.Task public override bool Execute () { - PreferencesResolver.Resolve(EntryAssemblyName, InspectedDirectory); using var spec = InspectSolution(); var mds = new JSModules(spec.Types); GenerateImports(mds); @@ -32,12 +31,11 @@ public override bool Execute () private SolutionInspection InspectSolution () { - var inspector = new SolutionInspector(); - var inspection = inspector.Inspect(InspectedDirectory, GetFiles()); - new InspectionReporter(Log).Report(inspection); - return inspection; + var inspector = new SolutionInspector(EntryAssemblyName, Log); + var inspected = ResolveInspectedFiles(); + return inspector.Inspect(InspectedDirectory, inspected); - IEnumerable GetFiles () + IEnumerable ResolveInspectedFiles () { if (LLVM) return Directory.GetFiles(InspectedDirectory, "*.dll").Order(); // Assemblies in publish dir are trimmed and don't contain some data (eg, method arg names). diff --git a/src/cs/Bootsharp.Publish/GenerateJS/JSInstanceGenerator.cs b/src/cs/Bootsharp.Publish/GenerateJS/JSInstanceGenerator.cs index 4eae0ee7..7a3275a6 100644 --- a/src/cs/Bootsharp.Publish/GenerateJS/JSInstanceGenerator.cs +++ b/src/cs/Bootsharp.Publish/GenerateJS/JSInstanceGenerator.cs @@ -8,7 +8,7 @@ internal sealed class JSInstanceGenerator (bool debug, JSModules md) { public string Generate (IReadOnlyCollection its) => $$""" - import { Event } from "../event.mjs"; + import { Event } from "../bcl/event.mjs"; import { {{(debug ? "exports, getExport" : "exports")}} } from "../exports.mjs"; import { instances as $i } from "../instances.mjs"; import $s, { serialize } from "./serializer.g.mjs"; @@ -55,6 +55,7 @@ string EmitHandler (EventMeta e) private string EmitProxy (InstanceMeta it) => it switch { DelegateMeta del => EmitDelegateProxy(del), + { Proxy: SpecializedProxy sp } => EmitSpecializedProxy(it, sp), _ => EmitOpaqueProxy(it) }; @@ -84,6 +85,17 @@ private string EmitOpaqueProxy (InstanceMeta it) => }; """; + private string EmitSpecializedProxy (InstanceMeta it, SpecializedProxy sp) => + $$""" + $i.{{it.Id}} = class {{it.Proxy.Id}} { + {{Fmt([ + "constructor(_id) { this._id = _id; }", + ..it.Members.Select(EmitMember), + sp.JS + ])}} + }; + """; + private string EmitMember (MemberMeta member) => member switch { EventMeta evt => EmitEvent(evt), PropertyMeta prop => EmitProperty(prop), diff --git a/src/cs/Bootsharp.Publish/GenerateJS/JSModuleGenerator.cs b/src/cs/Bootsharp.Publish/GenerateJS/JSModuleGenerator.cs index c7013f70..1d56a49f 100644 --- a/src/cs/Bootsharp.Publish/GenerateJS/JSModuleGenerator.cs +++ b/src/cs/Bootsharp.Publish/GenerateJS/JSModuleGenerator.cs @@ -27,7 +27,7 @@ public string Generate (JSModule module) private string EmitImports (JSModule md) => $$""" - import { Event } from "{{md.To("event")}}"; + import { Event } from "{{md.To("bcl/event")}}"; import { {{(debug ? "exports, getExport" : "exports")}} } from "{{md.To("exports")}}"; import { {{(debug ? "importEvent, getImport" : "importEvent")}} } from "{{md.To("imports")}}"; import $i from "{{md.ToGen("instances")}}"; diff --git a/src/cs/Directory.Build.props b/src/cs/Directory.Build.props index 61f339f7..7ce64b32 100644 --- a/src/cs/Directory.Build.props +++ b/src/cs/Directory.Build.props @@ -1,7 +1,7 @@ - 0.8.0-alpha.363 + 0.8.0-alpha.430 Elringus javascript typescript ts js wasm node deno bun interop codegen https://bootsharp.com diff --git a/src/js/src/bcl/cancellation.mts b/src/js/src/bcl/cancellation.mts new file mode 100644 index 00000000..47dc5298 --- /dev/null +++ b/src/js/src/bcl/cancellation.mts @@ -0,0 +1,19 @@ +import { Event } from "./event.mjs"; + +/** A cancellation token compatible with C# `CancellationToken`. */ +export class CancellationToken { + /** Occurs when the token is cancelled. */ + readonly onCancellationRequested = new Event<[]>(); + + /** Whether cancellation has been requested. */ + get isCancellationRequested(): boolean { return this.cancelled; } + + private cancelled = false; + + /** Signal cancellation. */ + cancel(): void { + if (this.cancelled) return; + this.cancelled = true; + this.onCancellationRequested.broadcast(); + } +} diff --git a/src/js/src/bcl/collection.mts b/src/js/src/bcl/collection.mts new file mode 100644 index 00000000..d7b55ead --- /dev/null +++ b/src/js/src/bcl/collection.mts @@ -0,0 +1,46 @@ +/** A collection of items compatible with C# `ICollection`. */ +export class Collection { + protected readonly items: T[]; + + constructor(items?: Iterable) { + this.items = items != null ? Array.from(items) : []; + } + + /** Number of items in the collection. */ + get count(): number { + return this.items.length; + } + + /** Adds the specified item to the collection. */ + add(item: T): void { + this.items.push(item); + } + + /** Removes the first occurrence of the specified item from the collection. + * @returns true when the item was removed; false when it wasn't found. */ + remove(item: T): boolean { + const idx = this.items.indexOf(item); + if (idx < 0) return false; + this.items.splice(idx, 1); + return true; + } + + /** Removes all items from the collection. */ + clear(): void { + this.items.length = 0; + } + + /** Whether the collection contains the specified item. */ + contains(item: T): boolean { + return this.items.indexOf(item) >= 0; + } + + /** Returns a fresh array with a snapshot of the current items. */ + copy(): T[] { + return this.items.slice(); + } + + [Symbol.iterator](): IterableIterator { + return this.copy()[Symbol.iterator](); + } +} diff --git a/src/js/src/bcl/dictionary.mts b/src/js/src/bcl/dictionary.mts new file mode 100644 index 00000000..9250eef1 --- /dev/null +++ b/src/js/src/bcl/dictionary.mts @@ -0,0 +1,58 @@ +/** A dictionary of key-value pairs compatible with C# `IDictionary`. */ +export class Dictionary { + protected readonly map: Map; + + constructor(entries?: Iterable<[TKey, TValue]>) { + this.map = new Map(entries); + } + + /** Number of key-value pairs in the dictionary. */ + get count(): number { + return this.map.size; + } + + /** Associates the specified value with the specified key. */ + add(key: TKey, value: TValue): void { + this.map.set(key, value); + } + + /** Whether the dictionary contains the specified key. */ + containsKey(key: TKey): boolean { + return this.map.has(key); + } + + /** Removes the value with the specified key from the dictionary. + * @returns true when the key was removed; false when it wasn't found. */ + remove(key: TKey): boolean { + return this.map.delete(key); + } + + /** Removes all key-value pairs from the dictionary. */ + clear(): void { + this.map.clear(); + } + + /** Returns the value associated with the specified key. */ + getAt(key: TKey): TValue { + return this.map.get(key) as TValue; + } + + /** Associates the specified value with the specified key. */ + setAt(key: TKey, value: TValue): void { + this.map.set(key, value); + } + + /** Returns a fresh array with a snapshot of the current keys. */ + getKeys(): TKey[] { + return Array.from(this.map.keys()); + } + + /** Returns a fresh array with a snapshot of the current values. */ + getValues(): TValue[] { + return Array.from(this.map.values()); + } + + [Symbol.iterator](): IterableIterator<[TKey, TValue]> { + return this.map[Symbol.iterator](); + } +} diff --git a/src/js/src/event.mts b/src/js/src/bcl/event.mts similarity index 97% rename from src/js/src/event.mts rename to src/js/src/bcl/event.mts index 04825819..bfe76145 100644 --- a/src/js/src/event.mts +++ b/src/js/src/bcl/event.mts @@ -23,7 +23,7 @@ export type EventOptions = { warn?: (message: string) => void }; -/** Allows attaching handlers and broadcasting events. */ +/** Allows attaching handlers and broadcasting events; compatible with C# events. */ export class Event implements EventBroadcaster, EventSubscriber { private readonly handlers = new Map void>(); private readonly warn: (message: string) => void; diff --git a/src/js/src/bcl/index.mts b/src/js/src/bcl/index.mts new file mode 100644 index 00000000..ea160306 --- /dev/null +++ b/src/js/src/bcl/index.mts @@ -0,0 +1,5 @@ +export * from "./event.mjs"; +export * from "./collection.mjs"; +export * from "./list.mjs"; +export * from "./dictionary.mjs"; +export * from "./cancellation.mjs"; diff --git a/src/js/src/bcl/list.mts b/src/js/src/bcl/list.mts new file mode 100644 index 00000000..4cb60e19 --- /dev/null +++ b/src/js/src/bcl/list.mts @@ -0,0 +1,29 @@ +import { Collection } from "./collection.mjs"; + +/** A list of items compatible with C# `IList`. */ +export class List extends Collection { + /** Returns the item at the specified index. */ + getAt(index: number): T { + return this.items[index]; + } + + /** Assigns the specified item at the specified index. */ + setAt(index: number, item: T): void { + this.items[index] = item; + } + + /** Returns the index of the first occurrence of the specified item, or -1 when not found. */ + indexOf(item: T): number { + return this.items.indexOf(item); + } + + /** Inserts the specified item at the specified index. */ + insert(index: number, item: T): void { + this.items.splice(index, 0, item); + } + + /** Removes the item at the specified index. */ + removeAt(index: number): void { + this.items.splice(index, 1); + } +} diff --git a/src/js/src/imports.mts b/src/js/src/imports.mts index 42940239..9152c0dc 100644 --- a/src/js/src/imports.mts +++ b/src/js/src/imports.mts @@ -1,4 +1,4 @@ -import { Event } from "./event.mjs"; +import { Event } from "./bcl/event.mjs"; import { bindImports as bindGeneratedImports } from "./generated/imports.g.mjs"; import { instances } from "./instances.mjs"; import type { RuntimeAPI } from "./dotnet/index.mjs"; diff --git a/src/js/src/index.mts b/src/js/src/index.mts index dfdc7c02..e93fe0c7 100644 --- a/src/js/src/index.mts +++ b/src/js/src/index.mts @@ -11,7 +11,7 @@ export default { dotnet: app.dotnet }; -export * from "./event.mjs"; +export * from "./bcl/index.mjs"; export * from "./generated/modules/index.g.mjs"; export type { BootOptions } from "./boot.mjs"; export type { BootManifest, BootResources, BinaryResource } from "./resources.mjs"; diff --git a/src/js/test/cs/Test/BCL.cs b/src/js/test/cs/Test/BCL.cs new file mode 100644 index 00000000..1cfee2d5 --- /dev/null +++ b/src/js/test/cs/Test/BCL.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using Bootsharp; + +namespace Test; + +public static partial class BCL +{ + private static CancellationTokenSource cts = new(); + + [Export] public static CancellationToken ExportCancellationToken () => (cts = new()).Token; + [Export] public static void CancelExportedCancellationToken () => cts.Cancel(); + [Import] public static partial CancellationToken ImportCancellationToken (); + [Import] public static partial void CancelImportedCancellationToken (); + [Export] public static CancellationToken EchoCancellationTokenExport (CancellationToken ct) => ct; + [Import] public static partial CancellationToken EchoCancellationTokenImport (CancellationToken ct); + + [Export] + public static void TestCancellationTokenImport () + { + var cancelled = false; + var ct = ImportCancellationToken(); + ct.Register(() => cancelled = true); + Assert(ct.CanBeCanceled); + Assert(!ct.IsCancellationRequested); + CancelImportedCancellationToken(); + Assert(ct.IsCancellationRequested); + Assert(cancelled); + CancelImportedCancellationToken(); + CancelImportedCancellationToken(); + Assert(ct.IsCancellationRequested); + var source = new CancellationTokenSource(); + var echoed = EchoSource(source); + Assert(ReferenceEquals(source, echoed)); + Assert(ReferenceEquals(source, EchoSource(echoed))); + Assert(!echoed.IsCancellationRequested); + source.Cancel(); + Assert(echoed.IsCancellationRequested); + ct = default; + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_source")] + static extern ref CancellationTokenSource GetSource (ref CancellationToken ct); + static CancellationTokenSource EchoSource (CancellationTokenSource src) + { + var ct = EchoCancellationTokenImport(src.Token); + return GetSource(ref ct); + } + } + + [Export] public static ICollection ExportCollection (string[] items) => new List(items); + [Import] public static partial ICollection ImportCollection (string[] items); + [Export] public static ICollection EchoCollectionExport (ICollection cl) => cl; + [Import] public static partial ICollection EchoCollectionImport (ICollection cl); + + [Export] + public static void TestCollectionImport () + { + var cl = ImportCollection(["a", "b"]); + Assert(cl.Count == 2); + Assert(cl.SequenceEqual(["a", "b"])); + Assert(cl.Contains("a")); + Assert(!cl.Contains("z")); + cl.Add("c"); + var concat = ""; + foreach (var item in cl) concat += item; + Assert(concat == "abc"); + Assert(cl.Remove("a")); + Assert(!cl.Remove("z")); + Assert(cl.SequenceEqual(["b", "c"])); + cl.Clear(); + Assert(cl.Count == 0); + cl.Add("d"); + Assert(cl.SequenceEqual(["d"])); + var source = new List { "foo", "bar" }; + var echoed = EchoCollectionImport(source); + Assert(ReferenceEquals(source, echoed)); + Assert(ReferenceEquals(source, EchoCollectionImport(echoed))); + source.Clear(); + Assert(echoed.Count == 0); + } + + [Export] public static IList ExportList (string[] items) => new List(items); + [Import] public static partial IList ImportList (string[] items); + [Export] public static IList EchoListExport (IList list) => list; + [Import] public static partial IList EchoListImport (IList list); + + [Export] + public static void TestListImport () + { + var list = ImportList(["a", "b"]); + Assert(list.Count == 2); + Assert(list[0] == "a"); + Assert(list[1] == "b"); + list[0] = "x"; + Assert(list[0] == "x"); + list[0] = "a"; + Assert(list.IndexOf("a") == 0); + Assert(list.IndexOf("b") == 1); + Assert(list.SequenceEqual(["a", "b"])); + Assert(list.Contains("a")); + Assert(!list.Contains("z")); + list.Add("c"); + var concat = ""; + foreach (var item in list) concat += item; + Assert(concat == "abc"); + Assert(list.Remove("a")); + Assert(!list.Remove("z")); + list.Insert(1, "z"); + Assert(list.SequenceEqual(["b", "z", "c"])); + list.RemoveAt(1); + Assert(list.SequenceEqual(["b", "c"])); + list.Clear(); + Assert(list.Count == 0); + list.Add("d"); + Assert(list.SequenceEqual(["d"])); + var source = new List { "foo", "bar" }; + var echoed = EchoListImport(source); + Assert(ReferenceEquals(source, echoed)); + Assert(ReferenceEquals(source, EchoListImport(echoed))); + source.Clear(); + Assert(echoed.Count == 0); + } + + [Export] public static IDictionary ExportDictionary (Dictionary kv) => kv.ToDictionary(); + [Import] public static partial IDictionary ImportDictionary (Dictionary kv); + [Export] public static IDictionary EchoDictionaryExport (IDictionary dic) => dic; + [Import] public static partial IDictionary EchoDictionaryImport (IDictionary dic); + + [Export] + public static void TestDictionaryImport () + { + var dic = ImportDictionary(new Dictionary { ["a"] = "A", ["b"] = "B" }); + Assert(dic.Count == 2); + Assert(dic["a"] == "A"); + Assert(dic["b"] == "B"); + Assert(dic.ContainsKey("a")); + Assert(!dic.ContainsKey("z")); + Assert(dic.TryGetValue("a", out var value) && value == "A"); + Assert(!dic.TryGetValue("z", out _)); + Assert(dic.Keys.SequenceEqual(["a", "b"])); + Assert(dic.Values.SequenceEqual(["A", "B"])); + dic.Add("c", "C"); + dic["c"] = "CC"; + var concat = ""; + foreach (var kv in dic) concat += $"{kv.Key}{kv.Value}"; + Assert(concat == "aAbBcCC"); + Assert(dic.Remove("a")); + Assert(!dic.Remove("z")); + Assert(!dic.ContainsKey("a")); + dic.Clear(); + Assert(dic.Count == 0); + dic.Add("d", "D"); + Assert(dic.Values.SequenceEqual(["D"])); + var source = new Dictionary { ["foo"] = "1", ["bar"] = "2" }; + var echoed = EchoDictionaryImport(source); + Assert(ReferenceEquals(source, echoed)); + Assert(ReferenceEquals(source, EchoDictionaryImport(echoed))); + source.Clear(); + Assert(echoed.Count == 0); + } + + [Export] public static IComparer ExportComparer () => Comparer.Create(string.CompareOrdinal); + [Import] public static partial IComparer ImportComparer (); + [Export] public static IComparer EchoComparerExport (IComparer cmp) => cmp; + [Import] public static partial IComparer EchoComparerImport (IComparer cmp); + + [Export] + public static void TestComparerImport () + { + var cmp = ImportComparer(); + Assert(cmp.Compare("a", "b") < 0); + Assert(cmp.Compare("b", "a") > 0); + Assert(cmp.Compare("a", "a") == 0); + Assert(new[] { "c", "a", "b" }.OrderBy(i => i, cmp).SequenceEqual(["a", "b", "c"])); + var source = Comparer.Create(string.CompareOrdinal); + var echoed = EchoComparerImport(source); + Assert(ReferenceEquals(source, echoed)); + Assert(ReferenceEquals(source, EchoComparerImport(echoed))); + } +} + +[SpecializeImport(typeof(IComparer<>))] // Testing user-specified specialization. +public abstract class ComparerImport (int id) : SpecializedImport(id), IComparer +{ + public abstract int Compare (T? x, T? y); +} + +[SpecializeExport(typeof(IComparer<>))] +public class ComparerExport (IComparer cmp) : SpecializedExport(cmp) +{ + public int Compare (T? x, T? y) => cmp.Compare(x, y); +} diff --git a/src/js/test/cs/Test/Serialization.cs b/src/js/test/cs/Test/Serialization.cs index 2a20659e..40dc05d6 100644 --- a/src/js/test/cs/Test/Serialization.cs +++ b/src/js/test/cs/Test/Serialization.cs @@ -73,12 +73,12 @@ public static class Serialization [Export] public static List?[]? EchoNestedIntList (List?[]? value) => value; [Export] public static Dictionary? EchoDictionary (Dictionary? value) => value; [Export] public static Dictionary?[]? EchoNestedDictionary (Dictionary?[]? value) => value; - [Export] public static IList EchoListInterface (IList value) => value; + [Export] public static IList EchoListInterface (IList value) => value; [Export] public static IReadOnlyList EchoReadOnlyList (IReadOnlyList value) => value; - [Export] public static ICollection EchoCollection (ICollection value) => value; + [Export] public static ICollection EchoCollection (ICollection value) => value; [Export] public static IReadOnlyCollection EchoReadOnlyCollection (IReadOnlyCollection value) => value; - [Export] public static IDictionary EchoDictionaryInterface (IDictionary value) => value; - [Export] public static IReadOnlyDictionary EchoReadOnlyDictionary (IReadOnlyDictionary value) => value; + [Export] public static IDictionary EchoDictionaryInterface (IDictionary value) => value; + [Export] public static IReadOnlyDictionary EchoReadOnlyDictionary (IReadOnlyDictionary value) => value; [Export] public static void ImportedInstancesSurviveSerialization (Union union, IBidirectional bi) diff --git a/src/js/test/spec/bcl.spec.ts b/src/js/test/spec/bcl.spec.ts new file mode 100644 index 00000000..a95c0277 --- /dev/null +++ b/src/js/test/spec/bcl.spec.ts @@ -0,0 +1,293 @@ +import { describe, expect, it, beforeAll } from "vitest"; +import { Event, Collection, List, Dictionary, CancellationToken, bootRuntime } from "../cs"; +import { BCL } from "../cs/Test/bin/bootsharp/generated/modules/test.g.mjs"; + +describe("BCL", () => { + beforeAll(bootRuntime); + describe("event", () => { + it("can broadcast without subscribers", () => { + new Event().broadcast(); + }); + it("doesn't mind unsubscribing null handler", () => { + new Event().unsubscribe(null); + }); + it("warns when unsubscribing handler which is not subscribed", () => { + let warning; + new Event({ warn: msg => warning = msg }).unsubscribe(it); + expect(warning).include("handler is not subscribed"); + }); + it("warns when subscribing handler which is already subscribed", () => { + let warning; + const event = new Event({ warn: msg => warning = msg }); + event.subscribe(it); + event.subscribe(it); + expect(warning).include("handler is already subscribed"); + }); + it("invokes subscribed handlers in order", () => { + let result = ""; + const event = new Event(); + event.subscribe(() => result = "foo"); + event.subscribe(() => result = "bar"); + event.broadcast(); + expect(result).toStrictEqual("bar"); + }); + it("doesn't invoke un-subscribed handler", () => { + let result = false; + const event = new Event(); + const handler = (v: unknown) => result = v; + event.subscribe(handler); + event.broadcast(true); + event.unsubscribe(handler); + event.broadcast(false); + expect(result).toStrictEqual(true); + }); + it("delivers broadcast argument to the handlers", () => { + let result = ""; + const event = new Event(); + event.subscribe(v => result = v); + event.broadcast("foo"); + expect(result).toStrictEqual("foo"); + }); + it("can broadcast multiple arguments", () => { + let resultA, resultB; + const event = new Event(); + event.subscribe(function (a, b) { + resultA = a; + resultB = b; + }); + event.broadcast(["foo", "bar", undefined, null], "nya"); + expect(resultA).toStrictEqual(["foo", "bar", undefined, null]); + expect(resultB).toStrictEqual("nya"); + }); + it("doesnt add same handlers multiple times", () => { + let result = 0; + const event = new Event({ warn: () => {} }); + const incrementer = () => result++; + for (let i = 0; i < 10; i++) + event.subscribe(incrementer); + event.broadcast(); + expect(result).toStrictEqual(1); + }); + it("can un/subscribe by id", () => { + let result = 0; + const event = new Event(); + const incrementer = () => result++; + for (let i = 0; i < 10; i++) + event.subscribeById(i.toString(), incrementer); + event.unsubscribeById("0"); + event.broadcast(); + expect(result).toStrictEqual(9); + }); + it("returns undefined last args until no broadcasts performed", () => { + expect(new Event().last).toBeUndefined(); + }); + it("returns args of the last broadcasts", () => { + const event = new Event(); + event.broadcast("foo"); + event.broadcast("bar"); + expect(event.last).toStrictEqual(["bar"]); + }); + }); + describe("cancellation token", () => { + it("can interop with exported cancellation token", () => { + let cancelled = false; + const ct = BCL.exportCancellationToken(); + ct.onCancellationRequested.subscribe(() => cancelled = true); + expect(ct.isCancellationRequested).toStrictEqual(false); + BCL.cancelExportedCancellationToken(); + expect(ct.isCancellationRequested).toStrictEqual(true); + expect(cancelled).toStrictEqual(true); + BCL.cancelExportedCancellationToken(); + BCL.cancelExportedCancellationToken(); + expect(ct.isCancellationRequested).toStrictEqual(true); + const source = new CancellationToken(); + const echoed = BCL.echoCancellationTokenExport(source); + expect(echoed).toBe(source); + expect(BCL.echoCancellationTokenExport(echoed)).toBe(source); + expect(echoed.isCancellationRequested).toBeFalsy(); + source.cancel(); + expect(echoed.isCancellationRequested).toBeTruthy(); + }); + it("can interop with imported cancellation token", () => { + let ct = new CancellationToken(); + BCL.importCancellationToken = () => (ct = new CancellationToken()); + BCL.cancelImportedCancellationToken = () => ct.cancel(); + BCL.echoCancellationTokenImport = ct => ct; + BCL.testCancellationTokenImport(); + }); + }); + describe("collection", () => { + it("can use collection", () => { + const cl = new Collection(); + expect(cl.count).toStrictEqual(0); + cl.add("a"); + cl.add("b"); + expect(cl.count).toStrictEqual(2); + expect(cl.contains("a")).toStrictEqual(true); + expect(cl.contains("z")).toStrictEqual(false); + expect(cl.copy()).toStrictEqual(["a", "b"]); + expect([...cl]).toStrictEqual(["a", "b"]); + expect(cl.remove("a")).toStrictEqual(true); + expect(cl.remove("z")).toStrictEqual(false); + expect([...cl]).toStrictEqual(["b"]); + cl.clear(); + expect(cl.count).toStrictEqual(0); + expect([...new Collection(["x", "y"])]).toStrictEqual(["x", "y"]); + }); + it("can interop with exported collection", () => { + const cl = BCL.exportCollection(["a", "b"]); + expect(cl.count).toStrictEqual(2); + expect(cl.copy()).toStrictEqual(["a", "b"]); + expect(cl.contains("a")).toStrictEqual(true); + expect(cl.contains("z")).toStrictEqual(false); + cl.add("c"); + let concat = ""; + for (const item of cl) concat += item; + expect(concat).toStrictEqual("abc"); + expect(cl.remove("a")).toStrictEqual(true); + expect(cl.remove("z")).toStrictEqual(false); + expect(cl.copy()).toStrictEqual(["b", "c"]); + cl.clear(); + expect(cl.count).toStrictEqual(0); + cl.add("d"); + expect(cl.copy()).toStrictEqual(["d"]); + const source = new Collection(["foo", "bar"]); + const echoed = BCL.echoCollectionExport(source); + expect(echoed).toBe(source); + expect(BCL.echoCollectionExport(echoed)).toBe(source); + source.clear(); + expect(echoed.count).toStrictEqual(0); + }); + it("can interop with imported collection", () => { + BCL.importCollection = items => new Collection(items); + BCL.echoCollectionImport = cl => cl; + BCL.testCollectionImport(); + }); + }); + describe("list", () => { + it("can use list", () => { + const list = new List(); + expect(list.count).toStrictEqual(0); + list.add("a"); + list.add("b"); + expect(list.getAt(0)).toStrictEqual("a"); + list.setAt(0, "z"); + expect(list.getAt(0)).toStrictEqual("z"); + expect(list.indexOf("z")).toStrictEqual(0); + expect(list.indexOf("missing")).toStrictEqual(-1); + list.insert(1, "m"); + expect(list.copy()).toStrictEqual(["z", "m", "b"]); + list.removeAt(1); + expect(list.copy()).toStrictEqual(["z", "b"]); + expect([...list]).toStrictEqual(["z", "b"]); + }); + it("can interop with exported list", () => { + const list = BCL.exportList(["a", "b"]); + expect(list.count).toStrictEqual(2); + expect(list.getAt(0)).toStrictEqual("a"); + expect(list.getAt(1)).toStrictEqual("b"); + list.setAt(0, "x"); + expect(list.getAt(0)).toStrictEqual("x"); + list.setAt(0, "a"); + expect(list.indexOf("a")).toStrictEqual(0); + expect(list.indexOf("b")).toStrictEqual(1); + expect(list.copy()).toStrictEqual(["a", "b"]); + expect(list.contains("a")).toStrictEqual(true); + expect(list.contains("z")).toStrictEqual(false); + list.add("c"); + let concat = ""; + for (const item of list) concat += item; + expect(concat).toStrictEqual("abc"); + expect(list.remove("a")).toStrictEqual(true); + expect(list.remove("z")).toStrictEqual(false); + list.insert(1, "z"); + expect(list.copy()).toStrictEqual(["b", "z", "c"]); + list.removeAt(1); + expect(list.copy()).toStrictEqual(["b", "c"]); + list.clear(); + expect(list.count).toStrictEqual(0); + list.add("d"); + expect(list.copy()).toStrictEqual(["d"]); + const source = new List(["foo", "bar"]); + const echoed = BCL.echoListExport(source); + expect(echoed).toBe(source); + expect(BCL.echoListExport(echoed)).toBe(source); + source.clear(); + expect(echoed.count).toStrictEqual(0); + }); + it("can interop with imported list", () => { + BCL.importList = items => new List(items); + BCL.echoListImport = list => list; + BCL.testListImport(); + }); + }); + describe("dictionary", () => { + it("can use dictionary", () => { + const dic = new Dictionary(); + expect(dic.count).toStrictEqual(0); + dic.add("a", "A"); + dic.setAt("b", "B"); + expect(dic.getAt("a")).toStrictEqual("A"); + expect(dic.containsKey("a")).toStrictEqual(true); + expect(dic.containsKey("z")).toStrictEqual(false); + expect(dic.getKeys()).toStrictEqual(["a", "b"]); + expect(dic.getValues()).toStrictEqual(["A", "B"]); + expect([...dic]).toStrictEqual([["a", "A"], ["b", "B"]]); + expect(dic.remove("a")).toStrictEqual(true); + expect(dic.remove("z")).toStrictEqual(false); + dic.clear(); + expect(dic.count).toStrictEqual(0); + expect([...new Dictionary([["x", "1"]])]).toStrictEqual([["x", "1"]]); + }); + it("can interop with exported dictionary", () => { + const dic = BCL.exportDictionary(new Map([["a", "A"], ["b", "B"]])); + expect(dic.count).toStrictEqual(2); + expect(dic.getAt("a")).toStrictEqual("A"); + expect(dic.getAt("b")).toStrictEqual("B"); + expect(dic.containsKey("a")).toStrictEqual(true); + expect(dic.containsKey("z")).toStrictEqual(false); + expect(dic.getKeys()).toStrictEqual(["a", "b"]); + expect(dic.getValues()).toStrictEqual(["A", "B"]); + dic.add("c", "C"); + dic.setAt("c", "CC"); + const kv: [string, string][] = []; + for (const entry of dic) kv.push(entry); + expect(kv).toStrictEqual([["a", "A"], ["b", "B"], ["c", "CC"]]); + expect(dic.remove("a")).toStrictEqual(true); + expect(dic.remove("z")).toStrictEqual(false); + expect(dic.getKeys()).toStrictEqual(["b", "c"]); + dic.clear(); + expect(dic.count).toStrictEqual(0); + dic.add("d", "D"); + expect(dic.getValues()).toStrictEqual(["D"]); + const source = new Dictionary([["foo", "1"], ["bar", "2"]]); + const echoed = BCL.echoDictionaryExport(source); + expect(echoed).toBe(source); + expect(BCL.echoDictionaryExport(echoed)).toBe(source); + source.clear(); + expect(echoed.count).toStrictEqual(0); + }); + it("can interop with imported dictionary", () => { + BCL.importDictionary = kv => new Dictionary(kv); + BCL.echoDictionaryImport = dic => dic; + BCL.testDictionaryImport(); + }); + }); + describe("user-specified specialization", () => { + const comparer = { compare: (x: string, y: string) => x < y ? -1 : x > y ? 1 : 0 }; + it("can interop with exported comparer", () => { + const cmp = BCL.exportComparer(); + expect(cmp.compare("a", "b")).toBeLessThan(0); + expect(cmp.compare("b", "a")).toBeGreaterThan(0); + expect(cmp.compare("a", "a")).toStrictEqual(0); + const echoed = BCL.echoComparerExport(comparer); + expect(echoed).toBe(comparer); + expect(BCL.echoComparerExport(echoed)).toBe(comparer); + }); + it("can interop with imported comparer", () => { + BCL.importComparer = () => comparer; + BCL.echoComparerImport = cmp => cmp; + BCL.testComparerImport(); + }); + }); +}); diff --git a/src/js/test/spec/event.spec.ts b/src/js/test/spec/event.spec.ts deleted file mode 100644 index ddfbedda..00000000 --- a/src/js/test/spec/event.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Event } from "../cs"; - -describe("event", () => { - it("can broadcast without subscribers", () => { - new Event().broadcast(); - }); - it("doesn't mind unsubscribing null handler", () => { - new Event().unsubscribe(null); - }); - it("warns when unsubscribing handler which is not subscribed", () => { - let warning; - new Event({ warn: msg => warning = msg }).unsubscribe(it); - expect(warning).include("handler is not subscribed"); - }); - it("warns when subscribing handler which is already subscribed", () => { - let warning; - const event = new Event({ warn: msg => warning = msg }); - event.subscribe(it); - event.subscribe(it); - expect(warning).include("handler is already subscribed"); - }); - it("invokes subscribed handlers in order", () => { - let result = ""; - const event = new Event(); - event.subscribe(() => result = "foo"); - event.subscribe(() => result = "bar"); - event.broadcast(); - expect(result).toStrictEqual("bar"); - }); - it("doesn't invoke un-subscribed handler", () => { - let result = false; - const event = new Event(); - const handler = (v: unknown) => result = v; - event.subscribe(handler); - event.broadcast(true); - event.unsubscribe(handler); - event.broadcast(false); - expect(result).toStrictEqual(true); - }); - it("delivers broadcast argument to the handlers", () => { - let result = ""; - const event = new Event(); - event.subscribe(v => result = v); - event.broadcast("foo"); - expect(result).toStrictEqual("foo"); - }); - it("can broadcast multiple arguments", () => { - let resultA, resultB; - const event = new Event(); - event.subscribe(function (a, b) { - resultA = a; - resultB = b; - }); - event.broadcast(["foo", "bar", undefined, null], "nya"); - expect(resultA).toStrictEqual(["foo", "bar", undefined, null]); - expect(resultB).toStrictEqual("nya"); - }); - it("doesnt add same handlers multiple times", () => { - let result = 0; - const event = new Event({ warn: () => {} }); - const incrementer = () => result++; - for (let i = 0; i < 10; i++) - event.subscribe(incrementer); - event.broadcast(); - expect(result).toStrictEqual(1); - }); - it("can un/subscribe by id", () => { - let result = 0; - const event = new Event(); - const incrementer = () => result++; - for (let i = 0; i < 10; i++) - event.subscribeById(i.toString(), incrementer); - event.unsubscribeById("0"); - event.broadcast(); - expect(result).toStrictEqual(9); - }); - it("returns undefined last args until no broadcasts performed", () => { - expect(new Event().last).toBeUndefined(); - }); - it("returns args of the last broadcasts", () => { - const event = new Event(); - event.broadcast("foo"); - event.broadcast("bar"); - expect(event.last).toStrictEqual(["bar"]); - }); -}); diff --git a/src/js/test/spec/interop.spec.ts b/src/js/test/spec/interop.spec.ts index cfc6a268..18c0181a 100644 --- a/src/js/test/spec/interop.spec.ts +++ b/src/js/test/spec/interop.spec.ts @@ -1,6 +1,6 @@ import { describe, it, beforeAll, expect, vi } from "vitest"; import { Event, bootRuntime } from "../cs"; -import { Platform, Static } from "../cs/Test/bin/bootsharp/generated/modules/test.g.mjs"; +import { BCL, Platform, Static } from "../cs/Test/bin/bootsharp/generated/modules/test.g.mjs"; import { IExportedModule, IImportedModule, Modules, Registries, IRegistryProvider, TrackType } from "../cs/Test/bin/bootsharp/generated/modules/test/library.g.mjs"; import type { IBidirectional, IImportedInstanced, IImportedInnerInstanced, Record } from "../cs/Test/bin/bootsharp/generated/modules/test/library.g.mjs"; @@ -54,6 +54,17 @@ describe("while bootsharp is booted", () => { expect(IRegistryProvider.getRegistries).toBeUndefined(); expect(IRegistryProvider.getRegistryMap).toBeUndefined(); expect(IImportedModule.getInstanceAsync).toBeUndefined(); + expect(BCL.importCancellationToken).toBeUndefined(); + expect(BCL.cancelImportedCancellationToken).toBeUndefined(); + expect(BCL.echoCancellationTokenImport).toBeUndefined(); + expect(BCL.importCollection).toBeUndefined(); + expect(BCL.echoCollectionImport).toBeUndefined(); + expect(BCL.importList).toBeUndefined(); + expect(BCL.echoListImport).toBeUndefined(); + expect(BCL.importDictionary).toBeUndefined(); + expect(BCL.echoDictionaryImport).toBeUndefined(); + expect(BCL.importComparer).toBeUndefined(); + expect(BCL.echoComparerImport).toBeUndefined(); }); it("errs when invoking unassigned imported function", () => { expect(() => Static.invokeImportedFunction()) @@ -143,6 +154,17 @@ describe("while bootsharp is booted", () => { it("can interop with imported inner instances", () => { Modules.canInteropWithImportedInnerInstance(new Imported("")); }); + it("can interop with exported inner instances", async () => { + const handler = vi.fn(); + const inner = (await IExportedModule.getInstanceAsync("bar")).inner; + inner.onCountChanged.subscribe(handler); + inner.count = 0; + expect(handler).toHaveBeenCalledWith(0); + inner.increment(); + expect(handler).toHaveBeenCalledWith(1); + inner.increment(); + expect(inner.count).toStrictEqual(2); + }); it("can interop with bidirectional instances", () => { const factory = () => new BidirectionalJS(); Modules.importBi = factory; @@ -166,17 +188,6 @@ describe("while bootsharp is booted", () => { exp.onBiChanged.unsubscribe(handler); Modules.canInteropWithBidirectional(); }); - it("can interop with exported inner instances", async () => { - const handler = vi.fn(); - const inner = (await IExportedModule.getInstanceAsync("bar")).inner; - inner.onCountChanged.subscribe(handler); - inner.count = 0; - expect(handler).toHaveBeenCalledWith(0); - inner.increment(); - expect(handler).toHaveBeenCalledWith(1); - inner.increment(); - expect(inner.count).toStrictEqual(2); - }); it("releases instances after use", async () => { IImportedModule.getInstanceAsync = async (arg) => new Imported(arg); expect(await Modules.getImportedArgsAndFinalize("qux", "fox")).toStrictEqual(["qux", "fox"]); diff --git a/src/js/test/spec/serialization.spec.ts b/src/js/test/spec/serialization.spec.ts index 20b31620..3a6448a1 100644 --- a/src/js/test/spec/serialization.spec.ts +++ b/src/js/test/spec/serialization.spec.ts @@ -96,9 +96,9 @@ describe("serialization", () => { expect(Serialization.echoIntList([1, 2, 3])).toStrictEqual([1, 2, 3]); expect(Serialization.echoStringList(["a", null, "", "b"])).toStrictEqual(["a", null, "", "b"]); expect(Serialization.echoNestedIntList([[1, 2], null, []])).toStrictEqual([[1, 2], null, []]); - expect(Serialization.echoListInterface([1, 2, 3])).toStrictEqual([1, 2, 3]); + expect(Serialization.echoListInterface(["a", "b", "c"])).toStrictEqual(["a", "b", "c"]); expect(Serialization.echoReadOnlyList([1, 2, 3])).toStrictEqual([1, 2, 3]); - expect(Serialization.echoCollection([1, 2, 3])).toStrictEqual([1, 2, 3]); + expect(Serialization.echoCollection(["a", "b", "c"])).toStrictEqual(["a", "b", "c"]); expect(Serialization.echoReadOnlyCollection([1, 2, 3])).toStrictEqual([1, 2, 3]); expect(Serialization.echoIntList(undefined)).toBeNull(); expect(Serialization.echoStringList(undefined)).toBeNull(); @@ -109,10 +109,10 @@ describe("serialization", () => { .toStrictEqual(new Map([["1", "a"], ["2", null], ["3", ""]])); expect(Serialization.echoNestedDictionary([new Map([["1", "a"]]), null, new Map([["2", null]])])) .toStrictEqual([new Map([["1", "a"]]), null, new Map([["2", null]])]); - expect(Serialization.echoDictionaryInterface(new Map([[1, 2], [3, 4], [5, 0]]))) - .toStrictEqual(new Map([[1, 2], [3, 4], [5, 0]])); - expect(Serialization.echoReadOnlyDictionary(new Map([[1, 2], [3, 4], [5, 0]]))) - .toStrictEqual(new Map([[1, 2], [3, 4], [5, 0]])); + expect(Serialization.echoDictionaryInterface(new Map([["a", "b"], ["c", "d"]]))) + .toStrictEqual(new Map([["a", "b"], ["c", "d"]])); + expect(Serialization.echoReadOnlyDictionary(new Map([["a", "b"], ["c", "d"]]))) + .toStrictEqual(new Map([["a", "b"], ["c", "d"]])); expect(Serialization.echoDictionary(undefined)).toBeNull(); expect(Serialization.echoNestedDictionary(undefined)).toBeNull(); });