diff --git a/.gitignore b/.gitignore
index 94d133cb..4217463a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
# production
/build
+**/pack
# misc
.DS_Store
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 268517b8..156f7d62 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -2,13 +2,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/TypeShim.Analyzers/AnalyzerReleases.Unshipped.md b/src/TypeShim.Analyzers/AnalyzerReleases.Unshipped.md
index bebfc94d..9bed3134 100644
--- a/src/TypeShim.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/src/TypeShim.Analyzers/AnalyzerReleases.Unshipped.md
@@ -5,4 +5,4 @@
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
-
+TSHIM010 | Usage | Error | TypeShimAnalyzer
diff --git a/src/TypeShim.Analyzers/TypeShimAnalyzer.cs b/src/TypeShim.Analyzers/TypeShimAnalyzer.cs
index 8da40150..00671665 100644
--- a/src/TypeShim.Analyzers/TypeShimAnalyzer.cs
+++ b/src/TypeShim.Analyzers/TypeShimAnalyzer.cs
@@ -4,9 +4,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
-using System.Diagnostics;
using System.Linq;
-using System.Threading;
using TypeShim.Shared;
namespace TypeShim.Analyzers;
@@ -23,7 +21,8 @@ internal sealed class TypeShimAnalyzer : DiagnosticAnalyzer
TypeShimDiagnostics.NonExportedTypeInInteropApiRule,
TypeShimDiagnostics.UnderDevelopmentTypeRule,
TypeShimDiagnostics.NoGenericsTSExportRule,
- TypeShimDiagnostics.NoGenericsPublicMethodRule
+ TypeShimDiagnostics.NoGenericsPublicMethodRule,
+ TypeShimDiagnostics.MixedExportRule
];
public override void Initialize(AnalysisContext context)
@@ -31,6 +30,21 @@ public override void Initialize(AnalysisContext context)
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(AnalyzeClass, SymbolKind.NamedType);
+ context.RegisterSymbolAction(AnalyzeMethodForMixedExport, SymbolKind.Method);
+ }
+
+ private static void AnalyzeMethodForMixedExport(SymbolAnalysisContext context)
+ {
+ IMethodSymbol methodSymbol = (IMethodSymbol)context.Symbol;
+ bool hasJSExport = SymbolFacts.HasJSExportAttribute(methodSymbol);
+ if (!hasJSExport) return;
+
+ bool classHasTSExport = SymbolFacts.HasTSExportAttribute(methodSymbol.ContainingType);
+ if (classHasTSExport)
+ {
+ Diagnostic diagnostic = Diagnostic.Create(TypeShimDiagnostics.MixedExportRule, methodSymbol.Locations[0], methodSymbol.Name, methodSymbol.ContainingType.Name);
+ context.ReportDiagnostic(diagnostic);
+ }
}
private static void AnalyzeClass(SymbolAnalysisContext context)
@@ -38,7 +52,7 @@ private static void AnalyzeClass(SymbolAnalysisContext context)
if (context.Symbol is not INamedTypeSymbol type || type.TypeKind != TypeKind.Class)
return;
- bool hasTSExport = SymbolFacts.HasAttribute(type, "TypeShim.TSExportAttribute");
+ bool hasTSExport = SymbolFacts.HasTSExportAttribute(type);
if (!hasTSExport)
return;
//Debugger.Launch();
diff --git a/src/TypeShim.Analyzers/TypeShimDiagnostics.cs b/src/TypeShim.Analyzers/TypeShimDiagnostics.cs
index e1103e31..827ae7d3 100644
--- a/src/TypeShim.Analyzers/TypeShimDiagnostics.cs
+++ b/src/TypeShim.Analyzers/TypeShimDiagnostics.cs
@@ -24,6 +24,15 @@ internal static class TypeShimDiagnostics
isEnabledByDefault: true,
description: "TSExport public API should use supported .NET-JS interop types or (other) TSExport classes.");
+ internal static readonly DiagnosticDescriptor MixedExportRule = new(
+ id: "TSHIM010",
+ title: "Mixed TSExport and JSExport attributes",
+ messageFormat: "Method '{0}' cannot be marked with [JSExport] because its containing class '{1}' is marked with [TSExport]",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "A class marked with [TSExport] cannot contain methods marked with [JSExport].");
+
internal static readonly DiagnosticDescriptor UnderDevelopmentTypeRule = new(
id: "TSHIM007",
title: "Type under development",
diff --git a/src/TypeShim.E2E/TypeShim.E2E.Wasm/JSExportClass.cs b/src/TypeShim.E2E/TypeShim.E2E.Wasm/JSExportClass.cs
new file mode 100644
index 00000000..300ee1d2
--- /dev/null
+++ b/src/TypeShim.E2E/TypeShim.E2E.Wasm/JSExportClass.cs
@@ -0,0 +1,69 @@
+using System.Linq;
+using System.Runtime.InteropServices.JavaScript;
+using System.Threading.Tasks;
+
+namespace TypeShim.E2E.Wasm;
+
+public static partial class JSExportClass
+{
+ private static object? _identityObject;
+
+ [JSExport]
+ public static int Add(int a, int b) => a + b;
+
+ [JSExport]
+ public static int GetSum(int[] ints) => ints.Sum();
+
+ [JSExport]
+ public static string GetGreeting() => "Hello, from JSExport";
+
+ [JSExport]
+ public static bool IsEven(int value) => value % 2 == 0;
+
+ [JSExport]
+ public static double MultiplyDouble(double left, double right) => left * right;
+
+ [JSExport]
+ public static string Describe(int x, bool y, string z) => $"{x}:{y}:{z}";
+
+ [JSExport]
+ [return: JSMarshalAs>]
+ public static Task IsPositiveAsync([JSMarshalAs] int value) => Task.FromResult(value > 0);
+
+ [JSExport]
+ [return: JSMarshalAs>]
+ public static Task AddAsync([JSMarshalAs] int a, [JSMarshalAs] int b) => Task.FromResult(a + b);
+
+ [JSExport]
+ [return: JSMarshalAs>]
+ public static Task CompleteAsync() => Task.CompletedTask;
+
+ [JSExport]
+ [return: JSMarshalAs]
+ public static object CreateIdentityObject([JSMarshalAs] int id) => new IdentityPayload(id);
+
+ [JSExport]
+ [return: JSMarshalAs]
+ public static void RememberObject([JSMarshalAs] object obj) => _identityObject = obj;
+
+ [JSExport]
+ [return: JSMarshalAs]
+ public static int ReadIdentityId([JSMarshalAs] object obj) => ((IdentityPayload)obj).Id;
+
+ [JSExport]
+ [return: JSMarshalAs>]
+ public static Task ReadIdentityIdAsync([JSMarshalAs] object obj) => Task.FromResult(((IdentityPayload)obj).Id);
+
+ [JSExport]
+ [return: JSMarshalAs>]
+ public static int[] ReadIdentityIds([JSMarshalAs>] object[] objs) => objs.Select(obj => ((IdentityPayload)obj).Id).ToArray();
+
+ [JSExport]
+ [return: JSMarshalAs]
+ public static bool IsRememberedObject([JSMarshalAs] object obj) => ReferenceEquals(_identityObject, obj);
+
+ [JSExport]
+ public static string[] PrefixAll(string[] values, string prefix) => values.Select(value => $"{prefix}{value}").ToArray();
+
+ private sealed record IdentityPayload(int Id);
+}
\ No newline at end of file
diff --git a/src/TypeShim.E2E/vitest/src/jsexport.test.ts b/src/TypeShim.E2E/vitest/src/jsexport.test.ts
new file mode 100644
index 00000000..c8637af1
--- /dev/null
+++ b/src/TypeShim.E2E/vitest/src/jsexport.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from 'vitest';
+import { JSExportClass } from '@typeshim/e2e-wasm-lib';
+
+describe('JSExportClass primitives and tasks', () => {
+ test('Add returns number', () => {
+ expect(JSExportClass.Add(2, 3)).toBe(5);
+ });
+
+ test('GetSum handles number arrays', () => {
+ expect(JSExportClass.GetSum([1, 2, 3, 4])).toBe(10);
+ });
+
+ test('GetGreeting returns string', () => {
+ expect(JSExportClass.GetGreeting()).toBe('Hello, from JSExport');
+ });
+
+ test('IsEven returns true for even value', () => {
+ expect(JSExportClass.IsEven(4)).toBe(true);
+ });
+
+ test('IsEven returns false for odd value', () => {
+ expect(JSExportClass.IsEven(5)).toBe(false);
+ });
+
+ test('MultiplyDouble returns expected product', () => {
+ expect(JSExportClass.MultiplyDouble(2.5, 4)).toBeCloseTo(10);
+ });
+
+ test('Describe handles multiple primitive parameters', () => {
+ expect(JSExportClass.Describe(7, true, 'x')).toBe('7:True:x');
+ });
+
+ test('IsPositiveAsync resolves true/false correctly', async () => {
+ await expect(JSExportClass.IsPositiveAsync(3)).resolves.toBe(true);
+ await expect(JSExportClass.IsPositiveAsync(-1)).resolves.toBe(false);
+ });
+
+ test('AddAsync resolves sum', async () => {
+ await expect(JSExportClass.AddAsync(10, 20)).resolves.toBe(30);
+ });
+
+ test('CompleteAsync resolves void', async () => {
+ await expect(JSExportClass.CompleteAsync()).resolves.toBeUndefined();
+ });
+
+ test('Object identity is preserved on C# side with sync method', () => {
+ const identityObject = JSExportClass.CreateIdentityObject(321);
+ JSExportClass.RememberObject(identityObject);
+
+ expect(JSExportClass.ReadIdentityId(identityObject)).toBe(321);
+ expect(JSExportClass.IsRememberedObject(identityObject)).toBe(true);
+ });
+
+ test('Object identity is preserved on C# side with async method', async () => {
+ const identityObject = JSExportClass.CreateIdentityObject(654);
+ JSExportClass.RememberObject(identityObject);
+
+ await expect(JSExportClass.ReadIdentityIdAsync(identityObject)).resolves.toBe(654);
+ expect(JSExportClass.IsRememberedObject(identityObject)).toBe(true);
+ });
+
+ test('ReadIdentityIds returns ids for all objects in array', async () => {
+ const identityObject1 = JSExportClass.CreateIdentityObject(654);
+ const identityObject2 = JSExportClass.CreateIdentityObject(321);
+ const identityObjects = [identityObject1, identityObject2];
+ expect(JSExportClass.ReadIdentityIds(identityObjects)).toEqual(new Int32Array([654, 321]));
+ });
+
+ test('Can pass string array around the boundary', () => {
+ expect(JSExportClass.PrefixAll(['a', 'b', 'c'], 'pre-')).toEqual(['pre-a', 'pre-b', 'pre-c']);
+ });
+});
\ No newline at end of file
diff --git a/src/TypeShim.E2E/vitest/src/simple-functions.test.ts b/src/TypeShim.E2E/vitest/src/simple-functions.test.ts
index 45fcba6c..20131885 100644
--- a/src/TypeShim.E2E/vitest/src/simple-functions.test.ts
+++ b/src/TypeShim.E2E/vitest/src/simple-functions.test.ts
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach } from 'vitest';
-import { ExportedClass, SimpleReturnMethodsClass, TaskPropertiesClass } from '@typeshim/e2e-wasm-lib';
+import { ExportedClass, SimpleReturnMethodsClass } from '@typeshim/e2e-wasm-lib';
import { dateOffsetHour, dateOnly } from './date';
describe('Simple Functions Test', () => {
diff --git a/src/TypeShim.Generator.Tests/CSharp/CSharpInteropClassRendererTests_JSExport.cs b/src/TypeShim.Generator.Tests/CSharp/CSharpInteropClassRendererTests_JSExport.cs
new file mode 100644
index 00000000..5e95ca89
--- /dev/null
+++ b/src/TypeShim.Generator.Tests/CSharp/CSharpInteropClassRendererTests_JSExport.cs
@@ -0,0 +1,39 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using TypeShim.Generator.CSharp;
+using TypeShim.Generator.Parsing;
+using TypeShim.Shared;
+
+namespace TypeShim.Generator.Tests.CSharp;
+
+internal class CSharpInteropClassRendererTests_JSExport
+{
+ [Test]
+ public void CSharpInteropClass_JSExportOnlyClass_GeneratesNothing()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ INamedTypeSymbol classSymbol = exportedClasses[0];
+
+ InteropTypeInfoCache typeInfoCache = new();
+ ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeInfoCache).Build();
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.CSharp);
+ string interopClass = new CSharpInteropClassRenderer(classInfo, renderContext, new JSObjectMethodResolver([])).Render();
+
+ // No C# interop wrapper should be generated for a JSExport-only class -
+ // the methods are already WASM-exported by Roslyn's source generator.
+ Assert.That(interopClass.Trim(), Is.Empty);
+ }
+}
diff --git a/src/TypeShim.Generator.Tests/Parsing/SyntaxTreeParsingTests_JSExport.cs b/src/TypeShim.Generator.Tests/Parsing/SyntaxTreeParsingTests_JSExport.cs
new file mode 100644
index 00000000..e6cc2cb9
--- /dev/null
+++ b/src/TypeShim.Generator.Tests/Parsing/SyntaxTreeParsingTests_JSExport.cs
@@ -0,0 +1,332 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using TypeShim.Generator.Parsing;
+using TypeShim.Shared;
+
+namespace TypeShim.Generator.Tests.Parsing;
+
+internal class SyntaxTreeParsingTests_JSExport
+{
+ [Test]
+ public void ClassInfoBuilder_TSExportClass_WithJSExportMethod_Throws()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ [TSExport]
+ public partial class C1
+ {
+ [JSExport]
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ INamedTypeSymbol classSymbol = exportedClasses[0];
+
+ Assert.Throws(() =>
+ {
+ InteropTypeInfoCache typeCache = new();
+ _ = new ClassInfoBuilder(classSymbol, typeCache).Build();
+ });
+ }
+
+ [Test]
+ public void SymbolExtractor_JSExportOnlyClass_IsDiscovered()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ Assert.That(exportedClasses[0].Name, Is.EqualTo("C1"));
+ }
+
+ [Test]
+ public void SymbolExtractor_JSExportClass_NonJSExportMethodsDropped()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int Kept() => 1;
+ public static int Dropped() => 2;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ INamedTypeSymbol classSymbol = exportedClasses[0];
+
+ IMethodSymbol[] publicOrdinaryMethods = [..
+ classSymbol.GetMembers()
+ .OfType()
+ .Where(m => m.MethodKind == MethodKind.Ordinary && m.DeclaredAccessibility == Accessibility.Public)];
+
+ Assert.That(publicOrdinaryMethods, Has.Length.EqualTo(1));
+ Assert.That(publicOrdinaryMethods[0].Name, Is.EqualTo("Kept"));
+ }
+
+ [Test]
+ public void ClassInfoBuilder_JSExportClass_DropsProperties()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ public int Prop { get; set; }
+ [JSExport]
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ INamedTypeSymbol classSymbol = exportedClasses[0];
+
+ IPropertySymbol[] publicProperties = [..
+ classSymbol.GetMembers()
+ .OfType()
+ .Where(p => p.DeclaredAccessibility == Accessibility.Public)];
+
+ Assert.That(publicProperties, Is.Empty);
+ }
+
+ [Test]
+ public void ClassInfoBuilder_JSExportClass_DropsDeclaredConstructor()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ public C1(int x) {}
+ [JSExport]
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ INamedTypeSymbol classSymbol = exportedClasses[0];
+
+ // After the rewriter drops the declared C1(int) ctor, Roslyn synthesizes an implicit default ctor.
+ IMethodSymbol[] publicInstanceCtors = [..
+ classSymbol.Constructors.Where(m => m.DeclaredAccessibility == Accessibility.Public && !m.IsStatic)];
+
+ Assert.That(publicInstanceCtors, Has.Length.EqualTo(1));
+ Assert.That(publicInstanceCtors[0].Parameters, Is.Empty,
+ "Expected only the implicit default ctor - the user-declared C1(int) should have been dropped by the rewriter.");
+ }
+
+ [Test]
+ public void ClassInfoBuilder_JSExportMethod_WithOverload_Throws()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int M1() => 1;
+ [JSExport]
+ public static int M1(int x) => x;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ INamedTypeSymbol classSymbol = exportedClasses[0];
+
+ Assert.Throws(() =>
+ {
+ InteropTypeInfoCache typeCache = new();
+ _ = new ClassInfoBuilder(classSymbol, typeCache).Build();
+ });
+ }
+
+ [Test]
+ public void SymbolExtractor_ClassWithoutAnyExportAttribute_IsNotDiscovered()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ namespace N1;
+ public class C1
+ {
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ Assert.That(symbolExtractor.ExtractAllExportedSymbols(), Is.Empty);
+ }
+
+ [Test]
+ public void SymbolExtractor_JSExportMethodWithFullyQualifiedAttribute_IsRecognized()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ namespace N1;
+ public partial class C1
+ {
+ [System.Runtime.InteropServices.JavaScript.JSExport]
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ Assert.That(exportedClasses[0].Name, Is.EqualTo("C1"));
+ }
+
+ [Test]
+ public void SymbolExtractor_JSExportMethodWithAttributeSuffix_IsRecognized()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExportAttribute]
+ public static int M1() => 1;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ Assert.That(exportedClasses[0].Name, Is.EqualTo("C1"));
+ }
+
+ [Test]
+ public void ClassInfoBuilder_JSExportMethod_WithTSExportClassParameter_Throws()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+
+ [TSExport]
+ public class TSClass { }
+
+ public partial class C1
+ {
+ [JSExport]
+ public static void M1(TSClass p1) { }
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ var classSymbol = symbolExtractor.ExtractAllExportedSymbols().First(s => s.Name == "C1");
+
+ Assert.Throws(() =>
+ {
+ InteropTypeInfoCache typeCache = new();
+ _ = new ClassInfoBuilder(classSymbol, typeCache).Build();
+ });
+ }
+
+ [Test]
+ public void ClassInfoBuilder_JSExportMethod_ReturningTSExportClass_Throws()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+
+ [TSExport]
+ public class TSClass { }
+
+ public partial class C1
+ {
+ [JSExport]
+ public static TSClass M1() => null;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ var classSymbol = symbolExtractor.ExtractAllExportedSymbols().First(s => s.Name == "C1");
+
+ Assert.Throws(() =>
+ {
+ InteropTypeInfoCache typeCache = new();
+ _ = new ClassInfoBuilder(classSymbol, typeCache).Build();
+ });
+ }
+
+ [Test]
+ public void ClassInfoBuilder_JSExportMethod_WithPlainClassParameter_Throws()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+
+ public class PlainClass { }
+
+ public partial class C1
+ {
+ [JSExport]
+ public static void M1(PlainClass p1) { }
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ var classSymbol = symbolExtractor.ExtractAllExportedSymbols().First(s => s.Name == "C1");
+ // Throws NotSupportedTypeException because PlainClass is filtered out in TypeShim compilation (performance), the exception type is not ideal but its enough to prevent invalid codegen.
+ Assert.Throws(() =>
+ {
+ InteropTypeInfoCache typeCache = new();
+ _ = new ClassInfoBuilder(classSymbol, typeCache).Build();
+ });
+ }
+
+ [Test]
+ public void ClassInfoBuilder_JSExportMethod_ReturningPlainClass_Throws()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+
+ public class PlainClass { }
+
+ public partial class C1
+ {
+ [JSExport]
+ public static PlainClass M1() => null;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ var classSymbol = symbolExtractor.ExtractAllExportedSymbols().First(s => s.Name == "C1");
+
+ // Throws NotSupportedTypeException because PlainClass is filtered out in TypeShim compilation (performance), the exception type is not ideal but its enough to prevent invalid codegen.
+ Assert.Throws(() =>
+ {
+ InteropTypeInfoCache typeCache = new();
+ _ = new ClassInfoBuilder(classSymbol, typeCache).Build();
+ });
+ }
+}
diff --git a/src/TypeShim.Generator.Tests/TypeScript/TypeScriptJSExportRendererTests.cs b/src/TypeShim.Generator.Tests/TypeScript/TypeScriptJSExportRendererTests.cs
new file mode 100644
index 00000000..18c5e74f
--- /dev/null
+++ b/src/TypeShim.Generator.Tests/TypeScript/TypeScriptJSExportRendererTests.cs
@@ -0,0 +1,469 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using TypeShim.Generator.Parsing;
+using TypeShim.Generator.Typescript;
+using TypeShim.Shared;
+
+namespace TypeShim.Generator.Tests.TypeScript;
+
+internal class TypeScriptJSExportRendererTests
+{
+ [Test]
+ public void JSExport_StaticMethod_VoidReturn_NoParams()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static void M1() {}
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static M1(): void {
+ TypeShimConfig.exports.N1.C1.M1();
+ }
+}
+
+""");
+ }
+
+ [TestCase("int", "number")]
+ [TestCase("bool", "boolean")]
+ [TestCase("string", "string")]
+ [TestCase("double", "number")]
+ public void JSExport_StaticMethod_PrimitiveReturnType(string typeExpression, string typeScriptType)
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static {{typeExpression}} M1() => default;
+ }
+ """.Replace("{{typeExpression}}", typeExpression));
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static M1(): {{typeScriptType}} {
+ return TypeShimConfig.exports.N1.C1.M1();
+ }
+}
+
+""".Replace("{{typeScriptType}}", typeScriptType));
+ }
+
+ [TestCase("int", "number")]
+ [TestCase("bool", "boolean")]
+ [TestCase("string", "string")]
+ [TestCase("double", "number")]
+ public void JSExport_StaticMethod_PrimitiveParameter(string typeExpression, string typeScriptType)
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static void M1({{typeExpression}} p1) {}
+ }
+ """.Replace("{{typeExpression}}", typeExpression));
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static M1(p1: {{typeScriptType}}): void {
+ TypeShimConfig.exports.N1.C1.M1(p1);
+ }
+}
+
+""".Replace("{{typeScriptType}}", typeScriptType));
+ }
+
+ [Test]
+ public void JSExport_StaticMethod_IntArrayReturnType()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int[] M1() => Array.Empty();
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static M1(): Array {
+ return TypeShimConfig.exports.N1.C1.M1();
+ }
+}
+
+""");
+ }
+
+ [Test]
+ public void JSExport_StaticMethod_IntArrayParameter()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static void M1(int[] p1) {}
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static M1(p1: Array): void {
+ TypeShimConfig.exports.N1.C1.M1(p1);
+ }
+}
+
+""");
+ }
+
+ [Test]
+ public void JSExport_StaticMethod_TaskBoolReturnType()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Threading.Tasks;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static Task M1() => Task.FromResult(true);
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static async M1(): Promise {
+ return TypeShimConfig.exports.N1.C1.M1();
+ }
+}
+
+""");
+ }
+
+ [Test]
+ public void JSExport_StaticMethod_MultipleParameters()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static void M1(int x, bool y, string z) {}
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static M1(x: number, y: boolean, z: string): void {
+ TypeShimConfig.exports.N1.C1.M1(x, y, z);
+ }
+}
+
+""");
+ }
+
+ [Test]
+ public void JSExport_AssemblyExports_UsesOriginalClassName_NoInteropSuffix()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int M1(int p1) => p1;
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ ModuleHierarchyInfo hierarchyInfo = ModuleHierarchyInfo.FromClasses([classInfo]);
+ RenderContext renderCtx = new(null, [classInfo], RenderOptions.TypeScript);
+ new TypescriptAssemblyExportsRenderer(hierarchyInfo, renderCtx).Render();
+
+ AssertEx.EqualOrDiff(renderCtx.ToString(), """
+// TypeShim generated TypeScript module exports interface
+export interface AssemblyExports{
+ N1: {
+ C1: {
+ M1(p1: number): number;
+ };
+ };
+}
+
+""");
+ }
+
+ [Test]
+ public void JSExport_TSClass_HasPrivateConstructor()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static void M1() {}
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ Assert.That(renderContext.ToString(), Does.Contain("private constructor() {}"));
+ Assert.That(renderContext.ToString(), Does.Not.Contain("extends ProxyBase"));
+ }
+
+ [Test]
+ public void JSExport_TwoMethodsInSameClass_BothRendered()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int A() => 1;
+ [JSExport]
+ public static bool B() => true;
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static A(): number {
+ return TypeShimConfig.exports.N1.C1.A();
+ }
+
+ public static B(): boolean {
+ return TypeShimConfig.exports.N1.C1.B();
+ }
+}
+
+""");
+ }
+
+ [Test]
+ public void JSExport_MethodAndNonJSExportMethod_OnlyJSExportRendered()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static int Kept() => 1;
+ public static int Dropped() => 2;
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ Assert.That(renderContext.ToString(), Does.Contain("public static Kept()"));
+ Assert.That(renderContext.ToString(), Does.Not.Contain("Dropped"));
+ }
+
+ [Test]
+ public void JSExport_TwoJSExportClasses_InSameNamespace_BothExported()
+ {
+ SyntaxTree treeA = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class A
+ {
+ [JSExport]
+ public static int MA() => 1;
+ }
+ """);
+ SyntaxTree treeB = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class B
+ {
+ [JSExport]
+ public static int MB() => 2;
+ }
+ """);
+
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(treeA), CSharpFileInfo.Create(treeB)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(2));
+
+ InteropTypeInfoCache typeCache = new();
+ ClassInfo a = new ClassInfoBuilder(exportedClasses[0], typeCache).Build();
+ ClassInfo b = new ClassInfoBuilder(exportedClasses[1], typeCache).Build();
+
+ ModuleHierarchyInfo hierarchyInfo = ModuleHierarchyInfo.FromClasses([a, b]);
+ RenderContext renderCtx = new(null, [a, b], RenderOptions.TypeScript);
+ new TypescriptAssemblyExportsRenderer(hierarchyInfo, renderCtx).Render();
+
+ AssertEx.EqualOrDiff(renderCtx.ToString(), """
+// TypeShim generated TypeScript module exports interface
+export interface AssemblyExports{
+ N1: {
+ A: {
+ MA(): number;
+ };
+ B: {
+ MB(): number;
+ };
+ };
+}
+
+""");
+ }
+
+ [Test]
+ public void JSExport_NestedNamespace_RendersUnderCorrectPath()
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1.N2;
+ public partial class C1
+ {
+ [JSExport]
+ public static int M1() => 1;
+ }
+ """);
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ ModuleHierarchyInfo hierarchyInfo = ModuleHierarchyInfo.FromClasses([classInfo]);
+ RenderContext renderCtx = new(null, [classInfo], RenderOptions.TypeScript);
+ new TypescriptAssemblyExportsRenderer(hierarchyInfo, renderCtx).Render();
+
+ AssertEx.EqualOrDiff(renderCtx.ToString(), """
+// TypeShim generated TypeScript module exports interface
+export interface AssemblyExports{
+ N1: {
+ N2: {
+ C1: {
+ M1(): number;
+ };
+ };
+ };
+}
+
+""");
+ }
+
+ [TestCase("int?", "number | null")]
+ [TestCase("string?", "string | null")]
+ [TestCase("bool?", "boolean | null")]
+ public void JSExport_StaticMethod_NullableReturnType(string typeExpression, string typeScriptType)
+ {
+ SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
+ using System;
+ using System.Runtime.InteropServices.JavaScript;
+ namespace N1;
+ public partial class C1
+ {
+ [JSExport]
+ public static {{typeExpression}} M1() => default;
+ }
+ """.Replace("{{typeExpression}}", typeExpression));
+
+ ClassInfo classInfo = BuildClassInfo(syntaxTree);
+ RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
+ new TypescriptUserClassProxyRenderer(renderContext).Render();
+
+ AssertEx.EqualOrDiff(renderContext.ToString(), """
+export class C1 {
+ private constructor() {}
+
+ public static M1(): {{typeScriptType}} {
+ return TypeShimConfig.exports.N1.C1.M1();
+ }
+}
+
+""".Replace("{{typeScriptType}}", typeScriptType));
+ }
+
+ private static ClassInfo BuildClassInfo(SyntaxTree syntaxTree)
+ {
+ SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)], TestFixture.TargetingPackRefDir);
+ List exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
+ Assert.That(exportedClasses, Has.Count.EqualTo(1));
+ INamedTypeSymbol classSymbol = exportedClasses[0];
+
+ InteropTypeInfoCache typeCache = new();
+ return new ClassInfoBuilder(classSymbol, typeCache).Build();
+ }
+}
diff --git a/src/TypeShim.Generator.Tests/TypeShim.Generator.Tests.csproj b/src/TypeShim.Generator.Tests/TypeShim.Generator.Tests.csproj
index 170d2998..582c121d 100644
--- a/src/TypeShim.Generator.Tests/TypeShim.Generator.Tests.csproj
+++ b/src/TypeShim.Generator.Tests/TypeShim.Generator.Tests.csproj
@@ -9,7 +9,10 @@
-
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
@@ -26,22 +29,20 @@
-
+
- <_SystemRuntimeRef Include="@(ReferencePath)"
- Condition="$([System.String]::Equals('%(Filename)%(Extension)', 'System.Runtime.dll', System.StringComparison.OrdinalIgnoreCase))" />
+ <_SystemRuntimeRef Include="@(ReferencePath)" Condition="$([System.String]::Equals('%(Filename)%(Extension)', 'System.Runtime.dll', System.StringComparison.OrdinalIgnoreCase))" />
<_SystemRuntimePath>%(_SystemRuntimeRef.FullPath)
- <_TargetingPackRefDir Condition="'$(_SystemRuntimePath)' != ''" >$([System.IO.Path]::GetDirectoryName('$(_SystemRuntimePath)'))
+ <_TargetingPackRefDir Condition="'$(_SystemRuntimePath)' != ''">$([System.IO.Path]::GetDirectoryName('$(_SystemRuntimePath)'))
-
+
diff --git a/src/TypeShim.Generator/AttributeNameMatcher.cs b/src/TypeShim.Generator/AttributeNameMatcher.cs
new file mode 100644
index 00000000..1d339e33
--- /dev/null
+++ b/src/TypeShim.Generator/AttributeNameMatcher.cs
@@ -0,0 +1,22 @@
+using System;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace TypeShim.Generator;
+
+internal static class AttributeNameMatcher
+{
+ // [TSExport], [TSExportAttribute], [TypeShim.TSExport], [global::TSExport]
+ internal static bool IsAttributeName(NameSyntax nameSyntax, string attributeName)
+ {
+ string name = nameSyntax switch
+ {
+ IdentifierNameSyntax id => id.Identifier.ValueText,
+ QualifiedNameSyntax q => q.Right.Identifier.ValueText,
+ AliasQualifiedNameSyntax a => a.Name.Identifier.ValueText,
+ _ => nameSyntax.ToString()
+ };
+
+ return string.Equals(name, attributeName, StringComparison.Ordinal)
+ || string.Equals(name, attributeName + "Attribute", StringComparison.Ordinal);
+ }
+}
diff --git a/src/TypeShim.Generator/CSharp/CSharpInteropClassRenderer.cs b/src/TypeShim.Generator/CSharp/CSharpInteropClassRenderer.cs
index 2225eb35..a7360347 100644
--- a/src/TypeShim.Generator/CSharp/CSharpInteropClassRenderer.cs
+++ b/src/TypeShim.Generator/CSharp/CSharpInteropClassRenderer.cs
@@ -16,7 +16,7 @@ public CSharpInteropClassRenderer(ClassInfo classInfo, RenderContext context, JS
{
ArgumentNullException.ThrowIfNull(classInfo);
ArgumentNullException.ThrowIfNull(context);
- if (!classInfo.Methods.Any() && !classInfo.Properties.Any())
+ if (classInfo.IsTSExport && !classInfo.Methods.Any() && !classInfo.Properties.Any())
{
throw new ArgumentException("Interop class must have at least one method or property to render.", nameof(classInfo));
}
@@ -28,6 +28,11 @@ public CSharpInteropClassRenderer(ClassInfo classInfo, RenderContext context, JS
internal string Render()
{
+ if (!_classInfo.IsTSExport)
+ {
+ return string.Empty;
+ }
+
_ctx.AppendLine("#nullable enable")
.AppendLine("// TypeShim generated TypeScript interop definitions")
.AppendLine("using System;")
diff --git a/src/TypeShim.Generator/ExportOnlySyntaxRewriter.cs b/src/TypeShim.Generator/ExportOnlySyntaxRewriter.cs
new file mode 100644
index 00000000..dd1403f7
--- /dev/null
+++ b/src/TypeShim.Generator/ExportOnlySyntaxRewriter.cs
@@ -0,0 +1,68 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace TypeShim.Generator;
+
+public sealed class ExportOnlySyntaxRewriter : CSharpSyntaxRewriter
+{
+ public override SyntaxNode? VisitClassDeclaration(ClassDeclarationSyntax node)
+ {
+ if (HasTSExportAttribute(node))
+ {
+ // TSExport classes are kept whole - downstream parsing already filters by accessibility/kind.
+ return node;
+ }
+
+ return RewriteForJSExport(node);
+ }
+
+ private static bool HasTSExportAttribute(ClassDeclarationSyntax node)
+ {
+ foreach (AttributeListSyntax attributeList in node.AttributeLists)
+ {
+ foreach (AttributeSyntax attribute in attributeList.Attributes)
+ {
+ if (AttributeNameMatcher.IsAttributeName(attribute.Name, "TSExport"))
+ {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static ClassDeclarationSyntax? RewriteForJSExport(ClassDeclarationSyntax node)
+ {
+ List kept = new();
+ foreach (MemberDeclarationSyntax member in node.Members)
+ {
+ if (HasJSExportAttribute(member))
+ {
+ kept.Add(member);
+ }
+ }
+
+ if (kept.Count == 0)
+ {
+ return null;
+ }
+ return node.WithMembers(SyntaxFactory.List(kept));
+ }
+
+ private static bool HasJSExportAttribute(MemberDeclarationSyntax member)
+ {
+ foreach (AttributeListSyntax attributeList in member.AttributeLists)
+ {
+ foreach (AttributeSyntax attribute in attributeList.Attributes)
+ {
+ if (AttributeNameMatcher.IsAttributeName(attribute.Name, "JSExport"))
+ {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/TypeShim.Generator/Parsing/ClassInfo.cs b/src/TypeShim.Generator/Parsing/ClassInfo.cs
index 7ec0f29c..6205de71 100644
--- a/src/TypeShim.Generator/Parsing/ClassInfo.cs
+++ b/src/TypeShim.Generator/Parsing/ClassInfo.cs
@@ -6,6 +6,7 @@ internal sealed class ClassInfo
{
internal required string Namespace { get; init; }
internal required string Name { get; init; }
+ internal required bool IsTSExport { get; init; }
internal required bool IsStatic { get; init; }
internal required InteropTypeInfo Type { get; init; }
internal required ConstructorInfo? Constructor { get; init; }
diff --git a/src/TypeShim.Generator/Parsing/ClassInfoBuilder.cs b/src/TypeShim.Generator/Parsing/ClassInfoBuilder.cs
index 0b3a6105..21d31af6 100644
--- a/src/TypeShim.Generator/Parsing/ClassInfoBuilder.cs
+++ b/src/TypeShim.Generator/Parsing/ClassInfoBuilder.cs
@@ -9,15 +9,18 @@ internal ClassInfo Build()
{
ThrowIfContainsRequiredFields();
+ bool isTSExport = SymbolFacts.HasTSExportAttribute(classSymbol);
+
List properties = BuildProperties();
return new ClassInfo
{
Namespace = classSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty,
Name = classSymbol.Name,
- IsStatic = classSymbol.IsStatic,
+ IsTSExport = isTSExport,
+ IsStatic = !isTSExport || classSymbol.IsStatic,
Type = new InteropTypeInfoBuilder(classSymbol, typeInfoCache).Build(),
- Constructor = BuildConstructor(properties),
- Methods = BuildMethods(),
+ Constructor = isTSExport ? BuildConstructor(properties) : null,
+ Methods = BuildMethods(isTSExport),
Properties = properties,
Comment = new CommentInfoBuilder(classSymbol).Build(),
};
@@ -51,13 +54,18 @@ private List BuildProperties()
return propertyInfoBuilders;
}
- private List BuildMethods()
+ private List BuildMethods(bool isTSExport)
{
Dictionary methodInfos = [];
IEnumerable methodSymbols = classSymbol.GetMembers().OfType()
.Where(m => m.MethodKind == MethodKind.Ordinary && m.DeclaredAccessibility == Accessibility.Public);
foreach (IMethodSymbol methodSymbol in methodSymbols)
{
+ if (isTSExport && SymbolFacts.HasJSExportAttribute(methodSymbol))
+ {
+ throw new NotSupportedMixedExportException($"Method '{classSymbol.Name}.{methodSymbol.Name}' is marked with [JSExport] but its containing class '{classSymbol.Name}' is marked with [TSExport], which is not supported.");
+ }
+
if (methodInfos.ContainsKey(methodSymbol.Name))
{
throw new NotSupportedMethodOverloadException($"Method '{classSymbol.Name}.{methodSymbol.Name}' is overloaded, which is not supported.");
diff --git a/src/TypeShim.Generator/Parsing/MethodInfoBuilder.cs b/src/TypeShim.Generator/Parsing/MethodInfoBuilder.cs
index 26e0da61..296df642 100644
--- a/src/TypeShim.Generator/Parsing/MethodInfoBuilder.cs
+++ b/src/TypeShim.Generator/Parsing/MethodInfoBuilder.cs
@@ -18,13 +18,31 @@ internal MethodInfo Build()
throw new NotSupportedMethodException($"Method {classSymbol}.{memberMethod} must be of kind 'Ordinary', 'PropertyGet', 'PropertySet' or 'Constructor' and have accessibility 'Public'.");
}
+ bool isJSExport = SymbolFacts.HasJSExportAttribute(memberMethod);
IReadOnlyCollection parameters = [.. parameterInfoBuilder.Build()];
+ InteropTypeInfo returnType = typeInfoBuilder.Build();
+
+ if (isJSExport)
+ {
+ foreach (MethodParameterInfo param in parameters)
+ {
+ if (param.Type.RequiresTypeConversion)
+ {
+ throw new NotSupportedJSExportReferenceException($"JSExport methods '{classSymbol.Name}.{memberMethod.Name}' cannot reference classes in its parameter types.");
+ }
+ }
+ if (returnType.RequiresTypeConversion)
+ {
+ throw new NotSupportedJSExportReferenceException($"JSExport methods '{classSymbol.Name}.{memberMethod.Name}' cannot reference classes in its return type.");
+ }
+ }
+
return new MethodInfo()
{
IsStatic = memberMethod.IsStatic,
Name = memberMethod.Name,
Parameters = parameters,
- ReturnType = typeInfoBuilder.Build(),
+ ReturnType = returnType,
Comment = new CommentInfoBuilder(memberMethod).Build(),
};
}
diff --git a/src/TypeShim.Generator/RenderConstants.cs b/src/TypeShim.Generator/RenderConstants.cs
index 8189b5d5..bb028359 100644
--- a/src/TypeShim.Generator/RenderConstants.cs
+++ b/src/TypeShim.Generator/RenderConstants.cs
@@ -16,6 +16,6 @@ internal static class RenderConstants
internal const string ManagedObject = "ManagedObject";
- internal static string InteropClassName(ClassInfo classInfo) => $"{classInfo.Name}Interop";
+ internal static string InteropClassName(ClassInfo classInfo) => classInfo.IsTSExport ? $"{classInfo.Name}Interop" : classInfo.Name;
}
diff --git a/src/TypeShim.Generator/SymbolExtractor.cs b/src/TypeShim.Generator/SymbolExtractor.cs
index 2817fc04..575d2a8f 100644
--- a/src/TypeShim.Generator/SymbolExtractor.cs
+++ b/src/TypeShim.Generator/SymbolExtractor.cs
@@ -23,7 +23,7 @@ private IEnumerable GetExportOnlyTrees()
{
foreach (CSharpFileInfo fileInfo in fileInfos)
{
- TSExportOnlySyntaxRewriter rewriter = new();
+ ExportOnlySyntaxRewriter rewriter = new();
CompilationUnitSyntax root = fileInfo.SyntaxTree.GetCompilationUnitRoot();
if (rewriter.Visit(root) is not CompilationUnitSyntax syntax)
{
diff --git a/src/TypeShim.Generator/TSExportOnlySyntaxRewriter.cs b/src/TypeShim.Generator/TSExportOnlySyntaxRewriter.cs
deleted file mode 100644
index c322aba1..00000000
--- a/src/TypeShim.Generator/TSExportOnlySyntaxRewriter.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System;
-using System.Linq;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-namespace TypeShim.Generator;
-
-public sealed class TSExportOnlySyntaxRewriter : CSharpSyntaxRewriter
-{
- public override SyntaxNode? VisitClassDeclaration(ClassDeclarationSyntax node)
- {
- if (!HasAttribute(node.AttributeLists, "TSExport"))
- return null;
-
- return node; // base.VisitClassDeclaration(node); (if nested types ever get supported)
- }
-
- private static bool HasAttribute(SyntaxList attributeLists, string attributeName)
- => attributeLists
- .SelectMany(al => al.Attributes)
- .Any(a => IsAttributeName(a.Name, attributeName));
-
- private static bool IsAttributeName(NameSyntax nameSyntax, string attributeName)
- {
- // [TSExport], [TSExportAttribute], [TypeShim.TSExport], [global::TSExport]
- var name = nameSyntax switch
- {
- IdentifierNameSyntax id => id.Identifier.ValueText,
- QualifiedNameSyntax q => q.Right.Identifier.ValueText,
- AliasQualifiedNameSyntax a => a.Name.Identifier.ValueText,
- _ => nameSyntax.ToString()
- };
-
- return string.Equals(name, attributeName, StringComparison.Ordinal)
- || string.Equals(name, attributeName + "Attribute", StringComparison.Ordinal);
- }
-}
\ No newline at end of file
diff --git a/src/TypeShim.Shared/Exceptions.cs b/src/TypeShim.Shared/Exceptions.cs
index 4ad7244c..48b267da 100644
--- a/src/TypeShim.Shared/Exceptions.cs
+++ b/src/TypeShim.Shared/Exceptions.cs
@@ -27,6 +27,14 @@ public class NotSupportedMethodOverloadException(string message, Exception? inne
{
}
+public class NotSupportedMixedExportException(string message, Exception? innerException = null) : TypeShimException(message, innerException)
+{
+}
+
+public class NotSupportedJSExportReferenceException(string message, Exception? innerException = null) : TypeShimException(message, innerException)
+{
+}
+
public class NotSupportedConstructorOverloadException(string message, Exception? innerException = null) : TypeShimException(message, innerException)
{
}
diff --git a/src/TypeShim.Shared/InteropTypeInfoBuilder.cs b/src/TypeShim.Shared/InteropTypeInfoBuilder.cs
index 328897e4..da57714d 100644
--- a/src/TypeShim.Shared/InteropTypeInfoBuilder.cs
+++ b/src/TypeShim.Shared/InteropTypeInfoBuilder.cs
@@ -10,7 +10,7 @@ namespace TypeShim.Shared;
internal sealed class InteropTypeInfoBuilder(ITypeSymbol typeSymbol, InteropTypeInfoCache cache)
{
- private readonly bool IsTSExport = typeSymbol.GetAttributes().Any(attributeData => attributeData.AttributeClass?.Name is "TSExportAttribute" or "TSExport");
+ private readonly bool IsTSExport = SymbolFacts.HasTSExportAttribute(typeSymbol);
public InteropTypeInfo Build()
{
diff --git a/src/TypeShim.Analyzers/SymbolFacts.cs b/src/TypeShim.Shared/SymbolFacts.cs
similarity index 60%
rename from src/TypeShim.Analyzers/SymbolFacts.cs
rename to src/TypeShim.Shared/SymbolFacts.cs
index 2e4a8514..9870842b 100644
--- a/src/TypeShim.Analyzers/SymbolFacts.cs
+++ b/src/TypeShim.Shared/SymbolFacts.cs
@@ -1,19 +1,34 @@
-using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis;
using System;
-using System.Collections.Generic;
using System.Linq;
-namespace TypeShim.Analyzers;
+namespace TypeShim.Shared;
internal static class SymbolFacts
{
+ private const string JSExportFqn = "global::System.Runtime.InteropServices.JavaScript.JSExportAttribute";
+ private const string TSExportFqn = "global::TypeShim.TSExportAttribute";
+
internal static bool IsPublicClass(INamedTypeSymbol type)
=> type.TypeKind == TypeKind.Class && type.DeclaredAccessibility == Accessibility.Public;
- internal static bool HasAttribute(INamedTypeSymbol type, string fullName)
+ internal static bool HasJSExportAttribute(ISymbol symbol)
+ => symbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == JSExportFqn);
+
+ internal static bool HasTSExportAttribute(ISymbol symbol)
{
- string globalFullName = $"global::{fullName}";
- return type.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == globalFullName);
+ foreach (AttributeData attr in symbol.GetAttributes())
+ {
+ if (attr.AttributeClass is null) continue;
+ if (attr.AttributeClass.Kind == SymbolKind.ErrorType)
+ {
+ if (attr.AttributeClass.Name is "TSExportAttribute" or "TSExport")
+ return true;
+ }
+ else if (attr.AttributeClass.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == TSExportFqn)
+ return true;
+ }
+ return false;
}
internal static bool IsNullable(ITypeSymbol type)