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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

# production
/build
**/pack

# misc
.DS_Store
Expand Down
14 changes: 7 additions & 7 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
<Project>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="4.14.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="NUnit" Version="4.4.0" />
<PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.0.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="NUnit" Version="4.6.1" />
<PackageVersion Include="NUnit.Analyzers" Version="4.14.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
Expand Down
2 changes: 1 addition & 1 deletion src/TypeShim.Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@

Rule ID | Category | Severity | Notes
--------|----------|----------|-------

TSHIM010 | Usage | Error | TypeShimAnalyzer
22 changes: 18 additions & 4 deletions src/TypeShim.Analyzers/TypeShimAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,22 +21,38 @@ internal sealed class TypeShimAnalyzer : DiagnosticAnalyzer
TypeShimDiagnostics.NonExportedTypeInInteropApiRule,
TypeShimDiagnostics.UnderDevelopmentTypeRule,
TypeShimDiagnostics.NoGenericsTSExportRule,
TypeShimDiagnostics.NoGenericsPublicMethodRule
TypeShimDiagnostics.NoGenericsPublicMethodRule,
TypeShimDiagnostics.MixedExportRule
];

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)
{
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();
Expand Down
9 changes: 9 additions & 0 deletions src/TypeShim.Analyzers/TypeShimDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions src/TypeShim.E2E/TypeShim.E2E.Wasm/JSExportClass.cs
Original file line number Diff line number Diff line change
@@ -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<JSType.Promise<JSType.Boolean>>]
public static Task<bool> IsPositiveAsync([JSMarshalAs<JSType.Number>] int value) => Task.FromResult(value > 0);

[JSExport]
[return: JSMarshalAs<JSType.Promise<JSType.Number>>]
public static Task<int> AddAsync([JSMarshalAs<JSType.Number>] int a, [JSMarshalAs<JSType.Number>] int b) => Task.FromResult(a + b);

[JSExport]
[return: JSMarshalAs<JSType.Promise<JSType.Void>>]
public static Task CompleteAsync() => Task.CompletedTask;

[JSExport]
[return: JSMarshalAs<JSType.Any>]
public static object CreateIdentityObject([JSMarshalAs<JSType.Number>] int id) => new IdentityPayload(id);

[JSExport]
[return: JSMarshalAs<JSType.Void>]
public static void RememberObject([JSMarshalAs<JSType.Any>] object obj) => _identityObject = obj;

[JSExport]
[return: JSMarshalAs<JSType.Number>]
public static int ReadIdentityId([JSMarshalAs<JSType.Any>] object obj) => ((IdentityPayload)obj).Id;

[JSExport]
[return: JSMarshalAs<JSType.Promise<JSType.Number>>]
public static Task<int> ReadIdentityIdAsync([JSMarshalAs<JSType.Any>] object obj) => Task.FromResult(((IdentityPayload)obj).Id);

[JSExport]
[return: JSMarshalAs<JSType.Array<JSType.Number>>]
public static int[] ReadIdentityIds([JSMarshalAs<JSType.Array<JSType.Any>>] object[] objs) => objs.Select(obj => ((IdentityPayload)obj).Id).ToArray();

[JSExport]
[return: JSMarshalAs<JSType.Boolean>]
public static bool IsRememberedObject([JSMarshalAs<JSType.Any>] 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);
}
72 changes: 72 additions & 0 deletions src/TypeShim.E2E/vitest/src/jsexport.test.ts
Original file line number Diff line number Diff line change
@@ -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]));
Comment thread
ArcadeMode marked this conversation as resolved.
});

test('Can pass string array around the boundary', () => {
expect(JSExportClass.PrefixAll(['a', 'b', 'c'], 'pre-')).toEqual(['pre-a', 'pre-b', 'pre-c']);
});
});
2 changes: 1 addition & 1 deletion src/TypeShim.E2E/vitest/src/simple-functions.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<INamedTypeSymbol> 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);
}
}
Loading
Loading