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)