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
43 changes: 40 additions & 3 deletions Sample/TypeShim.Sample.Client/@typeshim/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ function App() {
export default App;

function E2E() {
console.log("Starting E2E CompilationTest...");
const exportedClass = new ExportedClass({ Id: 1 });
const t = new CompilationTest({
const testObject = new CompilationTest({
NIntProperty: 1,
ByteProperty: 2,
ShortProperty: 3,
Expand All @@ -89,7 +90,7 @@ function E2E() {
TaskOfIntProperty: Promise.resolve(44),
TaskOfLongProperty: Promise.resolve(45),
TaskOfBoolProperty: Promise.resolve(true),
TaskOfCharProperty: Promise.resolve(1 as unknown as string),
TaskOfCharProperty: Promise.resolve('B'),
TaskOfStringProperty: Promise.resolve("Task String"),
TaskOfDoubleProperty: Promise.resolve(67.8),
TaskOfFloatProperty: Promise.resolve(89.0),
Expand All @@ -107,5 +108,41 @@ function E2E() {
ExportedClassArrayProperty: [exportedClass],
});

console.log("E2E CompilationTest instance:", t, CompilationTest.materialize(t));
console.log("E2E CompilationTest instance:", testObject, CompilationTest.materialize(testObject));

testObject.CharProperty = 'Z';
if (testObject.CharProperty !== 'Z') {
console.error(`E2E CharProperty value mismatch. Expected 'Z', got '${testObject.CharProperty}'`);
}
testObject.TaskOfCharProperty = Promise.resolve('Y');
testObject.TaskOfCharProperty.then(value => {
if (value === 'Y') return;
console.error(`E2E TaskOfCharProperty value mismatch. Expected 'Y', got '${value}'`);
}).catch(err => {
console.error("E2E TaskOfCharProperty error:", err);
});

testObject.StringProperty = "Updated Test";
if (testObject.StringProperty !== "Updated Test") {
console.error(`E2E StringProperty value mismatch. Expected 'Updated Test', got '${testObject.StringProperty}'`);
}
testObject.TaskOfStringProperty = Promise.resolve("Updated Task String");
testObject.TaskOfStringProperty.then(value => {
if (value === "Updated Task String") return;
console.error(`E2E TaskOfStringProperty value mismatch. Expected 'Updated Task String', got '${value}'`);
}).catch(err => {
console.error("E2E TaskOfStringProperty error:", err);
});

testObject.ExportedClassProperty.Id = 99;
if (testObject.ExportedClassProperty.Id !== 99) {
console.error(`E2E ExportedClassProperty.Id value mismatch. Expected 99, got '${testObject.ExportedClassProperty.Id}'`);
}
const newExport = new ExportedClass({ Id: 100 });
testObject.ExportedClassProperty = newExport;
if (testObject.ExportedClassProperty.Id !== 100) {
console.error(`E2E ExportedClassProperty reassignment value mismatch. Expected 100, got '${testObject.ExportedClassProperty.Id}'`);
}

console.log("E2E CompilationTest completed.");
}
19 changes: 10 additions & 9 deletions Sample/TypeShim.Sample.Client/@typeshim/wasm-exports/typeshim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class TypeShimConfig {
}
options.setModuleImports("@typeshim", {
unwrap: (obj: any) => obj,
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName]
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName],
unwrapCharPromise: (promise: Promise<any>) => promise.then(c => c.charCodeAt(0))
});
TypeShimConfig._exports = options.assemblyExports;
}
Expand Down Expand Up @@ -96,8 +97,8 @@ export interface AssemblyExports{
set_BoolProperty(instance: ManagedObject, value: boolean): void;
get_StringProperty(instance: ManagedObject): string;
set_StringProperty(instance: ManagedObject, value: string): void;
get_CharProperty(instance: ManagedObject): string;
set_CharProperty(instance: ManagedObject, value: string): void;
get_CharProperty(instance: ManagedObject): number;
set_CharProperty(instance: ManagedObject, value: number): void;
get_DoubleProperty(instance: ManagedObject): number;
set_DoubleProperty(instance: ManagedObject, value: number): void;
get_FloatProperty(instance: ManagedObject): number;
Expand Down Expand Up @@ -126,8 +127,8 @@ export interface AssemblyExports{
set_TaskOfBoolProperty(instance: ManagedObject, value: Promise<boolean>): void;
get_TaskOfByteProperty(instance: ManagedObject): Promise<number>;
set_TaskOfByteProperty(instance: ManagedObject, value: Promise<number>): void;
get_TaskOfCharProperty(instance: ManagedObject): Promise<string>;
set_TaskOfCharProperty(instance: ManagedObject, value: Promise<string>): void;
get_TaskOfCharProperty(instance: ManagedObject): Promise<number>;
set_TaskOfCharProperty(instance: ManagedObject, value: Promise<number>): void;
get_TaskOfStringProperty(instance: ManagedObject): Promise<string>;
set_TaskOfStringProperty(instance: ManagedObject, value: Promise<string>): void;
get_TaskOfDoubleProperty(instance: ManagedObject): Promise<number>;
Expand Down Expand Up @@ -383,11 +384,11 @@ export class CompilationTest extends ProxyBase {
}

public get CharProperty(): string {
return TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.get_CharProperty(this.instance);
return String.fromCharCode(TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.get_CharProperty(this.instance));
}

public set CharProperty(value: string) {
TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.set_CharProperty(this.instance, value);
TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.set_CharProperty(this.instance, value.charCodeAt(0));
}

public get DoubleProperty(): number {
Expand Down Expand Up @@ -505,11 +506,11 @@ export class CompilationTest extends ProxyBase {
}

public get TaskOfCharProperty(): Promise<string> {
return TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.get_TaskOfCharProperty(this.instance);
return TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.get_TaskOfCharProperty(this.instance).then(c => String.fromCharCode(c));
}

public set TaskOfCharProperty(value: Promise<string>) {
TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.set_TaskOfCharProperty(this.instance, value);
TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.set_TaskOfCharProperty(this.instance, value.then(c => c.charCodeAt(0)));
}

public get TaskOfStringProperty(): Promise<string> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using System;
using System.Collections.Generic;
using System.Text;
using TypeShim.Generator.Parsing;
using TypeShim.Generator.Typescript;
using TypeShim.Shared;

namespace TypeShim.Generator.Tests.TypeScript;

internal class TypeScriptUserClassProxyRendererTests_Char
{
[Test]
public void TypeScriptUserClassProxy_InstanceMethod_WithCharReturnType_RendersNumberToStringConversion()
{
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
using System;
using System.Threading.Tasks;
namespace N1;
[TSExport]
public class C1
{
public char M1() {}
}
""");

SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
Assert.That(exportedClasses, Has.Count.EqualTo(1));
INamedTypeSymbol classSymbol = exportedClasses[0];

InteropTypeInfoCache typeCache = new();
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();

RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
new TypescriptUserClassProxyRenderer(renderContext).Render();

AssertEx.EqualOrDiff(renderContext.ToString(), """
export class C1 extends ProxyBase {
constructor() {
super(TypeShimConfig.exports.N1.C1Interop.ctor());
}

public M1(): string {
return String.fromCharCode(TypeShimConfig.exports.N1.C1Interop.M1(this.instance));
}
}

""");
}

[Test]
public void TypeScriptUserClassProxy_InstanceMethod_WithCharParameterType_RendersNumberToStringConversion()
{
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
using System;
using System.Threading.Tasks;
namespace N1;
[TSExport]
public class C1
{
public void M1(char p1) {}
}
""");

SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
Assert.That(exportedClasses, Has.Count.EqualTo(1));
INamedTypeSymbol classSymbol = exportedClasses[0];

InteropTypeInfoCache typeCache = new();
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();

RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
new TypescriptUserClassProxyRenderer(renderContext).Render();

AssertEx.EqualOrDiff(renderContext.ToString(), """
export class C1 extends ProxyBase {
constructor() {
super(TypeShimConfig.exports.N1.C1Interop.ctor());
}

public M1(p1: string): void {
TypeShimConfig.exports.N1.C1Interop.M1(this.instance, p1.charCodeAt(0));
}
}

""");
}

[Test]
public void TypeScriptUserClassProxy_InstanceMethod_WithCharParameterAndReturnType_RendersNumberToStringConversion()
{
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
using System;
using System.Threading.Tasks;
namespace N1;
[TSExport]
public class C1
{
public char M1(char p1) => p1;
}
""");

SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
Assert.That(exportedClasses, Has.Count.EqualTo(1));
INamedTypeSymbol classSymbol = exportedClasses[0];

InteropTypeInfoCache typeCache = new();
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();

RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
new TypescriptUserClassProxyRenderer(renderContext).Render();

AssertEx.EqualOrDiff(renderContext.ToString(), """
export class C1 extends ProxyBase {
constructor() {
super(TypeShimConfig.exports.N1.C1Interop.ctor());
}

public M1(p1: string): string {
return String.fromCharCode(TypeShimConfig.exports.N1.C1Interop.M1(this.instance, p1.charCodeAt(0)));
}
}

""");
}

[Test]
public void TypeScriptUserClassProxy_InstanceProperty_WithCharType_RendersNumberToStringConversion()
{
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
using System;
using System.Threading.Tasks;
namespace N1;
[TSExport]
public class C1
{
public char P1 { get; set; }
}
""");

SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
Assert.That(exportedClasses, Has.Count.EqualTo(1));
INamedTypeSymbol classSymbol = exportedClasses[0];

InteropTypeInfoCache typeCache = new();
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();

RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
new TypescriptUserClassProxyRenderer(renderContext).Render();

AssertEx.EqualOrDiff(renderContext.ToString(), """
export class C1 extends ProxyBase {
constructor(jsObject: C1.Initializer) {
super(TypeShimConfig.exports.N1.C1Interop.ctor(jsObject));
}

public get P1(): string {
return String.fromCharCode(TypeShimConfig.exports.N1.C1Interop.get_P1(this.instance));
}

public set P1(value: string) {
TypeShimConfig.exports.N1.C1Interop.set_P1(this.instance, value.charCodeAt(0));
}
}

""");
}
}
2 changes: 1 addition & 1 deletion TypeShim.Generator/CSharp/JSObjectExtensionsRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ public static partial class JSObjectTaskExtensions
[return: JSMarshalAs<JSType.Promise<JSType.Number>>]
public static partial Task<byte> MarshallAsByteTask([JSMarshalAs<JSType.Object>] JSObject jsObject);

[JSImport("unwrap", "@typeshim")]
[JSImport("unwrapCharPromise", "@typeshim")]
[return: JSMarshalAs<JSType.Promise<JSType.String>>]
public static partial Task<char> MarshallAsCharTask([JSMarshalAs<JSType.Object>] JSObject jsObject);

Expand Down
33 changes: 31 additions & 2 deletions TypeShim.Generator/Typescript/TypeScriptMethodRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,31 @@ private void RenderMethodBody(MethodInfo methodInfo)
else
{
ctx.Append(methodInfo.ReturnType.ManagedType == KnownManagedType.Void ? string.Empty : "return ");
RenderInteropInvocation(methodInfo.Name, methodInfo.Parameters);

RenderCharConversionIfNecessary(methodInfo.ReturnType, () =>
{
RenderInteropInvocation(methodInfo.Name, methodInfo.Parameters);
});

ctx.AppendLine(";");
}
}
ctx.AppendLine("}");

void RenderCharConversionIfNecessary(InteropTypeInfo typeInfo, Action renderCharExpression)
{
// dotnet does not marshall chars as strings, instead as numbers. TypeShim converts to strings on the TS side.
if (methodInfo.ReturnType.ManagedType == KnownManagedType.Char)
ctx.Append("String.fromCharCode(");

renderCharExpression();

if (methodInfo.ReturnType.ManagedType == KnownManagedType.Char)
ctx.Append(")");
if (methodInfo.ReturnType is { ManagedType: KnownManagedType.Task, TypeArgument.ManagedType: KnownManagedType.Char })
ctx.Append(".then(c => String.fromCharCode(c))");
}

void RenderInlineProxyConstruction(InteropTypeInfo typeInfo, string proxyClassName, string sourceVarName)
{
if (typeInfo is { IsNullableType: true })
Expand Down Expand Up @@ -251,8 +270,9 @@ void RenderMethodInvocationParameters(string instanceParameterExpression)
foreach (MethodParameterInfo parameter in methodParameters)
{
if (!isFirst) ctx.Append(", ");

ctx.Append(parameter.IsInjectedInstanceParameter ? instanceParameterExpression : GetInteropInvocationVariable(parameter));
RenderCharConversionIfNecessary(parameter);
isFirst = false;
}
if (initializerObject == null) return;
Expand All @@ -265,6 +285,15 @@ void RenderInteropMethodAccessor(string methodName)
{
ctx.Append(ctx.Class.Namespace).Append('.').Append(RenderConstants.InteropClassName(ctx.Class)).Append('.').Append(methodName);
}

void RenderCharConversionIfNecessary(MethodParameterInfo parameter)
{
// dotnet does not marshall chars as strings atm. We convert from/to numbers while this is the case.
if (parameter.Type.ManagedType == KnownManagedType.Char)
ctx.Append(".charCodeAt(0)");
if (parameter.Type is { ManagedType: KnownManagedType.Task, TypeArgument.ManagedType: KnownManagedType.Char })
ctx.Append(".then(c => c.charCodeAt(0))");
}
}

private static string GetInteropInvocationVariable(MethodParameterInfo param) // TODO: get from ctx localscope (check param.Name call sites!)
Expand Down
3 changes: 2 additions & 1 deletion TypeShim.Generator/Typescript/TypeScriptPreambleRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ static initialize(options: { assemblyExports: AssemblyExports, setModuleImports:
}
options.setModuleImports("@typeshim", {
unwrap: (obj: any) => obj,
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName]
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName],
unwrapCharPromise: (promise: Promise<any>) => promise.then(c => c.charCodeAt(0))
});
TypeShimConfig._exports = options.assemblyExports;
}
Expand Down
4 changes: 3 additions & 1 deletion TypeShim.Shared/InteropTypeInfoBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,8 +313,10 @@ private TypeScriptSymbolNameTemplate GetInteropSimpleTypeScriptSymbolTemplate(Kn
{
return managedType switch
{
KnownManagedType.Object // only objects are represented differently on the interop boundary
KnownManagedType.Object // objects are represented differently on the interop boundary
=> TypeScriptSymbolNameTemplate.ForUserType("ManagedObject"),
KnownManagedType.Char // chars are represented as numbers on the interop boundary (is intended: https://github.com/dotnet/runtime/issues/123187)
=> TypeScriptSymbolNameTemplate.ForSimpleType("number"),
_ => GetSimpleTypeScriptSymbolTemplate(managedType, originalSyntax, true, false)
};
}
Expand Down