diff --git a/src/cs/Bootsharp.Common.Test/InstancesTest.cs b/src/cs/Bootsharp.Common.Test/InstancesTest.cs new file mode 100644 index 00000000..be04b727 --- /dev/null +++ b/src/cs/Bootsharp.Common.Test/InstancesTest.cs @@ -0,0 +1,42 @@ +using static Bootsharp.Instances; + +namespace Bootsharp.Common.Test; + +public class InstancesTest +{ + [Fact] + public void ThrowsWhenGettingUnregisteredInstance () + { + Assert.Throws(() => Get(0)); + } + + [Fact] + public void ThrowsWhenDisposingUnregisteredInstance () + { + Assert.Throws(() => Dispose(0)); + } + + [Fact] + public void CanRegisterGetAndDisposeInstance () + { + var instance = new object(); + var id = Register(instance); + Assert.Same(instance, Get(id)); + Dispose(id); + Assert.Throws(() => Get(id)); + } + + [Fact] + public void GeneratesUniqueIdsOnEachRegister () + { + Assert.NotEqual(Register(new object()), Register(new object())); + } + + [Fact] + public void ReusesIdOfDisposedInstance () + { + var id = Register(new object()); + Dispose(id); + Assert.Equal(id, Register(new object())); + } +} diff --git a/src/cs/Bootsharp.Common/Interop/Instances.cs b/src/cs/Bootsharp.Common/Interop/Instances.cs new file mode 100644 index 00000000..618c18d5 --- /dev/null +++ b/src/cs/Bootsharp.Common/Interop/Instances.cs @@ -0,0 +1,46 @@ +namespace Bootsharp; + +/// +/// Manages exported (C# -> JavaScript) instanced interop interfaces. +/// +public static class Instances +{ + private static readonly Dictionary idToInstance = []; + private static readonly Queue idPool = []; + private static int nextId = int.MinValue; + + /// + /// Registers specified interop instance and associates it with unique ID. + /// + /// The instance to register. + /// Unique ID associated with the registered instance. + public static int Register (object instance) + { + var id = idPool.Count > 0 ? idPool.Dequeue() : nextId++; + idToInstance[id] = instance; + return id; + } + + /// + /// Resolves registered instance by the specified ID. + /// + /// Unique ID of the instance to resolve. + public static object Get (int id) + { + if (!idToInstance.TryGetValue(id, out var instance)) + throw new Error($"Failed to resolve exported interop instance with '{id}' ID: not registered."); + return instance; + } + + /// + /// Notifies that interop instance is no longer used on JavaScript side + /// (eg, was garbage collected) and can be released on C# side as well. + /// + /// ID of the disposed interop instance. + public static void Dispose (int id) + { + if (!idToInstance.Remove(id)) + throw new Error($"Failed to dispose exported interop instance with '{id}' ID: not registered."); + idPool.Enqueue(id); + } +} diff --git a/src/cs/Bootsharp.Publish.Test/Emit/DependenciesTest.cs b/src/cs/Bootsharp.Publish.Test/Emit/DependenciesTest.cs index 2509208a..a542fb74 100644 --- a/src/cs/Bootsharp.Publish.Test/Emit/DependenciesTest.cs +++ b/src/cs/Bootsharp.Publish.Test/Emit/DependenciesTest.cs @@ -8,7 +8,7 @@ public class DependenciesTest : EmitTest protected override string TestedContent => GeneratedDependencies; [Fact] - public void WhenNothingInspectedIncludesCommonDependencies () + public void AddsCommonDependencies () { Execute(); Contains( @@ -28,31 +28,55 @@ internal static void RegisterDynamicDependencies () { } } [Fact] - public void AddsGeneratedExportTypes () + public void AddsStaticInteropInterfaceImplementations () { AddAssembly( - With("[assembly:JSExport(typeof(IFoo), typeof(Space.IBar))]"), - With("public interface IFoo {}"), - With("Space", "public interface IBar {}")); + With("[assembly:JSExport(typeof(IExported), typeof(Space.IExported))]"), + With("[assembly:JSImport(typeof(IImported), typeof(Space.IImported))]"), + With("public interface IExported {}"), + With("public interface IImported {}"), + With("Space", "public interface IExported {}"), + With("Space", "public interface IImported {}")); Execute(); - Added(All, "Bootsharp.Generated.Exports.JSFoo"); - Added(All, "Bootsharp.Generated.Exports.Space.JSBar"); + Added(All, "Bootsharp.Generated.Exports.JSExported"); + Added(All, "Bootsharp.Generated.Exports.Space.JSExported"); + Added(All, "Bootsharp.Generated.Imports.JSImported"); + Added(All, "Bootsharp.Generated.Imports.Space.JSImported"); } [Fact] - public void AddsGeneratedImportTypes () + public void AddsInstancedInteropInterfaceImplementations () { - AddAssembly( - With("[assembly:JSImport(typeof(IFoo), typeof(Space.IBar))]"), - With("public interface IFoo {}"), - With("Space", "public interface IBar {}")); + AddAssembly(With( + """ + [assembly:JSExport(typeof(IExportedStatic))] + [assembly:JSImport(typeof(IImportedStatic))] + + public interface IExportedStatic { IExportedInstancedA CreateExported (); } + public interface IImportedStatic { IImportedInstancedA CreateImported (); } + + public interface IExportedInstancedA { } + public interface IExportedInstancedB { } + public interface IImportedInstancedA { } + public interface IImportedInstancedB { } + + public class Class + { + [JSInvokable] public static IExportedInstancedB CreateExported () => default; + [JSFunction] public static IImportedInstancedB CreateImported () => default; + } + """)); Execute(); - Added(All, "Bootsharp.Generated.Imports.JSFoo"); - Added(All, "Bootsharp.Generated.Imports.Space.JSBar"); + Added(All, "Bootsharp.Generated.Exports.JSExportedStatic"); + Added(All, "Bootsharp.Generated.Imports.JSImportedStatic"); + Added(All, "Bootsharp.Generated.Imports.JSImportedInstancedA"); + Added(All, "Bootsharp.Generated.Imports.JSImportedInstancedB"); + // Export interop instances are not generated in C#; they're authored by user. + Assert.DoesNotContain("Bootsharp.Generated.Exports.JSExportedInstanced", TestedContent); } [Fact] - public void AddsClassesWithInteropMethods () + public void AddsClassesWithStaticInteropMethods () { AddAssembly("Assembly.With.Dots.dll", With("SpaceA", "public class ClassA { [JSInvokable] public static void Foo () {} }"), diff --git a/src/cs/Bootsharp.Publish.Test/Emit/InterfacesTest.cs b/src/cs/Bootsharp.Publish.Test/Emit/InterfacesTest.cs index 11321042..b1e78d6a 100644 --- a/src/cs/Bootsharp.Publish.Test/Emit/InterfacesTest.cs +++ b/src/cs/Bootsharp.Publish.Test/Emit/InterfacesTest.cs @@ -5,7 +5,7 @@ public class InterfacesTest : EmitTest protected override string TestedContent => GeneratedInterfaces; [Fact] - public void GeneratesInteropClassForExportedInterface () + public void GeneratesImplementationForExportedStaticInterface () { AddAssembly(With( """ @@ -61,7 +61,7 @@ internal static void RegisterInterfaces () } [Fact] - public void GeneratesImplementationForImportedInterface () + public void GeneratesImplementationForImportedStaticInterface () { AddAssembly(With( """ @@ -85,11 +85,11 @@ namespace Bootsharp.Generated.Imports { public class JSImported : global::IImported { - [JSFunction] public static void Inv (global::System.String? a) => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.Inv")(a); - [JSFunction] public static global::System.Threading.Tasks.Task InvAsync () => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.InvAsync")(); - [JSFunction] public static global::Record? InvRecord () => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.InvRecord")(); - [JSFunction] public static global::System.Threading.Tasks.Task InvAsyncResult () => Proxies.Get>>("Bootsharp.Generated.Imports.JSImported.InvAsyncResult")(); - [JSFunction] public static global::System.String[] InvArray (global::System.Int32[] a) => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.InvArray")(a); + [JSFunction] public static void Inv (global::System.String? a) => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.Inv")(a); + [JSFunction] public static global::System.Threading.Tasks.Task InvAsync () => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.InvAsync")(); + [JSFunction] public static global::Record? InvRecord () => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.InvRecord")(); + [JSFunction] public static global::System.Threading.Tasks.Task InvAsyncResult () => Proxies.Get>>("Bootsharp.Generated.Imports.JSImported.InvAsyncResult")(); + [JSFunction] public static global::System.String[] InvArray (global::System.Int32[] a) => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.InvArray")(a); void global::IImported.Inv (global::System.String? a) => Inv(a); global::System.Threading.Tasks.Task global::IImported.InvAsync () => InvAsync(); @@ -115,6 +115,40 @@ internal static void RegisterInterfaces () """); } + [Fact] + public void GeneratesImplementationForInstancedImportInterface () + { + AddAssembly(With( + """ + public interface IExported { void Inv (string arg); } + public interface IImported { void Fun (string arg); void NotifyEvt(string arg); } + + public class Class + { + [JSInvokable] public static IExported GetExported () => default; + [JSFunction] public static IImported GetImported () => Proxies.Get>("Class.GetImported")(); + } + """)); + Execute(); + Contains( + """ + namespace Bootsharp.Generated.Imports + { + public class JSImported(global::System.Int32 _id) : global::IImported + { + ~JSImported() => global::Bootsharp.Generated.Interop.DisposeImportedInstance(_id); + + [JSFunction] public static void Fun (global::System.Int32 _id, global::System.String arg) => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.Fun")(_id, arg); + [JSEvent] public static void OnEvt (global::System.Int32 _id, global::System.String arg) => Proxies.Get>("Bootsharp.Generated.Imports.JSImported.OnEvt")(_id, arg); + + void global::IImported.Fun (global::System.String arg) => Fun(_id, arg); + void global::IImported.NotifyEvt (global::System.String arg) => OnEvt(_id, arg); + } + } + """); + Assert.DoesNotContain("JSExported", TestedContent); // Exported instances are authored by user and registered on initial interop. + } + [Fact] public void RespectsInterfaceNamespace () { @@ -151,7 +185,7 @@ namespace Bootsharp.Generated.Imports.Space { public class JSImported : global::Space.IImported { - [JSFunction] public static void Fun (global::Space.Record a) => Proxies.Get>("Bootsharp.Generated.Imports.Space.JSImported.Fun")(a); + [JSFunction] public static void Fun (global::Space.Record a) => Proxies.Get>("Bootsharp.Generated.Imports.Space.JSImported.Fun")(a); void global::Space.IImported.Fun (global::Space.Record a) => Fun(a); } @@ -190,7 +224,7 @@ namespace Bootsharp.Generated.Imports { public class JSImported : global::IImported { - [JSEvent] public static void OnFoo () => Proxies.Get("Bootsharp.Generated.Imports.JSImported.OnFoo")(); + [JSEvent] public static void OnFoo () => Proxies.Get("Bootsharp.Generated.Imports.JSImported.OnFoo")(); void global::IImported.NotifyFoo () => OnFoo(); } @@ -219,8 +253,8 @@ namespace Bootsharp.Generated.Imports { public class JSImported : global::IImported { - [JSFunction] public static void NotifyFoo () => Proxies.Get("Bootsharp.Generated.Imports.JSImported.NotifyFoo")(); - [JSEvent] public static void OnBar () => Proxies.Get("Bootsharp.Generated.Imports.JSImported.OnBar")(); + [JSFunction] public static void NotifyFoo () => Proxies.Get("Bootsharp.Generated.Imports.JSImported.NotifyFoo")(); + [JSEvent] public static void OnBar () => Proxies.Get("Bootsharp.Generated.Imports.JSImported.OnBar")(); void global::IImported.NotifyFoo () => NotifyFoo(); void global::IImported.BroadcastBar () => OnBar(); @@ -242,4 +276,27 @@ internal static void RegisterInterfaces () } """); } + + [Fact] + public void IgnoresImplementedInterfaceMethods () + { + AddAssembly(With( + """ + [assembly:JSExport(typeof(IExportedStatic))] + [assembly:JSImport(typeof(IImportedStatic))] + + public interface IExportedStatic { int Foo () => 0; } + public interface IImportedStatic { int Foo () => 0; } + public interface IExportedInstanced { int Foo () => 0; } + public interface IImportedInstanced { int Foo () => 0; } + + public class Class + { + [JSInvokable] public static IExportedInstanced GetExported () => default; + [JSFunction] public static IImportedInstanced GetImported () => default; + } + """)); + Execute(); + Assert.DoesNotContain("Foo", TestedContent, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/cs/Bootsharp.Publish.Test/Emit/InteropTest.cs b/src/cs/Bootsharp.Publish.Test/Emit/InteropTest.cs index 1730974b..f49c8b07 100644 --- a/src/cs/Bootsharp.Publish.Test/Emit/InteropTest.cs +++ b/src/cs/Bootsharp.Publish.Test/Emit/InteropTest.cs @@ -5,7 +5,7 @@ public class InteropTest : EmitTest protected override string TestedContent => GeneratedInterop; [Fact] - public void WhenNothingInspectedGeneratesEmptyClass () + public void WhenNothingInspectedGeneratesDefaults () { Execute(); Contains( @@ -22,6 +22,14 @@ internal static void RegisterProxies () """); } + [Fact] + public void GeneratesDisposeInstanceBindings () + { + Execute(); + Contains("JSExport] internal static void DisposeExportedInstance (global::System.Int32 id) => global::Bootsharp.Instances.Dispose(id);"); + Contains("""JSImport("disposeInstance", "Bootsharp")] internal static partial void DisposeImportedInstance (global::System.Int32 id);"""); + } + [Fact] public void GeneratesForMethodsWithoutNamespace () { @@ -37,9 +45,9 @@ [JSInvokable] public static void Inv () {} Execute(); Contains("""Proxies.Set("Class.Fun", () => Class_Fun());"""); Contains("""Proxies.Set("Class.Evt", () => Class_Evt());"""); - Contains("[System.Runtime.InteropServices.JavaScript.JSExport] internal static void Class_Inv () => global::Class.Inv();"); - Contains("""[System.Runtime.InteropServices.JavaScript.JSImport("Class.funSerialized", "Bootsharp")] internal static partial void Class_Fun ();"""); - Contains("""[System.Runtime.InteropServices.JavaScript.JSImport("Class.evtSerialized", "Bootsharp")] internal static partial void Class_Evt ();"""); + Contains("JSExport] internal static void Class_Inv () => global::Class.Inv();"); + Contains("""JSImport("Class.funSerialized", "Bootsharp")] internal static partial void Class_Fun ();"""); + Contains("""JSImport("Class.evtSerialized", "Bootsharp")] internal static partial void Class_Evt ();"""); } [Fact] @@ -80,7 +88,7 @@ [JSInvokable] public static void Inv () {} } [Fact] - public void GeneratesForMethodsInGeneratedClasses () + public void GeneratesForStaticInteropInterfaces () { AddAssembly(With( """ @@ -98,6 +106,61 @@ public interface IImported { void Fun (); void NotifyEvt(); } Contains("""JSImport("Imported.onEvtSerialized", "Bootsharp")] internal static partial void Bootsharp_Generated_Imports_JSImported_OnEvt ();"""); } + [Fact] + public void GeneratesForInstancedInteropInterfaces () + { + AddAssembly(With( + """ + namespace Space + { + public interface IExported { void Inv (); } + public interface IImported { void Fun (); } + } + + public interface IExported { void Inv (); } + public interface IImported { void NotifyEvt(); } + + public class Class + { + [JSInvokable] public static Task GetExported (Space.IImported arg) => default; + [JSFunction] public static Task GetImported (IExported arg) => Proxies.Get>>("Class.GetImported")(arg); + } + """)); + Execute(); + Contains("""Proxies.Set("Class.GetImported", async (global::IExported arg) => (global::IImported)new global::Bootsharp.Generated.Imports.JSImported(await Class_GetImported(global::Bootsharp.Instances.Register(arg))));"""); + Contains("""Proxies.Set("Bootsharp.Generated.Imports.JSImported.OnEvt", (global::System.Int32 _id) => Bootsharp_Generated_Imports_JSImported_OnEvt(_id));"""); + Contains("""Proxies.Set("Bootsharp.Generated.Imports.Space.JSImported.Fun", (global::System.Int32 _id) => Bootsharp_Generated_Imports_Space_JSImported_Fun(_id));"""); + Contains("JSExport] internal static async global::System.Threading.Tasks.Task Class_GetExported (global::System.Int32 arg) => global::Bootsharp.Instances.Register(await global::Class.GetExported(new global::Bootsharp.Generated.Imports.Space.JSImported(arg)));"); + Contains("""JSImport("Class.getImportedSerialized", "Bootsharp")] internal static partial global::System.Threading.Tasks.Task Class_GetImported (global::System.Int32 arg);"""); + Contains("JSExport] internal static void Bootsharp_Generated_Exports_JSExported_Inv (global::System.Int32 _id) => ((global::IExported)global::Bootsharp.Instances.Get(_id)).Inv();"); + Contains("""JSImport("Imported.onEvtSerialized", "Bootsharp")] internal static partial void Bootsharp_Generated_Imports_JSImported_OnEvt (global::System.Int32 _id);"""); + Contains("JSExport] internal static void Bootsharp_Generated_Exports_Space_JSExported_Inv (global::System.Int32 _id) => ((global::Space.IExported)global::Bootsharp.Instances.Get(_id)).Inv();"); + Contains("""JSImport("Space.Imported.funSerialized", "Bootsharp")] internal static partial void Bootsharp_Generated_Imports_Space_JSImported_Fun (global::System.Int32 _id);"""); + } + + [Fact] + public void IgnoresImplementedInterfaceMethods () + { + AddAssembly(With( + """ + [assembly:JSExport(typeof(IExportedStatic))] + [assembly:JSImport(typeof(IImportedStatic))] + + public interface IExportedStatic { int Foo () => 0; } + public interface IImportedStatic { int Foo () => 0; } + public interface IExportedInstanced { int Foo () => 0; } + public interface IImportedInstanced { int Foo () => 0; } + + public class Class + { + [JSInvokable] public static IExportedInstanced GetExported () => default; + [JSFunction] public static IImportedInstanced GetImported () => default; + } + """)); + Execute(); + Assert.DoesNotContain("Foo", TestedContent, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void DoesntSerializeTypesThatShouldNotBeSerialized () { diff --git a/src/cs/Bootsharp.Publish.Test/Emit/SerializerTest.cs b/src/cs/Bootsharp.Publish.Test/Emit/SerializerTest.cs index 73c6e48a..6c3c78f0 100644 --- a/src/cs/Bootsharp.Publish.Test/Emit/SerializerTest.cs +++ b/src/cs/Bootsharp.Publish.Test/Emit/SerializerTest.cs @@ -22,6 +22,30 @@ public void WhenNoSerializableTypesIsEmpty () Assert.DoesNotContain("JsonSerializable", TestedContent); } + [Fact] + public void DoesntSerializeInstancedInteropInterfaces () + { + AddAssembly(With( + """ + namespace Space + { + public interface IExported { void Inv (); } + public interface IImported { void Fun (); void NotifyEvt(); } + } + + public interface IExported { void Inv (); } + public interface IImported { void Fun (); void NotifyEvt(); } + + public class Class + { + [JSInvokable] public static Space.IExported GetExported (Space.IImported arg) => default; + [JSFunction] public static Task GetImported (IExported arg) => default; + } + """)); + Execute(); + Assert.DoesNotContain("JsonSerializable", TestedContent); + } + [Fact] // .NET's generator indexes types by short names (w/o namespace) and fails on duplicates. public void AddsOnlyTopLevelTypesAndCrawledDuplicates () { diff --git a/src/cs/Bootsharp.Publish.Test/Pack/BindingTest.cs b/src/cs/Bootsharp.Publish.Test/Pack/BindingTest.cs index b3761b9f..4ae65202 100644 --- a/src/cs/Bootsharp.Publish.Test/Pack/BindingTest.cs +++ b/src/cs/Bootsharp.Publish.Test/Pack/BindingTest.cs @@ -14,12 +14,14 @@ public void WhenNoBindingsNothingIsGenerated () [Fact] public void InteropFunctionsImported () { - AddAssembly(WithClass("Foo", "[JSInvokable] public static void Bar () {}")); + AddAssembly(WithClass("[JSInvokable] public static void Inv () {}")); Execute(); Contains( """ import { exports } from "./exports"; import { Event } from "./event"; + import { registerInstance, getInstance, disposeOnFinalize } from "./instances"; + function getExports () { if (exports == null) throw Error("Boot the runtime before invoking C# APIs."); return exports; } function serialize(obj) { return JSON.stringify(obj); } function deserialize(json) { const result = JSON.parse(json); if (result === null) return undefined; return result; } @@ -434,7 +436,7 @@ public static class Imports { [JSFunction] public static void Fun () {} } } [Fact] - public void GeneratesForExportImportInterfaces () + public void GeneratesForStaticInteropInterfaces () { AddAssembly(With( """ @@ -468,7 +470,7 @@ public interface IImported { void Fun (string s, Enum e); void NotifyEvt (string } [Fact] - public void GeneratesForExportImportInterfacesWithSpacePref () + public void GeneratesForStaticInteropInterfacesWithSpacePref () { AddAssembly(With( """ @@ -497,4 +499,86 @@ public interface IImported { void Fun (string s, Enum e); void NotifyEvt (string }; """); } + + [Fact] + public void GeneratesForInstancedInteropInterfaces () + { + AddAssembly(With( + """ + public enum Enum { A, B } + + public interface IExported { Enum Inv (string str); } + public interface IImported { void NotifyEvt(string str); } + + namespace Space + { + public interface IExported { void Inv (Enum en); } + public interface IImported { Enum Fun (Enum en); } + } + + public class Class + { + [JSInvokable] public static Task GetExported (Space.IImported inst) => default; + [JSFunction] public static Task GetImported (IExported inst) => Proxies.Get>>("Class.GetImported")(inst); + } + """)); + Execute(); + Contains( + """ + class Space_JSExported { + constructor(_id) { this._id = _id; disposeOnFinalize(this, _id); } + inv(en) { Space.Exported.inv(this._id, en); } + } + class JSExported { + constructor(_id) { this._id = _id; disposeOnFinalize(this, _id); } + inv(str) { return Exported.inv(this._id, str); } + } + """); + Contains( + """ + export const Class = { + getExported: async (inst) => new Space_JSExported(await getExports().Class_GetExported(registerInstance(inst))), + get getImported() { return this.getImportedHandler; }, + set getImported(handler) { this.getImportedHandler = handler; this.getImportedSerializedHandler = async (inst) => registerInstance(await this.getImportedHandler(new JSExported(inst))); }, + get getImportedSerialized() { if (typeof this.getImportedHandler !== "function") throw Error("Failed to invoke 'Class.getImported' from C#. Make sure to assign function in JavaScript."); return this.getImportedSerializedHandler; } + }; + export const Exported = { + inv: (_id, str) => deserialize(getExports().Bootsharp_Generated_Exports_JSExported_Inv(_id, str)) + }; + export const Imported = { + onEvtSerialized: (_id, str) => getInstance(_id).onEvt.broadcast(str) + }; + export const Space = { + Exported: { + inv: (_id, en) => getExports().Bootsharp_Generated_Exports_Space_JSExported_Inv(_id, serialize(en)) + }, + Imported: { + funSerialized: (_id, en) => serialize(getInstance(_id).fun(deserialize(en))) + } + }; + """); + } + + [Fact] + public void IgnoresImplementedInterfaceMethods () + { + AddAssembly(With( + """ + [assembly:JSExport(typeof(IExportedStatic))] + [assembly:JSImport(typeof(IImportedStatic))] + + public interface IExportedStatic { int Foo () => 0; } + public interface IImportedStatic { int Foo () => 0; } + public interface IExportedInstanced { int Foo () => 0; } + public interface IImportedInstanced { int Foo () => 0; } + + public class Class + { + [JSInvokable] public static IExportedInstanced GetExported () => default; + [JSFunction] public static IImportedInstanced GetImported () => default; + } + """)); + Execute(); + Assert.DoesNotContain("Foo", TestedContent, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/cs/Bootsharp.Publish.Test/Pack/DeclarationTest.cs b/src/cs/Bootsharp.Publish.Test/Pack/DeclarationTest.cs index e38d1686..123bde3f 100644 --- a/src/cs/Bootsharp.Publish.Test/Pack/DeclarationTest.cs +++ b/src/cs/Bootsharp.Publish.Test/Pack/DeclarationTest.cs @@ -179,8 +179,15 @@ public void DifferentSpacesWithSameRootAreDeclaredIndividually () WithClass("Nya.Bar", "[JSInvokable] public static void Fun () { }"), WithClass("Nya.Foo", "[JSInvokable] public static void Foo () { }")); Execute(); - Contains("export namespace Nya.Bar.Class {\n export function fun(): void;\n}"); - Contains("export namespace Nya.Foo.Class {\n export function foo(): void;\n}"); + Contains( + """ + export namespace Nya.Bar.Class { + export function fun(): void; + } + export namespace Nya.Foo.Class { + export function foo(): void; + } + """); } [Fact] @@ -292,6 +299,14 @@ public void IntArraysTranslatedToRelatedTypes () Contains("bigInt64(foo: BigInt64Array): void"); } + [Fact] + public void OtherTypesAreTranslatedToAny () + { + AddAssembly(WithClass("[JSInvokable] public static DBNull Method (IEnumerable t) => default;")); + Execute(); + Contains("method(t: any): any"); + } + [Fact] public void DefinitionIsGeneratedForObjectType () { @@ -299,8 +314,19 @@ public void DefinitionIsGeneratedForObjectType () With("n", "public class Foo { public string S { get; set; } public int I { get; set; } }"), WithClass("n", "[JSInvokable] public static Foo Method (Foo t) => default;")); Execute(); - Matches(@"export interface Foo {\s*s: string;\s*i: number;\s*}"); - Contains("method(t: n.Foo): n.Foo"); + Contains( + """ + export namespace n { + export interface Foo { + s: string; + i: number; + } + } + + export namespace n.Class { + export function method(t: n.Foo): n.Foo; + } + """); } [Fact] @@ -310,12 +336,25 @@ public void DefinitionIsGeneratedForInterfaceAndImplementation () With("n", "public interface Interface { Interface Foo { get; } void Bar (Interface b); }"), With("n", "public class Base { }"), With("n", "public class Derived : Base, Interface { public Interface Foo { get; } public void Bar (Interface b) {} }"), - WithClass("n", "[JSInvokable] public static Derived Method (Interface b) => default;")); + WithClass("n", "[JSInvokable] public static Derived Method (Base b) => default;")); Execute(); - Matches(@"export interface Interface {\s*foo: n.Interface;\s*}"); - Matches(@"export interface Base {\s*}"); - Matches(@"export interface Derived extends n.Base, n.Interface {\s*foo: n.Interface;\s*}"); - Contains("method(b: n.Interface): n.Derived"); + Contains( + """ + export namespace n { + export interface Base { + } + export interface Derived extends n.Base, n.Interface { + foo: n.Interface; + } + export interface Interface { + foo: n.Interface; + } + } + + export namespace n.Class { + export function method(b: n.Base): n.Derived; + } + """); } [Fact] @@ -326,9 +365,20 @@ public void DefinitionIsGeneratedForTypeWithListProperty () With("n", "public class Container { public List Items { get; } }"), WithClass("n", "[JSInvokable] public static Container Combine (List items) => default;")); Execute(); - Matches(@"export interface Item {\s*}"); - Matches(@"export interface Container {\s*items: Array;\s*}"); - Contains("combine(items: Array): n.Container"); + Contains( + """ + export namespace n { + export interface Item { + } + export interface Container { + items: Array; + } + } + + export namespace n.Class { + export function combine(items: Array): n.Container; + } + """); } [Fact] @@ -339,9 +389,20 @@ public void DefinitionIsGeneratedForTypeWithJaggedArrayProperty () With("n", "public class Container { public Item[][] Items { get; } }"), WithClass("n", "[JSInvokable] public static Container Get () => default;")); Execute(); - Matches(@"export interface Item {\s*}"); - Matches(@"export interface Container {\s*items: Array>;\s*}"); - Contains("get(): n.Container"); + Contains( + """ + export namespace n { + export interface Container { + items: Array>; + } + export interface Item { + } + } + + export namespace n.Class { + export function get(): n.Container; + } + """); } [Fact] @@ -352,9 +413,20 @@ public void DefinitionIsGeneratedForTypeWithReadOnlyListProperty () With("n", "public class Container { public IReadOnlyList Items { get; } }"), WithClass("n", "[JSInvokable] public static Container Combine (IReadOnlyList items) => default;")); Execute(); - Matches(@"export interface Item {\s*}"); - Matches(@"export interface Container {\s*items: Array;\s*}"); - Contains("combine(items: Array): n.Container"); + Contains( + """ + export namespace n { + export interface Item { + } + export interface Container { + items: Array; + } + } + + export namespace n.Class { + export function combine(items: Array): n.Container; + } + """); } [Fact] @@ -365,9 +437,20 @@ public void DefinitionIsGeneratedForTypeWithDictionaryProperty () With("n", "public class Container { public Dictionary Items { get; } }"), WithClass("n", "[JSInvokable] public static Container Combine (Dictionary items) => default;")); Execute(); - Matches(@"export interface Item {\s*}"); - Matches(@"export interface Container {\s*items: Map;\s*}"); - Contains("combine(items: Map): n.Container"); + Contains( + """ + export namespace n { + export interface Item { + } + export interface Container { + items: Map; + } + } + + export namespace n.Class { + export function combine(items: Map): n.Container; + } + """); } [Fact] @@ -378,9 +461,20 @@ public void DefinitionIsGeneratedForTypeWithReadOnlyDictionaryProperty () With("n", "public class Container { public IReadOnlyDictionary Items { get; } }"), WithClass("n", "[JSInvokable] public static Container Combine (IReadOnlyDictionary items) => default;")); Execute(); - Matches(@"export interface Item {\s*}"); - Matches(@"export interface Container {\s*items: Map;\s*}"); - Contains("combine(items: Map): n.Container"); + Contains( + """ + export namespace n { + export interface Item { + } + export interface Container { + items: Map; + } + } + + export namespace n.Class { + export function combine(items: Map): n.Container; + } + """); } [Fact] @@ -534,14 +628,6 @@ export namespace Space.Class { """); } - [Fact] - public void OtherTypesAreTranslatedToAny () - { - AddAssembly(WithClass("[JSInvokable] public static DBNull Method (IEnumerable t) => default;")); - Execute(); - Contains("method(t: any): any"); - } - [Fact] public void StaticPropertiesAreNotIncluded () { @@ -549,7 +635,17 @@ public void StaticPropertiesAreNotIncluded () WithClass("public class Foo { public static string Soo { get; } }"), WithClass("[JSInvokable] public static Foo Bar () => default;")); Execute(); - Matches(@"export interface Foo {\s*}"); + Contains( + """ + export namespace Class { + export interface Foo { + } + } + + export namespace Class { + export function bar(): Class.Foo; + } + """); } [Fact] @@ -559,7 +655,17 @@ public void ExpressionPropertiesAreNotIncluded () WithClass("public class Foo { public bool Boo => true; }"), WithClass("[JSInvokable] public static Foo Bar () => default;")); Execute(); - Matches(@"export interface Foo {\s*}"); + Contains( + """ + export namespace Class { + export interface Foo { + } + } + + export namespace Class { + export function bar(): Class.Foo; + } + """); } [Fact] @@ -696,23 +802,36 @@ public void RespectsSpacePreference () With("Foo.Bar.Nya", "public class Nya { }"), WithClass("Foo.Bar.Fun", "[JSFunction] public static void OnFun (Nya.Nya nya) { }")); Execute(); - Contains("export namespace Nya {\n export interface Nya {\n }\n}"); - Contains("export namespace Fun.Class {\n export let onFun: (nya: Nya.Nya) => void;\n}"); + Contains( + """ + export namespace Nya { + export interface Nya { + } + } + + export namespace Fun.Class { + export let onFun: (nya: Nya.Nya) => void; + } + """); } [Fact] public void RespectsTypePreference () { - AddAssembly( - With( - """ - [assembly: Bootsharp.JSPreferences( - Type = [@"Record", "Foo", @".+`.+", "Bar"] - )] - """), - With("public record Record;"), - With("public record Generic;"), - WithClass("[JSInvokable] public static void Inv (Record r, Generic g) {}")); + AddAssembly(With( + """ + [assembly: Bootsharp.JSPreferences( + Type = [@"Record", "Foo", @".+`.+", "Bar"] + )] + + public record Record; + public record Generic; + + public class Class + { + [JSInvokable] public static void Inv (Record r, Generic g) {} + } + """)); Execute(); Contains( """ @@ -810,4 +929,121 @@ export namespace Foo { } """); } + + [Fact] + public void GeneratesInstancedInterfacesFromStaticMethods () + { + AddAssembly(With( + """ + public enum Enum { A, B } + public interface IExportedInstancedA { void Inv (string s, Enum e); } + public interface IExportedInstancedB { Enum Inv (); } + public interface IImportedInstancedA { void Fun (string s, Enum e); void NotifyEvt (string s, Enum e); } + public interface IImportedInstancedB { Enum Fun (); void NotifyEvt (); } + + public class Class + { + [JSInvokable] public static IExportedInstancedA CreateExported (string arg, IImportedInstancedB i) => default; + [JSFunction] public static IImportedInstancedA CreateImported (string arg, IExportedInstancedB i) => default; + } + """)); + Execute(); + Contains( + """ + export interface IImportedInstancedB { + fun(): Enum; + onEvt: Event<[]>; + } + export interface IExportedInstancedA { + inv(s: string, e: Enum): void; + } + export enum Enum { + A, + B + } + export interface IExportedInstancedB { + inv(): Enum; + } + export interface IImportedInstancedA { + fun(s: string, e: Enum): void; + onEvt: Event<[s: string, e: Enum]>; + } + + export namespace Class { + export function createExported(arg: string, i: IImportedInstancedB): IExportedInstancedA; + export let createImported: (arg: string, i: IExportedInstancedB) => IImportedInstancedA; + } + """); + } + + [Fact] + public void GeneratesInstancedInterfacesFromStaticInterfaces () + { + AddAssembly(With( + """ + [assembly:JSExport(typeof(IExportedStatic))] + [assembly:JSImport(typeof(IImportedStatic))] + + public interface IExportedStatic { IExportedInstancedA CreateExported (string arg, IImportedInstancedB i); } + public interface IImportedStatic { IImportedInstancedA CreateImported (string arg, IExportedInstancedB i); } + + public enum Enum { A, B } + public interface IExportedInstancedA { void Inv (string s, Enum e); } + public interface IExportedInstancedB { Enum Inv (); } + public interface IImportedInstancedA { void Fun (string s, Enum e); void NotifyEvt (string s, Enum e); } + public interface IImportedInstancedB { Enum Fun (); void NotifyEvt (); } + """)); + Execute(); + Contains( + """ + export interface IImportedInstancedB { + fun(): Enum; + onEvt: Event<[]>; + } + export interface IExportedInstancedA { + inv(s: string, e: Enum): void; + } + export enum Enum { + A, + B + } + export interface IExportedInstancedB { + inv(): Enum; + } + export interface IImportedInstancedA { + fun(s: string, e: Enum): void; + onEvt: Event<[s: string, e: Enum]>; + } + + export namespace ExportedStatic { + export function createExported(arg: string, i: IImportedInstancedB): IExportedInstancedA; + } + export namespace ImportedStatic { + export let createImported: (arg: string, i: IExportedInstancedB) => IImportedInstancedA; + } + """); + } + + [Fact] + public void IgnoresImplementedInterfaceMethods () + { + AddAssembly(With( + """ + [assembly:JSExport(typeof(IExportedStatic))] + [assembly:JSImport(typeof(IImportedStatic))] + + public interface IExportedStatic { int Foo () => 0; } + public interface IImportedStatic { int Foo () => 0; } + public interface IExportedInstanced { int Foo () => 0; } + public interface IImportedInstanced { int Foo () => 0; } + + public class Class + { + [JSInvokable] public static IExportedInstanced GetExported () => default; + [JSFunction] public static IImportedInstanced GetImported () => default; + } + """)); + Execute(); + Assert.DoesNotContain("Foo", TestedContent, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/cs/Bootsharp.Publish.Test/Pack/AssemblyInspectionTest.cs b/src/cs/Bootsharp.Publish.Test/Pack/SolutionInspectionTest.cs similarity index 94% rename from src/cs/Bootsharp.Publish.Test/Pack/AssemblyInspectionTest.cs rename to src/cs/Bootsharp.Publish.Test/Pack/SolutionInspectionTest.cs index 6d96f311..627ebc1a 100644 --- a/src/cs/Bootsharp.Publish.Test/Pack/AssemblyInspectionTest.cs +++ b/src/cs/Bootsharp.Publish.Test/Pack/SolutionInspectionTest.cs @@ -1,6 +1,6 @@ namespace Bootsharp.Publish.Test; -public class AssemblyInspectionTest : PackTest +public class SolutionInspectionTest : PackTest { [Fact] public void AllAssembliesAreInspected () diff --git a/src/cs/Bootsharp.Publish.Test/TypesTest.cs b/src/cs/Bootsharp.Publish.Test/TypesTest.cs index 8321e165..5c3d0217 100644 --- a/src/cs/Bootsharp.Publish.Test/TypesTest.cs +++ b/src/cs/Bootsharp.Publish.Test/TypesTest.cs @@ -6,11 +6,10 @@ public class TypesTest public void Temp () { // TODO: Remove when coverlet bug is resolved: https://github.com/coverlet-coverage/coverlet/issues/1561 - _ = new InterfaceMeta { Kind = default, TypeSyntax = "", Name = "", Namespace = "", Methods = [] } with { Name = "foo" }; - _ = new InterfaceMethodMeta { Name = "", Generated = default } with { Name = "foo" }; + _ = new InterfaceMeta { Kind = default, Type = default, TypeSyntax = "", Name = "", Namespace = "", Methods = [] } with { Name = "foo" }; _ = new MethodMeta { Name = "", JSName = "", Arguments = default, Assembly = "", Kind = default, Space = "", JSSpace = "", ReturnValue = default } with { Assembly = "foo" }; _ = new ArgumentMeta { Name = "", JSName = "", Value = default } with { Name = "foo" }; - _ = new ValueMeta { Type = default, Nullable = true, TypeSyntax = "", Void = true, Serialized = true, Async = true, JSTypeSyntax = "" } with { TypeSyntax = "foo" }; + _ = new ValueMeta { Type = default, Nullable = true, TypeSyntax = "", Void = true, Serialized = true, Async = true, JSTypeSyntax = "", Instance = false, InstanceType = null } with { TypeSyntax = "foo" }; _ = new Preferences { Event = [] } with { Function = [] }; } } diff --git a/src/cs/Bootsharp.Publish/Common/AssemblyInspector/AssemblyInspection.cs b/src/cs/Bootsharp.Publish/Common/AssemblyInspector/AssemblyInspection.cs deleted file mode 100644 index 949ed269..00000000 --- a/src/cs/Bootsharp.Publish/Common/AssemblyInspector/AssemblyInspection.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Reflection; - -namespace Bootsharp.Publish; - -internal class AssemblyInspection (MetadataLoadContext ctx) : IDisposable -{ - public required IReadOnlyCollection Interfaces { get; init; } - public required IReadOnlyCollection Methods { get; init; } - public required IReadOnlyCollection Crawled { get; init; } - public required IReadOnlyCollection Warnings { get; init; } - - public void Dispose () => ctx.Dispose(); -} diff --git a/src/cs/Bootsharp.Publish/Common/AssemblyInspector/AssemblyInspector.cs b/src/cs/Bootsharp.Publish/Common/AssemblyInspector/AssemblyInspector.cs deleted file mode 100644 index 2491fb69..00000000 --- a/src/cs/Bootsharp.Publish/Common/AssemblyInspector/AssemblyInspector.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Reflection; - -namespace Bootsharp.Publish; - -internal sealed class AssemblyInspector (Preferences prefs, string entryAssemblyName) -{ - private readonly List interfaces = []; - private readonly List methods = []; - private readonly List warnings = []; - private readonly TypeConverter converter = new(prefs); - - public AssemblyInspection InspectInDirectory (string directory, IEnumerable paths) - { - var ctx = CreateLoadContext(directory); - foreach (var assemblyPath in paths) - try { InspectAssemblyFile(assemblyPath, ctx); } - catch (Exception e) { AddSkippedAssemblyWarning(assemblyPath, e); } - return CreateInspection(ctx); - } - - private void InspectAssemblyFile (string assemblyPath, MetadataLoadContext ctx) - { - if (!ShouldIgnoreAssembly(assemblyPath)) - InspectAssembly(ctx.LoadFromAssemblyPath(assemblyPath)); - } - - private void AddSkippedAssemblyWarning (string assemblyPath, Exception exception) - { - var assemblyName = Path.GetFileName(assemblyPath); - var message = $"Failed to inspect '{assemblyName}' assembly; " + - $"affected methods won't be available in JavaScript. Error: {exception.Message}"; - warnings.Add(message); - } - - private AssemblyInspection CreateInspection (MetadataLoadContext ctx) => new(ctx) { - Interfaces = [..interfaces], - Methods = [..methods], - Crawled = [..converter.CrawledTypes], - Warnings = [..warnings] - }; - - private void InspectAssembly (Assembly assembly) - { - foreach (var exported in assembly.GetExportedTypes()) - InspectExportedType(exported); - foreach (var attribute in assembly.CustomAttributes) - InspectAssemblyAttribute(attribute); - } - - private void InspectExportedType (Type type) - { - if (type.Namespace?.StartsWith("Bootsharp.Generated") ?? false) return; - foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static)) - foreach (var attr in method.CustomAttributes) - if (attr.AttributeType.FullName == typeof(JSInvokableAttribute).FullName) - methods.Add(CreateMethod(method, MethodKind.Invokable)); - else if (attr.AttributeType.FullName == typeof(JSFunctionAttribute).FullName) - methods.Add(CreateMethod(method, MethodKind.Function)); - else if (attr.AttributeType.FullName == typeof(JSEventAttribute).FullName) - methods.Add(CreateMethod(method, MethodKind.Event)); - } - - private void InspectAssemblyAttribute (CustomAttributeData attribute) - { - var name = attribute.AttributeType.FullName; - var kind = name == typeof(JSExportAttribute).FullName ? InterfaceKind.Export - : name == typeof(JSImportAttribute).FullName ? InterfaceKind.Import - : (InterfaceKind?)null; - if (!kind.HasValue) return; - foreach (var arg in (IEnumerable)attribute.ConstructorArguments[0].Value!) - AddInterface((Type)arg.Value!, kind.Value); - } - - private void AddInterface (Type iType, InterfaceKind kind) - { - var meta = CreateInterface(iType, kind); - interfaces.Add(meta); - foreach (var method in meta.Methods) - methods.Add(method.Generated); - } - - private MethodMeta CreateMethod (MethodInfo info, MethodKind kind) => new() { - Kind = kind, - Assembly = info.DeclaringType!.Assembly.GetName().Name!, - Space = info.DeclaringType.FullName!, - Name = info.Name, - Arguments = info.GetParameters().Select(CreateArgument).ToArray(), - ReturnValue = new() { - Type = info.ReturnType, - TypeSyntax = BuildSyntax(info.ReturnType, info.ReturnParameter), - JSTypeSyntax = converter.ToTypeScript(info.ReturnType, GetNullability(info.ReturnParameter)), - Nullable = IsNullable(info), - Async = IsTaskLike(info.ReturnType), - Void = IsVoid(info.ReturnType), - Serialized = ShouldSerialize(info.ReturnType) - }, - JSSpace = BuildMethodSpace(info), - JSName = WithPrefs(prefs.Function, info.Name, ToFirstLower(info.Name)) - }; - - private ArgumentMeta CreateArgument (ParameterInfo info) => new() { - Name = info.Name!, - JSName = info.Name == "function" ? "fn" : info.Name!, - Value = new() { - Type = info.ParameterType, - TypeSyntax = BuildSyntax(info.ParameterType, info), - JSTypeSyntax = converter.ToTypeScript(info.ParameterType, GetNullability(info)), - Nullable = IsNullable(info), - Async = false, - Void = false, - Serialized = ShouldSerialize(info.ParameterType) - } - }; - - private InterfaceMeta CreateInterface (Type iType, InterfaceKind kind) - { - var space = "Bootsharp.Generated." + (kind == InterfaceKind.Export ? "Exports" : "Imports"); - if (iType.Namespace != null) space += $".{iType.Namespace}"; - var name = "JS" + iType.Name[1..]; - return new InterfaceMeta { - Kind = kind, - TypeSyntax = BuildSyntax(iType), - Namespace = space, - Name = name, - Methods = iType.GetMethods().Select(m => CreateInterfaceMethod(m, kind, $"{space}.{name}")).ToArray() - }; - } - - private InterfaceMethodMeta CreateInterfaceMethod (MethodInfo info, InterfaceKind iKind, string space) - { - var name = WithPrefs(prefs.Event, info.Name, info.Name); - var mKind = iKind == InterfaceKind.Export ? MethodKind.Invokable - : name != info.Name ? MethodKind.Event : MethodKind.Function; - return new() { - Name = info.Name, - Generated = CreateMethod(info, mKind) with { - Assembly = entryAssemblyName, - Space = space, - Name = name, - JSName = ToFirstLower(name) - } - }; - } - - private string BuildMethodSpace (MethodInfo info) - { - var space = info.DeclaringType!.Namespace ?? ""; - var name = BuildJSSpaceName(info.DeclaringType); - if (info.DeclaringType.IsInterface) name = name[1..]; - var fullname = string.IsNullOrEmpty(space) ? name : $"{space}.{name}"; - return WithPrefs(prefs.Space, fullname, fullname); - } -} diff --git a/src/cs/Bootsharp.Publish/Common/AssemblyInspector/InspectionReporter.cs b/src/cs/Bootsharp.Publish/Common/AssemblyInspector/InspectionReporter.cs deleted file mode 100644 index e3b41ca3..00000000 --- a/src/cs/Bootsharp.Publish/Common/AssemblyInspector/InspectionReporter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -namespace Bootsharp.Publish; - -internal sealed class InspectionReporter (TaskLoggingHelper logger) -{ - public void Report (AssemblyInspection inspection) - { - logger.LogMessage(MessageImportance.Normal, "Bootsharp assembly inspection result:"); - logger.LogMessage(MessageImportance.Normal, JoinLines("Discovered assemblies:", - JoinLines(inspection.Methods.GroupBy(m => m.Assembly).Select(g => g.Key)))); - logger.LogMessage(MessageImportance.Normal, JoinLines("Discovered interop methods:", - JoinLines(inspection.Methods.Select(m => m.ToString())))); - - foreach (var warning in inspection.Warnings) - logger.LogWarning(warning); - } -} diff --git a/src/cs/Bootsharp.Publish/Common/Meta/ArgumentMeta.cs b/src/cs/Bootsharp.Publish/Common/Meta/ArgumentMeta.cs index 48fa07ba..b52d0f48 100644 --- a/src/cs/Bootsharp.Publish/Common/Meta/ArgumentMeta.cs +++ b/src/cs/Bootsharp.Publish/Common/Meta/ArgumentMeta.cs @@ -1,9 +1,21 @@ namespace Bootsharp.Publish; +/// +/// Interop method argument. +/// internal sealed record ArgumentMeta { + /// + /// C# name of the argument, as specified in source code. + /// public required string Name { get; init; } + /// + /// JavaScript name of the argument, to be specified in source code. + /// public required string JSName { get; init; } + /// + /// Metadata of the argument's value. + /// public required ValueMeta Value { get; init; } public override string ToString () => $"{Name}: {Value.JSTypeSyntax}"; diff --git a/src/cs/Bootsharp.Publish/Common/Meta/InterfaceKind.cs b/src/cs/Bootsharp.Publish/Common/Meta/InterfaceKind.cs index d5d90eb6..5f38765d 100644 --- a/src/cs/Bootsharp.Publish/Common/Meta/InterfaceKind.cs +++ b/src/cs/Bootsharp.Publish/Common/Meta/InterfaceKind.cs @@ -1,7 +1,16 @@ namespace Bootsharp.Publish; +/// +/// The type of API interop interface represents. +/// internal enum InterfaceKind { + /// + /// The interface represents C# API consumed in JavaScript. + /// Export, + /// + /// The interface represents JavaScript API consumed in C#. + /// Import } diff --git a/src/cs/Bootsharp.Publish/Common/Meta/InterfaceMeta.cs b/src/cs/Bootsharp.Publish/Common/Meta/InterfaceMeta.cs index 86744ad4..a725aff2 100644 --- a/src/cs/Bootsharp.Publish/Common/Meta/InterfaceMeta.cs +++ b/src/cs/Bootsharp.Publish/Common/Meta/InterfaceMeta.cs @@ -1,11 +1,39 @@ namespace Bootsharp.Publish; +/// +/// Interface supplied by user under either +/// or representing static interop API, or in +/// an interop method, representing instanced interop API. +/// internal sealed record InterfaceMeta { + /// + /// Whether the interface represents C# API consumed in + /// JavaScript (export) or vice-versa (import). + /// public required InterfaceKind Kind { get; init; } + /// + /// C# type of the interface. + /// + public required Type Type { get; init; } + /// + /// C# syntax of the interface type, as specified in source code. + /// public required string TypeSyntax { get; init; } + /// + /// Namespace of the generated interop class implementation. + /// public required string Namespace { get; init; } + /// + /// Name of the generated interop class implementation. + /// public required string Name { get; init; } + /// + /// Full type name of the generated interop class implementation. + /// public string FullName => $"{Namespace}.{Name}"; - public required IReadOnlyCollection Methods { get; init; } + /// + /// Methods declared on the interface, representing the interop API. + /// + public required IReadOnlyCollection Methods { get; init; } } diff --git a/src/cs/Bootsharp.Publish/Common/Meta/InterfaceMethodMeta.cs b/src/cs/Bootsharp.Publish/Common/Meta/InterfaceMethodMeta.cs deleted file mode 100644 index 0c8ba196..00000000 --- a/src/cs/Bootsharp.Publish/Common/Meta/InterfaceMethodMeta.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bootsharp.Publish; - -internal sealed record InterfaceMethodMeta -{ - public required string Name { get; set; } - public required MethodMeta Generated { get; set; } -} diff --git a/src/cs/Bootsharp.Publish/Common/Meta/MethodKind.cs b/src/cs/Bootsharp.Publish/Common/Meta/MethodKind.cs index 5801aab0..2dfafc10 100644 --- a/src/cs/Bootsharp.Publish/Common/Meta/MethodKind.cs +++ b/src/cs/Bootsharp.Publish/Common/Meta/MethodKind.cs @@ -1,8 +1,23 @@ namespace Bootsharp.Publish; +/// +/// Type of interop method. +/// internal enum MethodKind { + /// + /// The method is implemented in C# and invoked from JavaScript; + /// implementation has . + /// Invokable, + /// + /// The method is implemented in JavaScript and invoked from C#; + /// implementation has . + /// Function, + /// + /// The method is invoked from C# to notify subscribers in JavaScript; + /// implementation has . + /// Event } diff --git a/src/cs/Bootsharp.Publish/Common/Meta/MethodMeta.cs b/src/cs/Bootsharp.Publish/Common/Meta/MethodMeta.cs index e58d5007..20e2b923 100644 --- a/src/cs/Bootsharp.Publish/Common/Meta/MethodMeta.cs +++ b/src/cs/Bootsharp.Publish/Common/Meta/MethodMeta.cs @@ -1,14 +1,49 @@ namespace Bootsharp.Publish; +/// +/// Interop method. +/// internal sealed record MethodMeta { + /// + /// Type of interop the method is implementing. + /// public required MethodKind Kind { get; init; } + /// + /// C# assembly name (DLL file name, w/o the extension), under which the method is declared. + /// public required string Assembly { get; init; } + /// + /// Full name of the C# type (including namespace), under which the method is declared. + /// public required string Space { get; init; } + /// + /// JavaScript object name(s) (joined with dot when nested) under which the associated interop + /// function will be declared; resolved from with user-defined converters. + /// public required string JSSpace { get; init; } + /// + /// C# name of the method, as specified in source code. + /// public required string Name { get; init; } + /// + /// JavaScript name of the method (function), as will be specified in source code. + /// public required string JSName { get; init; } + /// + /// When the method's class is a generated implementation of an interop interface, contains + /// name of the associated interface method. The name may differ from , + /// which would be the name of the method on the generated interface implementation and is + /// subject to and . + /// + public string? InterfaceName { get; init; } + /// + /// Arguments of the method, in declaration order. + /// public required IReadOnlyList Arguments { get; init; } + /// + /// Metadata of the value returned by the method. + /// public required ValueMeta ReturnValue { get; init; } public override string ToString () diff --git a/src/cs/Bootsharp.Publish/Common/Meta/ValueMeta.cs b/src/cs/Bootsharp.Publish/Common/Meta/ValueMeta.cs index d1a1ae26..98b10c7f 100644 --- a/src/cs/Bootsharp.Publish/Common/Meta/ValueMeta.cs +++ b/src/cs/Bootsharp.Publish/Common/Meta/ValueMeta.cs @@ -1,12 +1,47 @@ -namespace Bootsharp.Publish; +using System.Diagnostics.CodeAnalysis; +namespace Bootsharp.Publish; + +/// +/// Interop method's argument or returned value. +/// internal sealed record ValueMeta { + /// + /// C# type of the value. + /// public required Type Type { get; init; } + /// + /// C# syntax of the value type, as specified in source code. + /// public required string TypeSyntax { get; init; } + /// + /// TypeScript syntax of the value type, to be specified in source code. + /// public required string JSTypeSyntax { get; init; } + /// + /// Whether the value is optional/nullable. + /// public required bool Nullable { get; init; } + /// + /// Whether the value type is of an async nature (eg, task or promise). + /// public required bool Async { get; init; } + /// + /// Whether the value is void (when method return value). + /// public required bool Void { get; init; } + /// + /// Whether the value has to be marshalled to/from JSON for interop. + /// public required bool Serialized { get; init; } + /// + /// Whether the value is an interop instance. + /// + [MemberNotNullWhen(true, nameof(InstanceType))] + public required bool Instance { get; init; } + /// + /// When contains type of the associated interop interface instance. + /// + public required Type? InstanceType { get; init; } } diff --git a/src/cs/Bootsharp.Publish/Common/Preferences/Preferences.cs b/src/cs/Bootsharp.Publish/Common/Preferences/Preferences.cs index 7a9a5df4..549934dd 100644 --- a/src/cs/Bootsharp.Publish/Common/Preferences/Preferences.cs +++ b/src/cs/Bootsharp.Publish/Common/Preferences/Preferences.cs @@ -1,9 +1,14 @@ namespace Bootsharp.Publish; +/// internal sealed record Preferences { + /// public IReadOnlyList Space { get; init; } = []; + /// public IReadOnlyList Type { get; init; } = []; + /// public IReadOnlyList Event { get; init; } = []; + /// public IReadOnlyList Function { get; init; } = []; } diff --git a/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs b/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs index eaf8a8f0..0011310a 100644 --- a/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs +++ b/src/cs/Bootsharp.Publish/Common/Preferences/PreferencesResolver.cs @@ -34,7 +34,7 @@ public Preferences Resolve (string outDir) var value = CreateValue(attr.NamedArguments.First(a => a.MemberName == name).TypedValue); var prefs = new Preference[value.Length / 2]; for (int i = 0; i < prefs.Length; i++) - prefs[i] = new(value[i * 2], value[i * 2 + 1]); + prefs[i] = new(value[i * 2], value[(i * 2) + 1]); return prefs; } diff --git a/src/cs/Bootsharp.Publish/Common/SolutionInspector/InspectionReporter.cs b/src/cs/Bootsharp.Publish/Common/SolutionInspector/InspectionReporter.cs new file mode 100644 index 00000000..cab37d24 --- /dev/null +++ b/src/cs/Bootsharp.Publish/Common/SolutionInspector/InspectionReporter.cs @@ -0,0 +1,35 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Bootsharp.Publish; + +internal sealed class InspectionReporter (TaskLoggingHelper logger) +{ + public void Report (SolutionInspection inspection) + { + logger.LogMessage(MessageImportance.Normal, "Bootsharp assembly inspection result:"); + logger.LogMessage(MessageImportance.Normal, JoinLines("Discovered assemblies:", + JoinLines(GetDiscoveredAssemblies(inspection)))); + logger.LogMessage(MessageImportance.Normal, JoinLines("Discovered interop methods:", + JoinLines(GetDiscoveredMethods(inspection)))); + foreach (var warning in inspection.Warnings) + logger.LogWarning(warning); + } + + private HashSet GetDiscoveredAssemblies (SolutionInspection inspection) + { + return inspection.StaticMethods + .Concat(inspection.StaticInterfaces.SelectMany(i => i.Methods)) + .Select(m => m.Assembly) + .ToHashSet(); + } + + private HashSet GetDiscoveredMethods (SolutionInspection inspection) + { + return inspection.StaticMethods + .Concat(inspection.StaticInterfaces.SelectMany(i => i.Methods)) + .Concat(inspection.InstancedInterfaces.SelectMany(i => i.Methods)) + .Select(m => m.ToString()) + .ToHashSet(); + } +} diff --git a/src/cs/Bootsharp.Publish/Common/SolutionInspector/InterfaceInspector.cs b/src/cs/Bootsharp.Publish/Common/SolutionInspector/InterfaceInspector.cs new file mode 100644 index 00000000..b87692bf --- /dev/null +++ b/src/cs/Bootsharp.Publish/Common/SolutionInspector/InterfaceInspector.cs @@ -0,0 +1,43 @@ +using System.Reflection; + +namespace Bootsharp.Publish; + +internal sealed class InterfaceInspector (Preferences prefs, TypeConverter converter, string entryAssemblyName) +{ + private readonly MethodInspector methodInspector = new(prefs, converter); + + public InterfaceMeta Inspect (Type interfaceType, InterfaceKind kind) + { + var impl = BuildInteropInterfaceImplementationName(interfaceType, kind); + return new InterfaceMeta { + Kind = kind, + Type = interfaceType, + TypeSyntax = BuildSyntax(interfaceType), + Namespace = impl.space, + Name = impl.name, + Methods = interfaceType.GetMethods() + .Where(m => m.IsAbstract) + .Select(m => CreateMethod(m, kind, impl.full)).ToArray() + }; + } + + private MethodMeta CreateMethod (MethodInfo info, InterfaceKind kind, string space) + { + var name = WithPrefs(prefs.Event, info.Name, info.Name); + return methodInspector.Inspect(info, ResolveMethodKind(kind, info, name)) with { + Assembly = entryAssemblyName, + Space = space, + Name = name, + JSName = ToFirstLower(name), + InterfaceName = info.Name + }; + } + + private MethodKind ResolveMethodKind (InterfaceKind iKind, MethodInfo info, string implMethodName) + { + if (iKind == InterfaceKind.Export) return MethodKind.Invokable; + // TODO: This assumes event methods are always renamed via prefs, which may not be the case. + if (implMethodName != info.Name) return MethodKind.Event; + return MethodKind.Function; + } +} diff --git a/src/cs/Bootsharp.Publish/Common/SolutionInspector/MethodInspector.cs b/src/cs/Bootsharp.Publish/Common/SolutionInspector/MethodInspector.cs new file mode 100644 index 00000000..b25db09c --- /dev/null +++ b/src/cs/Bootsharp.Publish/Common/SolutionInspector/MethodInspector.cs @@ -0,0 +1,54 @@ +using System.Reflection; + +namespace Bootsharp.Publish; + +internal sealed class MethodInspector (Preferences prefs, TypeConverter converter) +{ + private MethodInfo method = null!; + private MethodKind kind; + + public MethodMeta Inspect (MethodInfo method, MethodKind kind) + { + this.method = method; + this.kind = kind; + return CreateMethod(); + } + + private MethodMeta CreateMethod () => new() { + Kind = kind, + Assembly = method.DeclaringType!.Assembly.GetName().Name!, + Space = method.DeclaringType.FullName!, + Name = method.Name, + Arguments = method.GetParameters().Select(CreateArgument).ToArray(), + ReturnValue = CreateValue(method.ReturnParameter, true), + JSSpace = BuildMethodSpace(), + JSName = WithPrefs(prefs.Function, method.Name, ToFirstLower(method.Name)) + }; + + private ArgumentMeta CreateArgument (ParameterInfo param) => new() { + Name = param.Name!, + JSName = param.Name == "function" ? "fn" : param.Name!, + Value = CreateValue(param, false) + }; + + private ValueMeta CreateValue (ParameterInfo param, bool @return) => new() { + Type = param.ParameterType, + TypeSyntax = BuildSyntax(param.ParameterType, param), + JSTypeSyntax = converter.ToTypeScript(param.ParameterType, GetNullability(param)), + Nullable = @return ? IsNullable(method) : IsNullable(param), + Async = @return && IsTaskLike(param.ParameterType), + Void = @return && IsVoid(param.ParameterType), + Serialized = ShouldSerialize(param.ParameterType), + Instance = IsInstancedInteropInterface(param.ParameterType, out var instanceType), + InstanceType = instanceType + }; + + private string BuildMethodSpace () + { + var space = method.DeclaringType!.Namespace ?? ""; + var name = BuildJSSpaceName(method.DeclaringType); + if (method.DeclaringType.IsInterface) name = name[1..]; + var fullname = string.IsNullOrEmpty(space) ? name : $"{space}.{name}"; + return WithPrefs(prefs.Space, fullname, fullname); + } +} diff --git a/src/cs/Bootsharp.Publish/Common/SolutionInspector/SolutionInspection.cs b/src/cs/Bootsharp.Publish/Common/SolutionInspector/SolutionInspection.cs new file mode 100644 index 00000000..005289a0 --- /dev/null +++ b/src/cs/Bootsharp.Publish/Common/SolutionInspector/SolutionInspection.cs @@ -0,0 +1,45 @@ +using System.Reflection; + +namespace Bootsharp.Publish; + +/// +/// Metadata about the built C# solution required to generate interop +/// code and other Bootsharp-specific resources. +/// +/// +/// Context in which the solution's assemblies were loaded and inspected. +/// Shouldn't be disposed to keep C# reflection APIs usable on the inspected types. +/// Dispose to remove file lock on the inspected assemblies. +/// +internal class SolutionInspection (MetadataLoadContext ctx) : IDisposable +{ + /// + /// Interop interfaces specified under or + /// for which static bindings have to be emitted. + /// + public required IReadOnlyCollection StaticInterfaces { get; init; } + /// + /// Interop interfaces found in interop method arguments or return values. Such + /// interfaces are considered instanced interop APIs, ie stateful objects with + /// interop methods/functions. Both methods of and + /// can be sources of the instanced interfaces. + /// + public required IReadOnlyCollection InstancedInterfaces { get; init; } + /// + /// Static interop methods, ie methods with + /// and similar interop attributes found on user-defined static classes. + /// + public required IReadOnlyCollection StaticMethods { get; init; } + /// + /// Types referenced on the interop methods (both static and on interfaces), + /// as well as types they depend on (ie, implemented interfaces). + /// Basically, all the types that have to pass interop boundary. + /// + public required IReadOnlyCollection Crawled { get; init; } + /// + /// Warnings logged while inspecting solution. + /// + public required IReadOnlyCollection Warnings { get; init; } + + public void Dispose () => ctx.Dispose(); +} diff --git a/src/cs/Bootsharp.Publish/Common/SolutionInspector/SolutionInspector.cs b/src/cs/Bootsharp.Publish/Common/SolutionInspector/SolutionInspector.cs new file mode 100644 index 00000000..f3391027 --- /dev/null +++ b/src/cs/Bootsharp.Publish/Common/SolutionInspector/SolutionInspector.cs @@ -0,0 +1,130 @@ +using System.Reflection; + +namespace Bootsharp.Publish; + +internal sealed class SolutionInspector +{ + private readonly List staticInterfaces = []; + private readonly List instancedInterfaces = []; + private readonly List staticMethods = []; + private readonly List warnings = []; + private readonly TypeConverter converter; + private readonly MethodInspector methodInspector; + private readonly InterfaceInspector interfaceInspector; + + public SolutionInspector (Preferences prefs, string entryAssemblyName) + { + converter = new(prefs); + methodInspector = new(prefs, converter); + interfaceInspector = new(prefs, converter, entryAssemblyName); + } + + /// + /// Inspects specified solution assembly paths in the output directory. + /// + /// Absolute path to directory containing compiled assemblies. + /// Absolute paths of the assemblies to inspect. + public SolutionInspection Inspect (string directory, IEnumerable paths) + { + var ctx = CreateLoadContext(directory); + foreach (var assemblyPath in paths) + try { InspectAssemblyFile(assemblyPath, ctx); } + catch (Exception e) { AddSkippedAssemblyWarning(assemblyPath, e); } + return CreateInspection(ctx); + } + + private void InspectAssemblyFile (string assemblyPath, MetadataLoadContext ctx) + { + if (!ShouldIgnoreAssembly(assemblyPath)) + InspectAssembly(ctx.LoadFromAssemblyPath(assemblyPath)); + } + + private void AddSkippedAssemblyWarning (string assemblyPath, Exception exception) + { + var assemblyName = Path.GetFileName(assemblyPath); + var message = $"Failed to inspect '{assemblyName}' assembly; " + + $"affected methods won't be available in JavaScript. Error: {exception.Message}"; + warnings.Add(message); + } + + private SolutionInspection CreateInspection (MetadataLoadContext ctx) => new(ctx) { + StaticInterfaces = [..staticInterfaces.DistinctBy(i => i.FullName)], + InstancedInterfaces = [..instancedInterfaces.DistinctBy(i => i.FullName)], + StaticMethods = [..staticMethods], + Crawled = [..converter.CrawledTypes], + Warnings = [..warnings] + }; + + private void InspectAssembly (Assembly assembly) + { + foreach (var exported in assembly.GetExportedTypes()) + InspectExportedType(exported); + foreach (var attribute in assembly.CustomAttributes) + InspectAssemblyAttribute(attribute); + } + + private void InspectExportedType (Type type) + { + if (type.Namespace?.StartsWith("Bootsharp.Generated") ?? false) return; + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + InspectExportedStaticMethod(method); + } + + private void InspectAssemblyAttribute (CustomAttributeData attribute) + { + var kind = default(InterfaceKind); + var name = attribute.AttributeType.FullName; + if (name == typeof(JSExportAttribute).FullName) kind = InterfaceKind.Export; + else if (name == typeof(JSImportAttribute).FullName) kind = InterfaceKind.Import; + else return; + foreach (var arg in (IEnumerable)attribute.ConstructorArguments[0].Value!) + InspectStaticInteropInterface((Type)arg.Value!, kind); + } + + private void InspectExportedStaticMethod (MethodInfo info) + { + var kind = default(MethodKind?); + foreach (var attr in info.CustomAttributes.Select(a => a.AttributeType.FullName)) + if (attr == typeof(JSInvokableAttribute).FullName) kind = MethodKind.Invokable; + else if (attr == typeof(JSFunctionAttribute).FullName) kind = MethodKind.Function; + else if (attr == typeof(JSEventAttribute).FullName) kind = MethodKind.Event; + if (kind.HasValue) InspectStaticInteropMethod(info, kind.Value); + } + + private void InspectStaticInteropMethod (MethodInfo info, MethodKind kind) + { + var methodMeta = methodInspector.Inspect(info, kind); + staticMethods.Add(methodMeta); + InspectMethodParameters(methodMeta, kind); + } + + private void InspectStaticInteropInterface (Type type, InterfaceKind kind) + { + var interfaceMeta = interfaceInspector.Inspect(type, kind); + staticInterfaces.Add(interfaceMeta); + foreach (var method in interfaceMeta.Methods) + InspectMethodParameters(method, kind); + } + + private void InspectMethodParameters (MethodMeta meta, MethodKind kind) + { + var iKind = kind == MethodKind.Invokable ? InterfaceKind.Export : InterfaceKind.Import; + InspectMethodParameters(meta, iKind); + } + + private void InspectMethodParameters (MethodMeta meta, InterfaceKind kind) + { + // When interop instance is an argument of exported method, it's imported (JS) API and vice-versa. + var argKind = kind == InterfaceKind.Export ? InterfaceKind.Import : InterfaceKind.Export; + foreach (var arg in meta.Arguments) + InspectMethodParameter(arg.Value.Type, argKind); + if (!meta.ReturnValue.Void) + InspectMethodParameter(meta.ReturnValue.Type, kind); + } + + private void InspectMethodParameter (Type paramType, InterfaceKind kind) + { + if (IsInstancedInteropInterface(paramType, out var instanceType)) + instancedInterfaces.Add(interfaceInspector.Inspect(instanceType, kind)); + } +} diff --git a/src/cs/Bootsharp.Publish/Common/TypeConverter/TypeConverter.cs b/src/cs/Bootsharp.Publish/Common/TypeConverter/TypeConverter.cs index 0e6a8bf8..8a50023c 100644 --- a/src/cs/Bootsharp.Publish/Common/TypeConverter/TypeConverter.cs +++ b/src/cs/Bootsharp.Publish/Common/TypeConverter/TypeConverter.cs @@ -72,12 +72,10 @@ private string ConvertGeneric (Type type) private string ConvertFinal (Type type) { - if (type.Name == "Void") return "void"; + if (IsVoid(type)) return "void"; if (CrawledTypes.Contains(type)) return BuildJSSpaceFullName(type, prefs); + if (IsNumber(type)) return "number"; return Type.GetTypeCode(type) switch { - TypeCode.Byte or TypeCode.SByte or TypeCode.UInt16 or TypeCode.UInt32 or - TypeCode.UInt64 or TypeCode.Int16 or TypeCode.Int32 or - TypeCode.Decimal or TypeCode.Double or TypeCode.Single => "number", TypeCode.Int64 => "bigint", TypeCode.Char or TypeCode.String => "string", TypeCode.Boolean => "boolean", @@ -86,6 +84,10 @@ TypeCode.UInt64 or TypeCode.Int16 or TypeCode.Int32 or }; } + private bool IsNumber (Type type) => Type.GetTypeCode(type) is + TypeCode.Byte or TypeCode.SByte or TypeCode.UInt16 or TypeCode.UInt32 or TypeCode.UInt64 or + TypeCode.Int16 or TypeCode.Int32 or TypeCode.Decimal or TypeCode.Double or TypeCode.Single; + private bool EnterNullability () { if (nullability == null) return false; diff --git a/src/cs/Bootsharp.Publish/Common/TypeUtilities.cs b/src/cs/Bootsharp.Publish/Common/TypeUtilities.cs index a894bfbc..cf129e23 100644 --- a/src/cs/Bootsharp.Publish/Common/TypeUtilities.cs +++ b/src/cs/Bootsharp.Publish/Common/TypeUtilities.cs @@ -153,10 +153,21 @@ public static string GetGenericNameWithoutArgs (string typeName) return typeName[..delimiterIndex]; } + public static bool IsInstancedInteropInterface (Type type, [NotNullWhen(true)] out Type? instanceType) + { + if (IsTaskWithResult(type, out instanceType)) + return IsInstancedInteropInterface(instanceType, out _); + instanceType = type; + if (!type.IsInterface) return false; + if (string.IsNullOrEmpty(type.Namespace)) return true; + return !type.Namespace.StartsWith("System.", StringComparison.Ordinal); + } + // https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/import-export-interop public static bool ShouldSerialize (Type type) { if (IsVoid(type)) return false; + if (IsInstancedInteropInterface(type, out _)) return false; if (IsTaskWithResult(type, out var result)) // TODO: Remove 'IsList(result)' when resolved: https://github.com/elringus/bootsharp/issues/138 return IsList(result) || ShouldSerialize(result); @@ -190,6 +201,30 @@ public static string BuildJSSpaceFullName (Type type, Preferences prefs) return string.IsNullOrEmpty(space) ? name : $"{space}.{name}"; } + public static (string space, string name, string full) BuildInteropInterfaceImplementationName (Type instanceType, InterfaceKind kind) + { + var space = "Bootsharp.Generated." + (kind == InterfaceKind.Export ? "Exports" : "Imports"); + if (instanceType.Namespace != null) space += $".{instanceType.Namespace}"; + var name = "JS" + instanceType.Name[1..]; + return (space, name, $"{space}.{name}"); + } + + public static string PrependInstanceIdArgName (string args) + { + if (string.IsNullOrEmpty(args)) return "_id"; + return $"_id, {args}"; + } + + public static string PrependInstanceIdArgTypeAndName (string args) + { + return $"{BuildSyntax(typeof(int))} {PrependInstanceIdArgName(args)}"; + } + + public static string BuildJSInteropInstanceClassName (InterfaceMeta inter) + { + return inter.FullName.Replace("Bootsharp.Generated.Exports.", "").Replace(".", "_"); + } + public static string WithPrefs (IReadOnlyCollection prefs, string input, string @default) { foreach (var pref in prefs) diff --git a/src/cs/Bootsharp.Publish/Emit/BootsharpEmit.cs b/src/cs/Bootsharp.Publish/Emit/BootsharpEmit.cs index 47c29204..e1d264f7 100644 --- a/src/cs/Bootsharp.Publish/Emit/BootsharpEmit.cs +++ b/src/cs/Bootsharp.Publish/Emit/BootsharpEmit.cs @@ -15,7 +15,7 @@ public sealed class BootsharpEmit : Microsoft.Build.Utilities.Task public override bool Execute () { var prefs = ResolvePreferences(); - using var inspection = InspectAssemblies(prefs); + using var inspection = InspectSolution(prefs); GenerateInterfaces(inspection); GenerateDependencies(inspection); GenerateSerializer(inspection); @@ -29,37 +29,37 @@ private Preferences ResolvePreferences () return resolver.Resolve(InspectedDirectory); } - private AssemblyInspection InspectAssemblies (Preferences prefs) + private SolutionInspection InspectSolution (Preferences prefs) { - var inspector = new AssemblyInspector(prefs, EntryAssemblyName); + var inspector = new SolutionInspector(prefs, EntryAssemblyName); var inspected = Directory.GetFiles(InspectedDirectory, "*.dll"); - var inspection = inspector.InspectInDirectory(InspectedDirectory, inspected); + var inspection = inspector.Inspect(InspectedDirectory, inspected); new InspectionReporter(Log).Report(inspection); return inspection; } - private void GenerateInterfaces (AssemblyInspection inspection) + private void GenerateInterfaces (SolutionInspection inspection) { var generator = new InterfaceGenerator(); var content = generator.Generate(inspection); WriteGenerated(InterfacesFilePath, content); } - private void GenerateDependencies (AssemblyInspection inspection) + private void GenerateDependencies (SolutionInspection inspection) { var generator = new DependencyGenerator(EntryAssemblyName); var content = generator.Generate(inspection); WriteGenerated(DependenciesFilePath, content); } - private void GenerateSerializer (AssemblyInspection inspection) + private void GenerateSerializer (SolutionInspection inspection) { var generator = new SerializerGenerator(); var content = generator.Generate(inspection); WriteGenerated(SerializerFilePath, content); } - private void GenerateInterop (AssemblyInspection inspection) + private void GenerateInterop (SolutionInspection inspection) { var generator = new InteropGenerator(); var content = generator.Generate(inspection); diff --git a/src/cs/Bootsharp.Publish/Emit/DependencyGenerator.cs b/src/cs/Bootsharp.Publish/Emit/DependencyGenerator.cs index 2029d11a..3383d08d 100644 --- a/src/cs/Bootsharp.Publish/Emit/DependencyGenerator.cs +++ b/src/cs/Bootsharp.Publish/Emit/DependencyGenerator.cs @@ -11,7 +11,7 @@ internal sealed class DependencyGenerator (string entryAssembly) { private readonly HashSet added = []; - public string Generate (AssemblyInspection inspection) + public string Generate (SolutionInspection inspection) { AddGeneratedCommon(); AddGeneratedInteropClasses(inspection); @@ -37,15 +37,18 @@ private void AddGeneratedCommon () Add(All, "Bootsharp.Generated.Interop", entryAssembly); } - private void AddGeneratedInteropClasses (AssemblyInspection inspection) + private void AddGeneratedInteropClasses (SolutionInspection inspection) { - foreach (var inter in inspection.Interfaces) + foreach (var inter in inspection.StaticInterfaces) Add(All, inter.FullName, entryAssembly); + foreach (var inter in inspection.InstancedInterfaces) + if (inter.Kind == InterfaceKind.Import) + Add(All, inter.FullName, entryAssembly); } - private void AddClassesWithInteropMethods (AssemblyInspection inspection) + private void AddClassesWithInteropMethods (SolutionInspection inspection) { - foreach (var method in inspection.Methods) + foreach (var method in inspection.StaticMethods) Add(All, method.Space, method.Assembly); } diff --git a/src/cs/Bootsharp.Publish/Emit/InterfaceGenerator.cs b/src/cs/Bootsharp.Publish/Emit/InterfaceGenerator.cs index edfd6c2d..a5b94bc3 100644 --- a/src/cs/Bootsharp.Publish/Emit/InterfaceGenerator.cs +++ b/src/cs/Bootsharp.Publish/Emit/InterfaceGenerator.cs @@ -1,18 +1,22 @@ namespace Bootsharp.Publish; /// -/// Generates interop classes for interfaces specified under -/// and . +/// Generates implementations for interop interfaces. /// internal sealed class InterfaceGenerator { private readonly HashSet classes = []; private readonly HashSet registrations = []; + private HashSet instanced = []; - public string Generate (AssemblyInspection inspection) + public string Generate (SolutionInspection inspection) { - foreach (var inter in inspection.Interfaces) - AddInterface(inter, inspection); + instanced = inspection.InstancedInterfaces.ToHashSet(); + foreach (var inter in inspection.StaticInterfaces) + AddInterface(inter); + foreach (var inter in inspection.InstancedInterfaces) + if (inter.Kind == InterfaceKind.Import) + classes.Add(EmitInstancedImportClass(inter)); return $$""" #nullable enable @@ -34,7 +38,7 @@ internal static void RegisterInterfaces () """; } - private void AddInterface (InterfaceMeta i, AssemblyInspection inspection) + private void AddInterface (InterfaceMeta i) { if (i.Kind == InterfaceKind.Export) classes.Add(EmitExportClass(i)); else classes.Add(EmitImportClass(i)); @@ -54,7 +58,7 @@ public class {{i.Name}} {{i.Name}}.handler = handler; } - {{JoinLines(i.Methods.Select(m => EmitExportMethod(m.Generated)), 2)}} + {{JoinLines(i.Methods.Select(EmitExportMethod), 2)}} } } """; @@ -65,7 +69,22 @@ namespace {{i.Namespace}} { public class {{i.Name}} : {{i.TypeSyntax}} { - {{JoinLines(i.Methods.Select(m => EmitImportMethod(m.Generated)), 2)}} + {{JoinLines(i.Methods.Select(m => EmitImportMethod(i, m)), 2)}} + + {{JoinLines(i.Methods.Select(m => EmitImportMethodImplementation(i, m)), 2)}} + } + } + """; + + private string EmitInstancedImportClass (InterfaceMeta i) => + $$""" + namespace {{i.Namespace}} + { + public class {{i.Name}}(global::System.Int32 _id) : {{i.TypeSyntax}} + { + ~{{i.Name}}() => global::Bootsharp.Generated.Interop.DisposeImportedInstance(_id); + + {{JoinLines(i.Methods.Select(m => EmitImportMethod(i, m)), 2)}} {{JoinLines(i.Methods.Select(m => EmitImportMethodImplementation(i, m)), 2)}} } @@ -84,27 +103,30 @@ private string EmitExportMethod (MethodMeta method) return $"[JSInvokable] {sig} => handler.{method.Name}({args});"; } - private string EmitImportMethod (MethodMeta method) + private string EmitImportMethod (InterfaceMeta i, MethodMeta method) { var attr = method.Kind == MethodKind.Function ? "JSFunction" : "JSEvent"; var sigArgs = string.Join(", ", method.Arguments.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); + if (instanced.Contains(i)) sigArgs = PrependInstanceIdArgTypeAndName(sigArgs); var sig = $"public static {method.ReturnValue.TypeSyntax} {method.Name} ({sigArgs})"; var args = string.Join(", ", method.Arguments.Select(a => a.Name)); - return $"[{attr}] {sig} => {EmitProxyGetter(method)}({args});"; + if (instanced.Contains(i)) args = PrependInstanceIdArgName(args); + return $"[{attr}] {sig} => {EmitProxyGetter(i, method)}({args});"; } - private string EmitImportMethodImplementation (InterfaceMeta i, InterfaceMethodMeta method) + private string EmitImportMethodImplementation (InterfaceMeta i, MethodMeta method) { - var gen = method.Generated; - var sigArgs = string.Join(", ", gen.Arguments.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); - var args = string.Join(", ", gen.Arguments.Select(a => a.Name)); - return $"{gen.ReturnValue.TypeSyntax} {i.TypeSyntax}.{method.Name} ({sigArgs}) => {gen.Name}({args});"; + var sigArgs = string.Join(", ", method.Arguments.Select(a => $"{a.Value.TypeSyntax} {a.Name}")); + var args = string.Join(", ", method.Arguments.Select(a => a.Name)); + if (instanced.Contains(i)) args = PrependInstanceIdArgName(args); + return $"{method.ReturnValue.TypeSyntax} {i.TypeSyntax}.{method.InterfaceName} ({sigArgs}) => {method.Name}({args});"; } - private string EmitProxyGetter (MethodMeta method) + private string EmitProxyGetter (InterfaceMeta i, MethodMeta method) { - var func = method.ReturnValue.Void ? "Action" : "Func"; + var func = method.ReturnValue.Void ? "global::System.Action" : "global::System.Func"; var syntax = method.Arguments.Select(a => a.Value.TypeSyntax).ToList(); + if (instanced.Contains(i)) syntax.Insert(0, BuildSyntax(typeof(int))); if (!method.ReturnValue.Void) syntax.Add(method.ReturnValue.TypeSyntax); if (syntax.Count > 0) func = $"{func}<{string.Join(", ", syntax)}>"; return $"Proxies.Get<{func}>(\"{method.Space}.{method.Name}\")"; diff --git a/src/cs/Bootsharp.Publish/Emit/InteropGenerator.cs b/src/cs/Bootsharp.Publish/Emit/InteropGenerator.cs index 58bd74e7..533b2c0b 100644 --- a/src/cs/Bootsharp.Publish/Emit/InteropGenerator.cs +++ b/src/cs/Bootsharp.Publish/Emit/InteropGenerator.cs @@ -1,4 +1,6 @@ -namespace Bootsharp.Publish; +using System.Diagnostics.CodeAnalysis; + +namespace Bootsharp.Publish; /// /// Generates bindings to be picked by .NET's interop source generator. @@ -7,12 +9,17 @@ internal sealed class InteropGenerator { private readonly HashSet proxies = []; private readonly HashSet methods = []; + private IReadOnlyCollection instanced = []; - public string Generate (AssemblyInspection inspection) + public string Generate (SolutionInspection inspection) { - foreach (var method in inspection.Methods) // @formatter:off - if (method.Kind == MethodKind.Invokable) AddExportMethod(method); - else { AddProxy(method); AddImportMethod(method); } // @formatter:on + instanced = inspection.InstancedInterfaces; + var @static = inspection.StaticMethods + .Concat(inspection.StaticInterfaces.SelectMany(i => i.Methods)) + .Concat(inspection.InstancedInterfaces.SelectMany(i => i.Methods)); + foreach (var meta in @static) // @formatter:off + if (meta.Kind == MethodKind.Invokable) AddExportMethod(meta); + else { AddProxy(meta); AddImportMethod(meta); } // @formatter:on return $$""" #nullable enable @@ -30,6 +37,10 @@ internal static void RegisterProxies () { {{JoinLines(proxies, 2)}} } + + [System.Runtime.InteropServices.JavaScript.JSExport] internal static void DisposeExportedInstance (global::System.Int32 id) => global::Bootsharp.Instances.Dispose(id); + [System.Runtime.InteropServices.JavaScript.JSImport("disposeInstance", "Bootsharp")] internal static partial void DisposeImportedInstance (global::System.Int32 id); + {{JoinLines(methods)}} } """; @@ -37,19 +48,17 @@ internal static void RegisterProxies () private void AddExportMethod (MethodMeta inv) { - const string attr = "[System.Runtime.InteropServices.JavaScript.JSExport]"; - var date = MarshalAmbiguous(inv.ReturnValue.TypeSyntax, true); - var wait = inv.ReturnValue.Async && inv.ReturnValue.Serialized; - methods.Add($"{attr} {date}internal static {BuildSignature()} => {BuildBody()};"); + var instanced = TryInstanced(inv, out var instance); + var marshalAs = MarshalAmbiguous(inv.ReturnValue.TypeSyntax, true); + var wait = ShouldWait(inv.ReturnValue); + var attr = $"[System.Runtime.InteropServices.JavaScript.JSExport] {marshalAs}"; + methods.Add($"{attr}internal static {BuildSignature()} => {BuildBody()};"); string BuildSignature () { var args = string.Join(", ", inv.Arguments.Select(BuildSignatureArg)); - var @return = inv.ReturnValue.Void ? "void" : (inv.ReturnValue.Serialized - ? $"global::System.String{(inv.ReturnValue.Nullable ? "?" : "")}" - : inv.ReturnValue.TypeSyntax); - if (inv.ReturnValue.Serialized && inv.ReturnValue.Async) - @return = $"global::System.Threading.Tasks.Task<{@return}>"; + if (instanced) args = args = PrependInstanceIdArgTypeAndName(args); + var @return = BuildReturnValue(inv.ReturnValue); var signature = $"{@return} {BuildMethodName(inv)} ({args})"; if (wait) signature = $"async {signature}"; return signature; @@ -58,41 +67,49 @@ string BuildSignature () string BuildBody () { var args = string.Join(", ", inv.Arguments.Select(BuildBodyArg)); - var body = $"global::{inv.Space}.{inv.Name}({args})"; + var body = instanced + ? $"(({instance!.TypeSyntax})global::Bootsharp.Instances.Get(_id)).{inv.Name}({args})" + : $"global::{inv.Space}.{inv.Name}({args})"; if (wait) body = $"await {body}"; - if (inv.ReturnValue.Serialized) body = $"Serialize({body})"; + if (inv.ReturnValue.Instance) body = $"global::Bootsharp.Instances.Register({body})"; + else if (inv.ReturnValue.Serialized) body = $"Serialize({body})"; return body; } - string BuildSignatureArg (ArgumentMeta arg) - { - var type = arg.Value.Serialized - ? $"global::System.String{(arg.Value.Nullable ? "?" : "")}" - : arg.Value.TypeSyntax; - return $"{MarshalAmbiguous(arg.Value.TypeSyntax, false)}{type} {arg.Name}"; - } - string BuildBodyArg (ArgumentMeta arg) { - if (!arg.Value.Serialized) return arg.Name; - return $"Deserialize<{arg.Value.TypeSyntax}>({arg.Name})"; + if (arg.Value.Instance) + { + var (_, _, full) = BuildInteropInterfaceImplementationName(arg.Value.InstanceType, InterfaceKind.Import); + return $"new global::{full}({arg.Name})"; + } + if (arg.Value.Serialized) return $"Deserialize<{arg.Value.TypeSyntax}>({arg.Name})"; + return arg.Name; } } private void AddProxy (MethodMeta method) { + var instanced = TryInstanced(method, out _); var id = $"{method.Space}.{method.Name}"; var args = string.Join(", ", method.Arguments.Select(arg => $"{arg.Value.TypeSyntax} {arg.Name}")); - var wait = method.ReturnValue.Async && method.ReturnValue.Serialized; + if (instanced) args = args = PrependInstanceIdArgTypeAndName(args); + var wait = ShouldWait(method.ReturnValue); var async = wait ? "async " : ""; proxies.Add($"""Proxies.Set("{id}", {async}({args}) => {BuildBody()});"""); string BuildBody () { var args = string.Join(", ", method.Arguments.Select(BuildBodyArg)); + if (instanced) args = PrependInstanceIdArgName(args); var body = $"{BuildMethodName(method)}({args})"; - if (!method.ReturnValue.Serialized) return body; if (wait) body = $"await {body}"; + if (method.ReturnValue.Instance) + { + var (_, _, full) = BuildInteropInterfaceImplementationName(method.ReturnValue.InstanceType, InterfaceKind.Import); + return $"({BuildSyntax(method.ReturnValue.InstanceType)})new global::{full}({body})"; + } + if (!method.ReturnValue.Serialized) return body; var type = method.ReturnValue.Async ? method.ReturnValue.TypeSyntax[36..^1] : method.ReturnValue.TypeSyntax; @@ -101,35 +118,59 @@ string BuildBody () string BuildBodyArg (ArgumentMeta arg) { - if (!arg.Value.Serialized) return arg.Name; - return $"Serialize({arg.Name})"; + if (arg.Value.Instance) return $"global::Bootsharp.Instances.Register({arg.Name})"; + if (arg.Value.Serialized) return $"Serialize({arg.Name})"; + return arg.Name; } } private void AddImportMethod (MethodMeta method) { - var args = string.Join(", ", method.Arguments.Select(BuildArg)); - var @return = method.ReturnValue.Void ? "void" : (method.ReturnValue.Serialized - ? $"global::System.String{(method.ReturnValue.Nullable ? "?" : "")}" - : method.ReturnValue.TypeSyntax); - if (method.ReturnValue.Serialized && method.ReturnValue.Async) - @return = $"global::System.Threading.Tasks.Task<{@return}>"; + var args = string.Join(", ", method.Arguments.Select(BuildSignatureArg)); + if (TryInstanced(method, out _)) args = PrependInstanceIdArgTypeAndName(args); + var @return = BuildReturnValue(method.ReturnValue); var endpoint = $"{method.JSSpace}.{method.JSName}Serialized"; var attr = $"""[System.Runtime.InteropServices.JavaScript.JSImport("{endpoint}", "Bootsharp")]"""; var date = MarshalAmbiguous(method.ReturnValue.TypeSyntax, true); methods.Add($"{attr} {date}internal static partial {@return} {BuildMethodName(method)} ({args});"); + } - string BuildArg (ArgumentMeta arg) - { - var type = arg.Value.Serialized - ? $"global::System.String{(arg.Value.Nullable ? "?" : "")}" - : arg.Value.TypeSyntax; - return $"{MarshalAmbiguous(arg.Value.TypeSyntax, false)}{type} {arg.Name}"; - } + private string BuildValueType (ValueMeta value) + { + if (value.Void) return "void"; + var nil = value.Nullable ? "?" : ""; + if (value.Instance) return $"global::System.Int32{(nil)}"; + if (value.Serialized) return $"global::System.String{(nil)}"; + return value.TypeSyntax; + } + + private string BuildSignatureArg (ArgumentMeta arg) + { + var type = BuildValueType(arg.Value); + return $"{MarshalAmbiguous(arg.Value.TypeSyntax, false)}{type} {arg.Name}"; + } + + private string BuildReturnValue (ValueMeta value) + { + var syntax = BuildValueType(value); + if (ShouldWait(value)) + syntax = $"global::System.Threading.Tasks.Task<{syntax}>"; + return syntax; } private string BuildMethodName (MethodMeta method) { return $"{method.Space.Replace('.', '_')}_{method.Name}"; } + + private bool TryInstanced (MethodMeta method, [NotNullWhen(true)] out InterfaceMeta? instance) + { + instance = instanced.FirstOrDefault(i => i.Methods.Contains(method)); + return instance is not null; + } + + private bool ShouldWait (ValueMeta value) + { + return value.Async && (value.Serialized || value.Instance); + } } diff --git a/src/cs/Bootsharp.Publish/Emit/SerializerGenerator.cs b/src/cs/Bootsharp.Publish/Emit/SerializerGenerator.cs index 503c2d58..97fd60f1 100644 --- a/src/cs/Bootsharp.Publish/Emit/SerializerGenerator.cs +++ b/src/cs/Bootsharp.Publish/Emit/SerializerGenerator.cs @@ -9,10 +9,13 @@ internal sealed class SerializerGenerator { private readonly HashSet attributes = []; - public string Generate (AssemblyInspection inspection) + public string Generate (SolutionInspection inspection) { - foreach (var method in inspection.Methods) - CollectAttributes(method); + var metas = inspection.StaticMethods + .Concat(inspection.StaticInterfaces.SelectMany(i => i.Methods)) + .Concat(inspection.InstancedInterfaces.SelectMany(i => i.Methods)); + foreach (var meta in metas) + CollectAttributes(meta); CollectDuplicates(inspection); if (attributes.Count == 0) return ""; return @@ -54,11 +57,11 @@ private void CollectAttributes (string syntax, Type type) attributes.Add(BuildAttribute(syntax)); } - private void CollectDuplicates (AssemblyInspection inspection) + private void CollectDuplicates (SolutionInspection inspection) { var names = new HashSet(); foreach (var type in inspection.Crawled.DistinctBy(t => t.FullName)) - if (!names.Add(type.Name)) + if (ShouldSerialize(type) && !names.Add(type.Name)) CollectAttributes(BuildSyntax(type), type); } diff --git a/src/cs/Bootsharp.Publish/Pack/BindingGenerator/BindingClassGenerator.cs b/src/cs/Bootsharp.Publish/Pack/BindingGenerator/BindingClassGenerator.cs new file mode 100644 index 00000000..601888f9 --- /dev/null +++ b/src/cs/Bootsharp.Publish/Pack/BindingGenerator/BindingClassGenerator.cs @@ -0,0 +1,26 @@ +namespace Bootsharp.Publish; + +internal sealed class BindingClassGenerator +{ + public string Generate (IReadOnlyCollection instanced) + { + var exported = instanced.Where(i => i.Kind == InterfaceKind.Export); + return JoinLines(exported.Select(BuildClass), 0) + '\n'; + } + + private string BuildClass (InterfaceMeta inter) => + $$""" + class {{BuildJSInteropInstanceClassName(inter)}} { + constructor(_id) { this._id = _id; disposeOnFinalize(this, _id); } + {{JoinLines(inter.Methods.Select(BuildFunction))}} + } + """; + + private string BuildFunction (MethodMeta inv) + { + var sigArgs = string.Join(", ", inv.Arguments.Select(a => a.Name)); + var args = "this._id" + (sigArgs.Length > 0 ? $", {sigArgs}" : ""); + var @return = inv.ReturnValue.Void ? "" : "return "; + return $"{inv.JSName}({sigArgs}) {{ {@return}{inv.JSSpace}.{inv.JSName}({args}); }}"; + } +} diff --git a/src/cs/Bootsharp.Publish/Pack/BindingGenerator.cs b/src/cs/Bootsharp.Publish/Pack/BindingGenerator/BindingGenerator.cs similarity index 58% rename from src/cs/Bootsharp.Publish/Pack/BindingGenerator.cs rename to src/cs/Bootsharp.Publish/Pack/BindingGenerator/BindingGenerator.cs index 15f3848f..0294c1ec 100644 --- a/src/cs/Bootsharp.Publish/Pack/BindingGenerator.cs +++ b/src/cs/Bootsharp.Publish/Pack/BindingGenerator/BindingGenerator.cs @@ -7,6 +7,8 @@ internal sealed class BindingGenerator (Preferences prefs) private record Binding (MethodMeta? Method, Type? Enum, string Namespace); private readonly StringBuilder builder = new(); + private readonly BindingClassGenerator classGenerator = new(); + private IReadOnlyCollection instanced = []; private Binding binding => bindings[index]; private Binding? prevBinding => index == 0 ? null : bindings[index - 1]; @@ -15,15 +17,20 @@ private record Binding (MethodMeta? Method, Type? Enum, string Namespace); private Binding[] bindings = null!; private int index, level; - public string Generate (AssemblyInspection inspection) + public string Generate (SolutionInspection inspection) { - bindings = inspection.Methods + instanced = inspection.InstancedInterfaces; + bindings = inspection.StaticMethods + .Concat(inspection.StaticInterfaces.SelectMany(i => i.Methods)) + .Concat(inspection.InstancedInterfaces.SelectMany(i => i.Methods)) .Select(m => new Binding(m, null, m.JSSpace)) .Concat(inspection.Crawled.Where(t => t.IsEnum) .Select(t => new Binding(null, t, BuildJSSpace(t, prefs)))) .OrderBy(m => m.Namespace).ToArray(); if (bindings.Length == 0) return ""; EmitImports(); + if (inspection.InstancedInterfaces.Count > 0) + builder.Append(classGenerator.Generate(inspection.InstancedInterfaces)); for (index = 0; index < bindings.Length; index++) EmitBinding(); return builder.ToString(); @@ -33,9 +40,11 @@ private void EmitImports () { builder.Append("import { exports } from \"./exports\";\n"); builder.Append("import { Event } from \"./event\";\n"); + builder.Append("import { registerInstance, getInstance, disposeOnFinalize } from \"./instances\";\n\n"); builder.Append("function getExports () { if (exports == null) throw Error(\"Boot the runtime before invoking C# APIs.\"); return exports; }\n"); builder.Append("function serialize(obj) { return JSON.stringify(obj); }\n"); - builder.Append("function deserialize(json) { const result = JSON.parse(json); if (result === null) return undefined; return result; }\n"); + builder.Append("function deserialize(json) { const result = JSON.parse(json); if (result === null) return undefined; return result; }\n\n"); + builder.Append("/* v8 ignore start */\n"); } private void EmitBinding () @@ -98,43 +107,69 @@ private void EmitMethod (MethodMeta method) private void EmitInvokable (MethodMeta method) { + var instanced = IsInstanced(method); var wait = ShouldWait(method); var endpoint = $"getExports().{method.Space.Replace('.', '_')}_{method.Name}"; var funcArgs = string.Join(", ", method.Arguments.Select(a => a.JSName)); - var invArgs = string.Join(", ", method.Arguments.Select(arg => - arg.Value.Serialized ? $"serialize({arg.JSName})" : arg.JSName - )); + if (instanced) funcArgs = PrependInstanceIdArgName(funcArgs); + var invArgs = string.Join(", ", method.Arguments.Select(BuildInvArg)); + if (instanced) invArgs = PrependInstanceIdArgName(invArgs); var body = $"{(wait ? "await " : "")}{endpoint}({invArgs})"; - if (method.ReturnValue.Serialized) body = $"deserialize({body})"; + if (method.ReturnValue.Instance) body = $"new {BuildInstanceClassName(method.ReturnValue.InstanceType)}({body})"; + else if (method.ReturnValue.Serialized) body = $"deserialize({body})"; var func = $"{(wait ? "async " : "")}({funcArgs}) => {body}"; builder.Append($"{Break()}{method.JSName}: {func}"); + + string BuildInvArg (ArgumentMeta arg) + { + if (arg.Value.Instance) return $"registerInstance({arg.JSName})"; + if (arg.Value.Serialized) return $"serialize({arg.JSName})"; + return arg.JSName; + } } private void EmitFunction (MethodMeta method) { + var instanced = IsInstanced(method); var wait = ShouldWait(method); var name = method.JSName; var funcArgs = string.Join(", ", method.Arguments.Select(a => a.JSName)); - var invArgs = string.Join(", ", method.Arguments.Select(arg => - arg.Value.Serialized ? $"deserialize({arg.JSName})" : arg.JSName - )); - var body = $"{(wait ? "await " : "")}this.{name}Handler({invArgs})"; - if (method.ReturnValue.Serialized) body = $"serialize({body})"; - var set = $"this.{name}Handler = handler; this.{name}SerializedHandler = {(wait ? "async " : "")}({funcArgs}) => {body};"; - var error = $"throw Error(\"Failed to invoke '{binding.Namespace}.{name}' from C#. Make sure to assign function in JavaScript.\")"; - var serde = $"if (typeof this.{name}Handler !== \"function\") {error}; return this.{name}SerializedHandler;"; - builder.Append($"{Break()}get {name}() {{ return this.{name}Handler; }}"); - builder.Append($"{Break()}set {name}(handler) {{ {set} }}"); - builder.Append($"{Break()}get {name}Serialized() {{ {serde} }}"); + if (instanced) funcArgs = PrependInstanceIdArgName(funcArgs); + var invArgs = string.Join(", ", method.Arguments.Select(BuildInvArg)); + var handler = instanced ? $"getInstance(_id).{name}" : $"this.{name}Handler"; + var body = $"{(wait ? "await " : "")}{handler}({invArgs})"; + if (method.ReturnValue.Instance) body = $"registerInstance({body})"; + else if (method.ReturnValue.Serialized) body = $"serialize({body})"; + var serdeHandler = $"{(wait ? "async " : "")}({funcArgs}) => {body}"; + if (instanced) builder.Append($"{Break()}{name}Serialized: {serdeHandler}"); + else + { + var set = $"{handler} = handler; this.{name}SerializedHandler = {serdeHandler};"; + var error = $"throw Error(\"Failed to invoke '{binding.Namespace}.{name}' from C#. Make sure to assign function in JavaScript.\")"; + var serde = $"if (typeof {handler} !== \"function\") {error}; return this.{name}SerializedHandler;"; + builder.Append($"{Break()}get {name}() {{ return {handler}; }}"); + builder.Append($"{Break()}set {name}(handler) {{ {set} }}"); + builder.Append($"{Break()}get {name}Serialized() {{ {serde} }}"); + } + + string BuildInvArg (ArgumentMeta arg) + { + if (arg.Value.Instance) return $"new {BuildInstanceClassName(arg.Value.InstanceType)}({arg.JSName})"; + if (arg.Value.Serialized) return $"deserialize({arg.JSName})"; + return arg.JSName; + } } private void EmitEvent (MethodMeta method) { + var instanced = IsInstanced(method); var name = method.JSName; - builder.Append($"{Break()}{name}: new Event()"); + if (!instanced) builder.Append($"{Break()}{name}: new Event()"); var funcArgs = string.Join(", ", method.Arguments.Select(a => a.JSName)); + if (instanced) funcArgs = PrependInstanceIdArgName(funcArgs); var invArgs = string.Join(", ", method.Arguments.Select(arg => arg.Value.Serialized ? $"deserialize({arg.JSName})" : arg.JSName)); - builder.Append($"{Break()}{name}Serialized: ({funcArgs}) => {method.JSSpace}.{name}.broadcast({invArgs})"); + var handler = instanced ? "getInstance(_id)" : method.JSSpace; + builder.Append($"{Break()}{name}Serialized: ({funcArgs}) => {handler}.{name}.broadcast({invArgs})"); } private void EmitEnum (Type @enum) @@ -147,10 +182,21 @@ private void EmitEnum (Type @enum) } private bool ShouldWait (MethodMeta method) => - (method.Arguments.Any(a => a.Value.Serialized) || - method.ReturnValue.Serialized) && method.ReturnValue.Async; + (method.Arguments.Any(a => a.Value.Serialized || a.Value.Instance) || + method.ReturnValue.Serialized || method.ReturnValue.Instance) && method.ReturnValue.Async; private string Break () => $"{Comma()}\n{Pad(level + 1)}"; private string Pad (int level) => new(' ', level * 4); private string Comma () => builder[^1] == '{' ? "" : ","; + + private string BuildInstanceClassName (Type instanceType) + { + var instance = instanced.First(i => i.Type == instanceType); + return BuildJSInteropInstanceClassName(instance); + } + + private bool IsInstanced (MethodMeta method) + { + return instanced.Any(i => i.Methods.Contains(method)); + } } diff --git a/src/cs/Bootsharp.Publish/Pack/BootsharpPack.cs b/src/cs/Bootsharp.Publish/Pack/BootsharpPack.cs index 2c06a94f..9c46ab37 100644 --- a/src/cs/Bootsharp.Publish/Pack/BootsharpPack.cs +++ b/src/cs/Bootsharp.Publish/Pack/BootsharpPack.cs @@ -15,7 +15,7 @@ public sealed class BootsharpPack : Microsoft.Build.Utilities.Task public override bool Execute () { var prefs = ResolvePreferences(); - using var inspection = InspectAssemblies(prefs); + using var inspection = InspectSolution(prefs); GenerateBindings(prefs, inspection); GenerateDeclarations(prefs, inspection); GenerateResources(inspection); @@ -29,33 +29,33 @@ private Preferences ResolvePreferences () return resolver.Resolve(InspectedDirectory); } - private AssemblyInspection InspectAssemblies (Preferences prefs) + private SolutionInspection InspectSolution (Preferences prefs) { - var inspector = new AssemblyInspector(prefs, EntryAssemblyName); + var inspector = new SolutionInspector(prefs, EntryAssemblyName); // Assemblies in publish dir are trimmed and don't contain some data (eg, method arg names). // While the inspected dir contains extra assemblies we don't need in build. Hence the filtering. var included = Directory.GetFiles(BuildDirectory, "*.wasm").Select(Path.GetFileNameWithoutExtension).ToHashSet(); var inspected = Directory.GetFiles(InspectedDirectory, "*.dll").Where(p => included.Contains(Path.GetFileNameWithoutExtension(p))); - var inspection = inspector.InspectInDirectory(InspectedDirectory, inspected); + var inspection = inspector.Inspect(InspectedDirectory, inspected); new InspectionReporter(Log).Report(inspection); return inspection; } - private void GenerateBindings (Preferences prefs, AssemblyInspection inspection) + private void GenerateBindings (Preferences prefs, SolutionInspection inspection) { var generator = new BindingGenerator(prefs); var content = generator.Generate(inspection); File.WriteAllText(Path.Combine(BuildDirectory, "bindings.g.js"), content); } - private void GenerateDeclarations (Preferences prefs, AssemblyInspection inspection) + private void GenerateDeclarations (Preferences prefs, SolutionInspection inspection) { var generator = new DeclarationGenerator(prefs); var content = generator.Generate(inspection); File.WriteAllText(Path.Combine(BuildDirectory, "bindings.g.d.ts"), content); } - private void GenerateResources (AssemblyInspection inspection) + private void GenerateResources (SolutionInspection inspection) { var generator = new ResourceGenerator(EntryAssemblyName, EmbedBinaries); var content = generator.Generate(BuildDirectory); diff --git a/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/DeclarationGenerator.cs b/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/DeclarationGenerator.cs index 0e9b507b..0b4c55a2 100644 --- a/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/DeclarationGenerator.cs +++ b/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/DeclarationGenerator.cs @@ -5,9 +5,9 @@ internal sealed class DeclarationGenerator (Preferences prefs) private readonly MethodDeclarationGenerator methodsGenerator = new(); private readonly TypeDeclarationGenerator typesGenerator = new(prefs); - public string Generate (AssemblyInspection inspection) => JoinLines(0, + public string Generate (SolutionInspection inspection) => JoinLines(0, """import type { Event } from "./event";""", - typesGenerator.Generate(inspection.Crawled), - methodsGenerator.Generate(inspection.Methods) + typesGenerator.Generate(inspection), + methodsGenerator.Generate(inspection) ) + "\n"; } diff --git a/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/MethodDeclarationGenerator.cs b/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/MethodDeclarationGenerator.cs index 7e3aa554..096725b6 100644 --- a/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/MethodDeclarationGenerator.cs +++ b/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/MethodDeclarationGenerator.cs @@ -13,9 +13,11 @@ internal sealed class MethodDeclarationGenerator private MethodMeta[] methods = null!; private int index; - public string Generate (IEnumerable sourceMethods) + public string Generate (SolutionInspection inspection) { - methods = sourceMethods.OrderBy(m => m.JSSpace).ToArray(); + methods = inspection.StaticMethods + .Concat(inspection.StaticInterfaces.SelectMany(i => i.Methods)) + .OrderBy(m => m.JSSpace).ToArray(); for (index = 0; index < methods.Length; index++) DeclareMethod(); return builder.ToString(); diff --git a/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/TypeDeclarationGenerator.cs b/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/TypeDeclarationGenerator.cs index e9f8d803..6388eac0 100644 --- a/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/TypeDeclarationGenerator.cs +++ b/src/cs/Bootsharp.Publish/Pack/DeclarationGenerator/TypeDeclarationGenerator.cs @@ -11,13 +11,16 @@ internal sealed class TypeDeclarationGenerator (Preferences prefs) private Type type => GetTypeAt(index); private Type? prevType => index == 0 ? null : GetTypeAt(index - 1); private Type? nextType => index == types.Length - 1 ? null : GetTypeAt(index + 1); + private int indent => !string.IsNullOrEmpty(GetNamespace(type)) ? 1 : 0; + private InterfaceMeta[] instanced = null!; private Type[] types = null!; private int index; - public string Generate (IEnumerable sourceTypes) + public string Generate (SolutionInspection inspection) { - types = sourceTypes.OrderBy(GetNamespace).ToArray(); + instanced = [..inspection.InstancedInterfaces]; + types = inspection.Crawled.OrderBy(GetNamespace).ToArray(); for (index = 0; index < types.Length; index++) DeclareType(); return builder.ToString(); @@ -64,17 +67,17 @@ private void CloseNamespace () private void DeclareInterface () { - var indent = !string.IsNullOrEmpty(GetNamespace(type)) ? 1 : 0; AppendLine($"export interface {BuildTypeName(type)}", indent); AppendExtensions(); builder.Append(" {"); - AppendProperties(); + if (instanced.FirstOrDefault(i => i.Type == type) is { } inst) + AppendInstancedMethods(inst); + else AppendProperties(); AppendLine("}", indent); } private void DeclareEnum () { - var indent = !string.IsNullOrEmpty(GetNamespace(type)) ? 1 : 0; AppendLine($"export enum {type.Name} {{", indent); var names = Enum.GetNames(type); for (int i = 0; i < names.Length; i++) @@ -107,16 +110,38 @@ private void AppendProperties () private void AppendProperty (PropertyInfo property) { - var indent = !string.IsNullOrEmpty(GetNamespace(type)) ? 1 : 0; AppendLine(ToFirstLower(property.Name), indent + 1); if (IsNullable(property)) builder.Append('?'); - builder.Append($": {BuildType()};"); + builder.Append(": "); + if (property.PropertyType.IsGenericTypeParameter) builder.Append(property.PropertyType.Name); + else builder.Append(converter.ToTypeScript(property.PropertyType, GetNullability(property))); + builder.Append(';'); + } + + private void AppendInstancedMethods (InterfaceMeta instanced) + { + foreach (var meta in instanced.Methods) + if (meta.Kind == MethodKind.Event) + AppendInstancedEvent(meta); + else AppendInstancedFunction(meta); + } - string BuildType () - { - if (property.PropertyType.IsGenericTypeParameter) return property.PropertyType.Name; - return converter.ToTypeScript(property.PropertyType, GetNullability(property)); - } + private void AppendInstancedEvent (MethodMeta meta) + { + AppendLine(meta.JSName, indent + 1); + builder.Append(": Event<["); + builder.AppendJoin(", ", meta.Arguments.Select(a => $"{a.JSName}: {a.Value.JSTypeSyntax}")); + builder.Append("]>;"); + } + + private void AppendInstancedFunction (MethodMeta meta) + { + AppendLine(meta.JSName, indent + 1); + builder.Append('('); + builder.AppendJoin(", ", meta.Arguments.Select(a => $"{a.JSName}: {a.Value.JSTypeSyntax}")); + builder.Append("): "); + builder.Append(meta.ReturnValue.JSTypeSyntax); + builder.Append(';'); } private void AppendLine (string content, int level) diff --git a/src/cs/Directory.Build.props b/src/cs/Directory.Build.props index 1167aee4..98978c5b 100644 --- a/src/cs/Directory.Build.props +++ b/src/cs/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.0 + 0.3.0 Elringus javascript typescript ts js wasm node deno bun interop codegen https://bootsharp.com diff --git a/src/js/package.json b/src/js/package.json index 794629fd..450c9118 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -1,8 +1,8 @@ { "scripts": { "compile": "sh scripts/compile-test.sh", - "test": "vitest run --silent --pool=vmThreads", - "cover": "npm test -- --coverage.enabled --coverage.100 --coverage.include=**/sideload/*.mjs --coverage.exclude=**/dotnet.* --coverage.allowExternal", + "test": "sh scripts/test.sh", + "cover": "sh scripts/cover.sh", "build": "sh scripts/build.sh" }, "devDependencies": { diff --git a/src/js/scripts/cover.sh b/src/js/scripts/cover.sh new file mode 100644 index 00000000..7c58a29c --- /dev/null +++ b/src/js/scripts/cover.sh @@ -0,0 +1,3 @@ +node --expose-gc ./node_modules/vitest/vitest.mjs run --silent --pool=vmThreads \ + --coverage.enabled --coverage.thresholds.100 --coverage.include=**/sideload/*.mjs \ + --coverage.exclude=**/dotnet.* --coverage.allowExternal diff --git a/src/js/scripts/test.sh b/src/js/scripts/test.sh new file mode 100644 index 00000000..b2af41f7 --- /dev/null +++ b/src/js/scripts/test.sh @@ -0,0 +1 @@ +node --expose-gc ./node_modules/vitest/vitest.mjs run --silent --pool=vmThreads diff --git a/src/js/src/imports.ts b/src/js/src/imports.ts index 25426be7..49d05172 100644 --- a/src/js/src/imports.ts +++ b/src/js/src/imports.ts @@ -1,6 +1,11 @@ import * as bindings from "./bindings.g"; +import { disposeInstance, disposeOnFinalize } from "./instances"; import type { RuntimeAPI } from "./modules"; export function bindImports(runtime: RuntimeAPI) { - runtime.setModuleImports("Bootsharp", bindings); + runtime.setModuleImports("Bootsharp", { + ...bindings, + disposeInstance, + disposeOnFinalize + }); } diff --git a/src/js/src/instances.ts b/src/js/src/instances.ts new file mode 100644 index 00000000..89bd5c90 --- /dev/null +++ b/src/js/src/instances.ts @@ -0,0 +1,43 @@ +import { exports } from "./exports"; + +const finalizer = new FinalizationRegistry(finalizeInstance); +const idToInstance = new Map(); +const idPool = new Array(); +let nextId = -2147483648; // Number.MIN_SAFE_INTEGER is below C#'s Int32.MinValue + +/* v8 ignore start */ // TODO: Figure how to test finalize/dispose behaviour. + +/** Registers specified imported (JS -> C#) interop instance and associates it with unique ID. + * @param instance Interop instance to resolve ID for. + * @return Unique identifier of the registered instance. */ +export function registerInstance(instance: object): number { + const id = idPool.length > 0 ? idPool.shift()! : nextId++; + idToInstance.set(id, instance); + return id; +} + +/** Resolves registered imported (JS -> C#) interop instance from specified ID. + * @param id Unique identifier of the instance. */ +export function getInstance(id: number): object { + return idToInstance.get(id)!; +} + +/** Invoked from C# to notify that imported (JS -> C#) interop instance is no longer + * used (eg, was garbage collected) and can be released on JavaScript side as well. + * @param id Unique identifier of the disposed interop instance. */ +export function disposeInstance(id: number): void { + idToInstance.delete(id); + idPool.push(id); +} + +/** Registers specified exported (C# -> JS) instance to invoke dispose on C# side + * when it's collected (finalized) by JavaScript runtime GC. + * @param instance Interop instance to register. + * @param id Unique identifier of the interop instance. */ +export function disposeOnFinalize(instance: object, id: number): void { + finalizer.register(instance, id); +} + +function finalizeInstance(id: number) { + (<{ DisposeExportedInstance: (id: number) => void }>exports).DisposeExportedInstance(id); +} diff --git a/src/js/test/cs/Test.Types/Interfaces/ExportedInstanced.cs b/src/js/test/cs/Test.Types/Interfaces/ExportedInstanced.cs new file mode 100644 index 00000000..7cb8877a --- /dev/null +++ b/src/js/test/cs/Test.Types/Interfaces/ExportedInstanced.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Test.Types; + +public class ExportedInstanced (string instanceArg) : IExportedInstanced +{ + public string GetInstanceArg () => instanceArg; + + public async Task GetVehicleIdAsync (Vehicle vehicle) + { + await Task.Delay(1); + return vehicle.Id; + } +} diff --git a/src/js/test/cs/Test.Types/Interfaces/ExportedStatic.cs b/src/js/test/cs/Test.Types/Interfaces/ExportedStatic.cs new file mode 100644 index 00000000..5b2e660d --- /dev/null +++ b/src/js/test/cs/Test.Types/Interfaces/ExportedStatic.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace Test.Types; + +public class ExportedStatic : IExportedStatic +{ + public async Task GetInstanceAsync (string arg) + { + await Task.Delay(1); + return new ExportedInstanced(arg); + } +} diff --git a/src/js/test/cs/Test.Types/Interfaces/IExportedInstanced.cs b/src/js/test/cs/Test.Types/Interfaces/IExportedInstanced.cs new file mode 100644 index 00000000..6541e4a3 --- /dev/null +++ b/src/js/test/cs/Test.Types/Interfaces/IExportedInstanced.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Test.Types; + +public interface IExportedInstanced +{ + string GetInstanceArg (); + Task GetVehicleIdAsync (Vehicle vehicle); +} diff --git a/src/js/test/cs/Test.Types/Interfaces/IExportedStatic.cs b/src/js/test/cs/Test.Types/Interfaces/IExportedStatic.cs new file mode 100644 index 00000000..4332c01f --- /dev/null +++ b/src/js/test/cs/Test.Types/Interfaces/IExportedStatic.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Test.Types; + +public interface IExportedStatic +{ + Task GetInstanceAsync (string arg); +} diff --git a/src/js/test/cs/Test.Types/Interfaces/IImportedInstanced.cs b/src/js/test/cs/Test.Types/Interfaces/IImportedInstanced.cs new file mode 100644 index 00000000..43aba0fc --- /dev/null +++ b/src/js/test/cs/Test.Types/Interfaces/IImportedInstanced.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Test.Types; + +public interface IImportedInstanced +{ + string GetInstanceArg (); + Task GetVehicleIdAsync (Vehicle vehicle); +} diff --git a/src/js/test/cs/Test.Types/Interfaces/IImportedStatic.cs b/src/js/test/cs/Test.Types/Interfaces/IImportedStatic.cs new file mode 100644 index 00000000..1348b991 --- /dev/null +++ b/src/js/test/cs/Test.Types/Interfaces/IImportedStatic.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Test.Types; + +public interface IImportedStatic +{ + Task GetInstanceAsync (string arg); +} diff --git a/src/js/test/cs/Test.Types/Registry.cs b/src/js/test/cs/Test.Types/Vehicle/Registry.cs similarity index 95% rename from src/js/test/cs/Test.Types/Registry.cs rename to src/js/test/cs/Test.Types/Vehicle/Registry.cs index a179924f..43275629 100644 --- a/src/js/test/cs/Test.Types/Registry.cs +++ b/src/js/test/cs/Test.Types/Vehicle/Registry.cs @@ -24,14 +24,14 @@ public static float CountTotalSpeed () [JSInvokable] public static async Task> ConcatRegistriesAsync (IReadOnlyList registries) { - await Task.Delay(10); + await Task.Delay(1); return registries.Concat(GetRegistries()).ToArray(); } [JSInvokable] public static async Task> MapRegistriesAsync (IReadOnlyDictionary map) { - await Task.Delay(10); + await Task.Delay(1); return map.Concat(GetRegistryMap()).ToDictionary(kv => kv.Key, kv => kv.Value); } diff --git a/src/js/test/cs/Test.Types/TrackType.cs b/src/js/test/cs/Test.Types/Vehicle/TrackType.cs similarity index 100% rename from src/js/test/cs/Test.Types/TrackType.cs rename to src/js/test/cs/Test.Types/Vehicle/TrackType.cs diff --git a/src/js/test/cs/Test.Types/Tracked.cs b/src/js/test/cs/Test.Types/Vehicle/Tracked.cs similarity index 100% rename from src/js/test/cs/Test.Types/Tracked.cs rename to src/js/test/cs/Test.Types/Vehicle/Tracked.cs diff --git a/src/js/test/cs/Test.Types/Vehicle.cs b/src/js/test/cs/Test.Types/Vehicle/Vehicle.cs similarity index 100% rename from src/js/test/cs/Test.Types/Vehicle.cs rename to src/js/test/cs/Test.Types/Vehicle/Vehicle.cs diff --git a/src/js/test/cs/Test.Types/Wheeled.cs b/src/js/test/cs/Test.Types/Vehicle/Wheeled.cs similarity index 100% rename from src/js/test/cs/Test.Types/Wheeled.cs rename to src/js/test/cs/Test.Types/Vehicle/Wheeled.cs diff --git a/src/js/test/cs/Test/Program.cs b/src/js/test/cs/Test/Program.cs index 0fe877af..8e734101 100644 --- a/src/js/test/cs/Test/Program.cs +++ b/src/js/test/cs/Test/Program.cs @@ -1,11 +1,45 @@ +using System; +using System.Threading.Tasks; using Bootsharp; +using Bootsharp.Inject; +using Microsoft.Extensions.DependencyInjection; +using Test.Types; + +[assembly: JSExport([typeof(IExportedStatic)])] +[assembly: JSImport([typeof(IImportedStatic)])] namespace Test; public static partial class Program { - public static void Main () => OnMainInvoked(); + private static IServiceProvider services = null!; + + public static void Main () + { + services = new ServiceCollection() + .AddSingleton() + .AddBootsharp() + .BuildServiceProvider() + .RunBootsharp(); + OnMainInvoked(); + } [JSFunction] public static partial void OnMainInvoked (); + + [JSInvokable] + public static async Task GetExportedArgAndVehicleIdAsync (Vehicle vehicle, string arg) + { + var exported = services.GetService()!; + var instance = await exported.GetInstanceAsync(arg); + return await instance.GetVehicleIdAsync(vehicle) + instance.GetInstanceArg(); + } + + [JSInvokable] + public static async Task GetImportedArgAndVehicleIdAsync (Vehicle vehicle, string arg) + { + var imported = services.GetService()!; + var instance = await imported.GetInstanceAsync(arg); + return await instance.GetVehicleIdAsync(vehicle) + instance.GetInstanceArg(); + } } diff --git a/src/js/test/cs/Test/Test.csproj b/src/js/test/cs/Test/Test.csproj index 4ffbd8a1..4777c465 100644 --- a/src/js/test/cs/Test/Test.csproj +++ b/src/js/test/cs/Test/Test.csproj @@ -13,8 +13,10 @@ - + + + diff --git a/src/js/test/spec/interop.spec.ts b/src/js/test/spec/interop.spec.ts index e2236f09..fb5fd778 100644 --- a/src/js/test/spec/interop.spec.ts +++ b/src/js/test/spec/interop.spec.ts @@ -14,22 +14,7 @@ describe("while bootsharp is booted", () => { it("throws when invoking un-assigned JS function from C#", () => { const error = /Failed to invoke '.+' from C#. Make sure to assign function in JavaScript/; any(Test.Program).onMainInvoked = undefined; - expect(Test.Functions.getBytes).toBeUndefined(); - expect(Test.Functions.getString).toBeUndefined(); - expect(Test.Functions.getStringAsync).toBeUndefined(); - expect(Test.Platform.throwJS).toBeUndefined(); - expect(Test.Program.onMainInvoked).toBeUndefined(); - expect(Test.Types.Registry.getRegistry).toBeUndefined(); - expect(Test.Types.Registry.getRegistries).toBeUndefined(); - expect(Test.Types.Registry.getRegistryMap).toBeUndefined(); - expect(() => to<() => void>(Test.Functions).getStringSerialized()).throw(error); - expect(() => to<() => void>(Test.Functions).getStringAsyncSerialized()).throw(error); - expect(() => to<() => void>(Test.Functions).getBytesSerialized()).throw(error); - expect(() => to<() => void>(Test.Platform).throwJSSerialized()).throw(error); expect(() => to<() => void>(Test.Program).onMainInvokedSerialized()).throw(error); - expect(() => to<() => void>(Test.Types.Registry).getRegistrySerialized()).throw(error); - expect(() => to<() => void>(Test.Types.Registry).getRegistriesSerialized()).throw(error); - expect(() => to<() => void>(Test.Types.Registry).getRegistryMapSerialized()).throw(error); }); it("can invoke C# method", async () => { expect(Test.Invokable.joinStrings("foo", "bar")).toStrictEqual("foobar"); @@ -140,14 +125,14 @@ describe("while bootsharp is booted", () => { }); it("can catch js exception", () => { Test.Platform.throwJS = function () { throw new Error("foo"); }; - expect(Test.Platform.catchException().split("\n")[0]).toStrictEqual("Error: foo"); + expect(Test.Platform.catchException()!.split("\n")[0]).toStrictEqual("Error: foo"); }); it("can catch dotnet exceptions", () => { expect(() => Test.Platform.throwCS("bar")).throw("bar"); }); it("can invoke async method with async js callback", async () => { Test.Functions.getStringAsync = async () => { - await new Promise(res => setTimeout(res, 100)); + await new Promise(res => setTimeout(res, 1)); return "foo"; }; expect(await Test.Functions.echoStringAsync()).toStrictEqual("foo"); @@ -162,4 +147,26 @@ describe("while bootsharp is booted", () => { expect(Test.Invokable.getIdxEnumOne() === Test.IdxEnum.One).toBeTruthy(); expect(Test.Invokable.getIdxEnumOne() === Test.IdxEnum.Two).not.toBeTruthy(); }); + it("can interop with exported interfaces", async () => { + const result = await Test.Program.getExportedArgAndVehicleIdAsync({ id: "foo", maxSpeed: 0 }, "bar"); + expect(result).toStrictEqual("foobar"); + }); + it("can interop with imported interfaces", async () => { + class Imported { + constructor(private arg: string) { } + getInstanceArg() { return this.arg; } + async getVehicleIdAsync(vehicle: Test.Types.Vehicle) { + await new Promise(res => setTimeout(res, 1)); + return vehicle.id; + } + } + Test.Types.ImportedStatic.getInstanceAsync = async (arg) => { + await new Promise(res => setTimeout(res, 1)); + return new Imported(arg); + }; + const result1 = await Test.Program.getImportedArgAndVehicleIdAsync({ id: "foo", maxSpeed: 0 }, "bar"); + const result2 = await Test.Program.getImportedArgAndVehicleIdAsync({ id: "baz", maxSpeed: 0 }, "nya"); + expect(result1).toStrictEqual("foobar"); + expect(result2).toStrictEqual("baznya"); + }); });