Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/guide/specialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ const comparer = Program.getComparer();
comparer.compare("a", "b"); // -1
```

::: tip
When the specialized `Clr` type is a class, it will also affect (specialize) any subclasses discovered on the interop surfaces.
:::

## Injecting Code

The `[SpecializeImport]` attribute accepts optional `CS`, `JS`, `JSCtor` and `Decl` snippets that are spliced verbatim into the generated C# or JavaScript proxies and its TypeScript declaration. This lets the imported proxy satisfy JS-side contracts that aren't expressible through the C# abstract members alone — for example, injecting an iterator:
Expand All @@ -56,6 +60,10 @@ The `[SpecializeImport]` attribute accepts optional `CS`, `JS`, `JSCtor` and `De
Decl: "[Symbol.iterator](): IterableIterator<T>;")]
```

The `CS` snippet can contain `$full` markers — they will be replaced with the fully-qualified type name of the specialized instance. This allows referencing the concrete specialized instances in the proxy when the specialization is applied to a base class.

The `Decl` snippet can contain `$full`, `$name` and `$T{I}` markers — first is the same as CS one, but in TypeScript context, name is the short type name and `T` is the fully-qualified name of the generic type argument with the `{I}` inde (if any), for example `$T{0}` is replaced with the first generic argument.

When `Decl` value starts with `export ` — the content will replace the entire TypeScript declaration of the type, instead of splicing it into the bottom of the default type declaration.

::: tip EXAMPLE
Expand Down
20 changes: 16 additions & 4 deletions src/cs/Bootsharp.Common.Test/InstancesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,23 @@ public void ShortCircuitsImportedProxies ()
[Fact]
public void ShortCircuitsSpecializedExportsWrappingImportedProxy ()
{
Assert.Equal(Export(new SpecializedExport(new Proxy(1))), Export(new SpecializedExport(new Proxy(1))));
RegisterExport(typeof(Bar), _ => new SpecializedExport(new Proxy(1)));
Assert.Equal(Export(new Bar()), Export(new Bar()));
}

[Fact]
public void GeneratesUniqueIdsForSpecializedExportsNotWrappingImportedProxy ()
{
Assert.NotEqual(Export(new SpecializedExport(new object())), Export(new SpecializedExport(new object())));
RegisterExport(typeof(Foo), it => new SpecializedExport(it));
Assert.NotEqual(Export(new Foo()), Export(new Foo()));
}

[Fact]
public void ShortCircuitsRegisteredSpecializedExports ()
{
var exported = new Foo();
RegisterExport(typeof(Foo), it => new SpecializedExport(it));
Assert.Equal(Export(exported), Export(exported));
}

[Fact]
Expand All @@ -87,10 +97,11 @@ public void InvokesExportFactoryCallbacks ()
{
var exported = false;
var disposed = false;
var id = Export(new object(), (_, _) => {
RegisterExport(typeof(Bar), null, (_, _) => {
exported = true;
return () => disposed = true;
});
var id = Export(new Bar());
Assert.True(exported);
Assert.False(disposed);
DisposeExported(id);
Expand Down Expand Up @@ -154,7 +165,8 @@ public void UnwrapsResolvedSpecializedImportsOfValueType ()
public void UnwrapsResolvedSpecializedExports ()
{
var exported = new Foo();
Assert.Same(exported, Resolve<IFoo>(Export(new SpecializedExport(exported))));
RegisterExport(typeof(Foo), it => new SpecializedExport(it));
Assert.Same(exported, Resolve<IFoo>(Export(exported)));
}

[Fact]
Expand Down
11 changes: 6 additions & 5 deletions src/cs/Bootsharp.Common/Attributes/SpecializeExportAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ namespace Bootsharp;
/// The exported specialization is expected to be paired with the <see cref="SpecializeImportAttribute"/>
/// counterpart and contain implementations for all the abstract members defined on the imported specialization.
/// </summary>
/// <param name="Clr">
/// The CLR type to specialize the export for.
/// When the type is a class, will specialize subclasses as well.
/// </param>
[AttributeUsage(AttributeTargets.Class)]
public sealed class SpecializeExportAttribute (Type clr) : Attribute
public sealed class SpecializeExportAttribute (Type Clr) : Attribute
{
/// <summary>
/// The CLR type to specialize the export for.
/// </summary>
public Type Clr { get; } = clr;
public Type Clr { get; } = Clr;
}
42 changes: 24 additions & 18 deletions src/cs/Bootsharp.Common/Attributes/SpecializeImportAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,35 @@ namespace Bootsharp;
/// the actual interop surface. All the abstract members are expected to be implemented on the paired export
/// specialization of the class annotated with <see cref="SpecializeExportAttribute"/>.
/// </summary>
/// <param name="Clr">
/// The CLR type to specialize the import for.
/// When the type is a class, will specialize subclasses as well.
/// </param>
/// <param name="CS">
/// Raw snippet spliced into the generated C# import proxy class body.
/// All occurrences of '$full' are replaced with the fully-qualified type name of the specialized instance type.
/// </param>
/// <param name="JS">
/// Raw snippet spliced into the generated JavaScript export proxy class body.
/// </param>
/// <param name="JSCtor">
/// Raw snippet spliced into the generated JavaScript export proxy constructor.
/// </param>
/// <param name="Decl">
/// Raw snippet spliced into the generated TypeScript declaration.
/// When starts with 'export ' will instead replace the whole declaration.
/// Occurrences of '$name' are replaced with the name of the specialized instance type.
/// Occurrences of '$full' are replaced with the fully-qualified name of the specialized instance type.
/// When the instance type is generic, occurrences of '$T{I}' are replaced with the fully-qualified names
/// of the generic type arguments with the {I} index, starting with 0 (eg, '$T0' is the first generic arg).
/// </param>
[AttributeUsage(AttributeTargets.Class)]
public sealed class SpecializeImportAttribute (Type clr, string? CS = null, string? JS = null,
public sealed class SpecializeImportAttribute (Type Clr, string? CS = null, string? JS = null,
string? JSCtor = null, string? Decl = null) : Attribute
Comment thread
elringus marked this conversation as resolved.
{
/// <summary>
/// The CLR type to specialize the import for.
/// </summary>
public Type Clr { get; } = clr;
/// <summary>
/// Raw snippet spliced into the generated C# import proxy class body.
/// </summary>
public Type Clr { get; } = Clr;
public string? CS { get; } = CS;
/// <summary>
/// Raw snippet spliced into the generated JavaScript export proxy class body.
/// </summary>
public string? JS { get; } = JS;
/// <summary>
/// Raw snippet spliced into the generated JavaScript export proxy constructor.
/// </summary>
public string? JSCtor { get; } = JSCtor;
/// <summary>
/// Raw snippet spliced into the generated TypeScript declaration.
/// When starts with 'export ' will instead replace the whole declaration.
/// </summary>
public string? Decl { get; } = Decl;
}
31 changes: 22 additions & 9 deletions src/cs/Bootsharp.Common/Interop/Instances.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ namespace Bootsharp;
public static class Instances
{
/// <summary>
/// Invoked on <see cref="Export"/> when registering the instance.
/// Invoked on <see cref="Export"/> when registering an exported instance.
/// </summary>
/// <param name="id">The unique identifier of the instance.</param>
/// <param name="it">The registered instance.</param>
/// <param name="it">The registered instance or the specialized wrapper, when applicable.</param>
/// <returns>The callback to invoke when disposing the instance.</returns>
public delegate Action ExportCallback<T> (int id, T it) where T : class;
public delegate Action ExportCallback (int id, object it);

private static readonly Dictionary<int, WeakReference> importedById = [];
private static readonly Dictionary<Type, Func<int, object>> importers = [];
private static readonly Dictionary<Type, (Func<object, object>? spec, ExportCallback? cb)> exporters = [];
private static readonly Dictionary<int, object> exportedById = [];
private static readonly Dictionary<object, int> idByExported = new(ReferenceEqualityComparer.Instance);
private static readonly Dictionary<int, Action> onDisposeById = [];
Expand All @@ -47,20 +48,22 @@ public static class Instances
/// <summary>
/// Registers specified exported (C#) instance and returns the associated unique ID.
/// Short-circuits already registered exported and imported instances.
/// Invokes applicable exporters registered with <see cref="RegisterExport"/>.
/// </summary>
/// <param name="it">The instance to register.</param>
/// <param name="cb">Callback to invoke when registering and disposing the instance.</param>
/// <returns>Unique ID associated with the registered instance.</returns>
public static int Export<T> (T? it, ExportCallback<T>? cb = null) where T : class
public static int Export<T> (T? it)
{
if (it is null) return 0;
if (it is JSProxy js) return js._id;
if (it is Delegate { Target: JSProxy del }) return del._id;
if (it is SpecializedExport { _it: JSProxy ejs }) return ejs._id;
if (idByExported.TryGetValue(Key(it), out var id)) return id;
if (idByExported.TryGetValue(it, out var id)) return id;
Comment thread
elringus marked this conversation as resolved.
exporters.TryGetValue(typeof(T), out var exporter);
var stored = exporter.spec is { } specialize ? specialize(it) : it;
if (stored is SpecializedExport { _it: JSProxy ejs }) return ejs._id;
id = idPool.Count > 0 ? idPool.Dequeue() : nextId++;
exportedById[idByExported[Key(it)] = id] = it;
if (cb != null) onDisposeById[id] = cb(id, it);
exportedById[idByExported[Key(stored)] = id] = stored;
if (exporter.cb is { } cb) onDisposeById[id] = cb(id, stored);
return id;
}

Expand Down Expand Up @@ -91,6 +94,16 @@ public static void RegisterImport (Type type, Func<int, object> factory)
importers[type] = factory;
}

/// <summary>
/// Registers special handling for exported (C#) instances of the specified type.
/// Specialized exports register their wrapper factory with the <paramref name="spec"/> function.
/// Instances requiring custom callbacks on construction and disposal use <paramref name="cb"/> delegate.
/// </summary>
public static void RegisterExport (Type type, Func<object, object>? spec, ExportCallback? cb = null)
{
exporters[type] = (spec, cb);
}

/// <summary>
/// Notifies that an imported interop instance with the specified ID is no longer used
/// on the C# side and can be untracked.
Expand Down
81 changes: 58 additions & 23 deletions src/cs/Bootsharp.Publish.Test/GenerateCS/CSInstanceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public class Class
}

[Fact]
public void GeneratesSpecializedExportsForInstancesWithEvents ()
public void RegistersExportsForInstancesWithEvents ()
{
AddAssembly(With(
"""
Expand All @@ -114,19 +114,20 @@ public partial class Class
Execute();
Contains(
"""
internal static int Export (global::IExported it) => Export(it, static (_id, it) => {
it.Changed += HandleChanged;
return () => {
it.Changed -= HandleChanged;
};

void HandleChanged (global::Record arg1, global::IExported arg2) => Interop.JS_Export_IExported_BroadcastChanged_Serialized(_id, Serializer.Serialize(arg1, SerializerContext.Record), Instances.Export(arg2));
});
Bootsharp.Instances.RegisterExport(typeof(global::IExported), null, static (_id, obj) => {
var it = (global::IExported)obj;
it.Changed += HandleChanged;
return () => {
it.Changed -= HandleChanged;
};

void HandleChanged (global::Record arg1, global::IExported arg2) => Interop.JS_Export_IExported_BroadcastChanged_Serialized(_id, Serializer.Serialize(arg1, SerializerContext.Record), Instances.Export(arg2));
});
""");
}

[Fact]
public void DoesNotGenerateDuplicateSpecializedExports ()
public void DoesNotDuplicateExportsForInstanceWithEvents ()
{
AddAssembly(With(
"""
Expand All @@ -143,7 +144,7 @@ public class Class
}
"""));
Execute();
Once(@"internal static int Export \(global::IBi it\)");
Once(@"RegisterExport\(typeof\(global::IBi\)");
}

[Fact]
Expand Down Expand Up @@ -268,14 +269,48 @@ public sealed class JS_Import_Custom (int id) : global::CustomImport(id)
""");
Contains(
"""
internal static int Export (global::Custom it) => Export(new global::CustomExport(it), static (_id, it) => {
it.AddedEvent += HandleAddedEvent;
return () => {
it.AddedEvent -= HandleAddedEvent;
};

void HandleAddedEvent () => Interop.JS_Export_Custom_BroadcastAddedEvent_Serialized(_id);
});
Bootsharp.Instances.RegisterExport(typeof(global::Custom), static it => new global::CustomExport((global::Custom)it), static (_id, obj) => {
var it = (global::CustomExport)obj;
it.AddedEvent += HandleAddedEvent;
return () => {
it.AddedEvent -= HandleAddedEvent;
};

void HandleAddedEvent () => Interop.JS_Export_Custom_BroadcastAddedEvent_Serialized(_id);
});
""");
}

[Fact]
public void GeneratesForCustomSpecializationOfBaseClass ()
{
AddAssembly(With(
"""
public abstract class Event<T>;
public sealed class IntEvent : Event<int>;

[SpecializeImport(typeof(Event<>), CS: "protected override object Unwrap () => new $full();")]
public abstract class EventImport<T> (int id) : SpecializedImport(id);

[SpecializeExport(typeof(Event<>))]
public sealed class EventExport<T> (Event<T> it) : SpecializedExport(it);

public class Class
{
[Export] public static IntEvent Foo (IntEvent it) => default!;
}
"""));
Execute();
Contains("Bootsharp.Instances.RegisterExport(typeof(global::IntEvent), static it => new global::EventExport<global::System.Int32>((global::IntEvent)it));");
Contains("Instances.RegisterImport(typeof(global::IntEvent), static id => new global::Bootsharp.Generated.JS_Import_IntEvent(id));");
Contains(
"""
public sealed class JS_Import_IntEvent (int id) : global::EventImport<global::System.Int32>(id)
{
~JS_Import_IntEvent() => Instances.DisposeImported(_id);

protected override object Unwrap () => new global::IntEvent();
}
""");
}

Expand All @@ -290,10 +325,10 @@ public void GeneratesForBuiltInSpecializations ()
[Export] public static CancellationToken Bar (CancellationToken ct) => default!;
"""));
Execute();
Contains("int Export (global::System.Collections.Generic.ICollection<global::System.Int32> it) => Export(new global::Bootsharp.Specialized.CollectionExport<global::System.Int32>(it)");
Contains("int Export (global::System.Collections.Generic.IList<global::System.Int32> it) => Export(new global::Bootsharp.Specialized.ListExport<global::System.Int32>(it)");
Contains("int Export (global::System.Collections.Generic.IDictionary<global::System.Int32, global::System.String> it) => Export(new global::Bootsharp.Specialized.DictionaryExport<global::System.Int32, global::System.String>(it)");
Contains("int Export (global::System.Threading.CancellationToken it) => Export(new global::Bootsharp.Specialized.CancellationTokenExport(it)");
Contains("RegisterExport(typeof(global::System.Collections.Generic.ICollection<global::System.Int32>), static it => new global::Bootsharp.Specialized.CollectionExport<global::System.Int32>((global::System.Collections.Generic.ICollection<global::System.Int32>)it));");
Contains("RegisterExport(typeof(global::System.Collections.Generic.IList<global::System.Int32>), static it => new global::Bootsharp.Specialized.ListExport<global::System.Int32>((global::System.Collections.Generic.IList<global::System.Int32>)it));");
Contains("RegisterExport(typeof(global::System.Collections.Generic.IDictionary<global::System.Int32, global::System.String>), static it => new global::Bootsharp.Specialized.DictionaryExport<global::System.Int32, global::System.String>((global::System.Collections.Generic.IDictionary<global::System.Int32, global::System.String>)it));");
Contains("RegisterExport(typeof(global::System.Threading.CancellationToken), static it => new global::Bootsharp.Specialized.CancellationTokenExport((global::System.Threading.CancellationToken)it), static (_id, obj) => {");
Contains("class JS_Import_System_Collections_Generic_ICollection_Of_System_Int32 (int id) : global::Bootsharp.Specialized.CollectionImport<global::System.Int32>");
Contains("class JS_Import_System_Collections_Generic_IList_Of_System_Int32 (int id) : global::Bootsharp.Specialized.ListImport<global::System.Int32>");
Contains("class JS_Import_System_Collections_Generic_IDictionary_Of_System_Int32_And_System_String (int id) : global::Bootsharp.Specialized.DictionaryImport<global::System.Int32, global::System.String>");
Expand Down
Loading
Loading