Skip to content

Commit 5a4e209

Browse files
authored
Shims for C# char as TS string (#53)
* add shims to fix marshalling of chars to be strings * update comments on char conversions + refactor char conversion code for clarity * add ts render tests for chars * elementary runtime behavior test
1 parent 0a185b3 commit 5a4e209

7 files changed

Lines changed: 260 additions & 17 deletions

File tree

Sample/TypeShim.Sample.Client/@typeshim/app/src/App.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ function App() {
6565
export default App;
6666

6767
function E2E() {
68+
console.log("Starting E2E CompilationTest...");
6869
const exportedClass = new ExportedClass({ Id: 1 });
69-
const t = new CompilationTest({
70+
const testObject = new CompilationTest({
7071
NIntProperty: 1,
7172
ByteProperty: 2,
7273
ShortProperty: 3,
@@ -89,7 +90,7 @@ function E2E() {
8990
TaskOfIntProperty: Promise.resolve(44),
9091
TaskOfLongProperty: Promise.resolve(45),
9192
TaskOfBoolProperty: Promise.resolve(true),
92-
TaskOfCharProperty: Promise.resolve(1 as unknown as string),
93+
TaskOfCharProperty: Promise.resolve('B'),
9394
TaskOfStringProperty: Promise.resolve("Task String"),
9495
TaskOfDoubleProperty: Promise.resolve(67.8),
9596
TaskOfFloatProperty: Promise.resolve(89.0),
@@ -107,5 +108,41 @@ function E2E() {
107108
ExportedClassArrayProperty: [exportedClass],
108109
});
109110

110-
console.log("E2E CompilationTest instance:", t, CompilationTest.materialize(t));
111+
console.log("E2E CompilationTest instance:", testObject, CompilationTest.materialize(testObject));
112+
113+
testObject.CharProperty = 'Z';
114+
if (testObject.CharProperty !== 'Z') {
115+
console.error(`E2E CharProperty value mismatch. Expected 'Z', got '${testObject.CharProperty}'`);
116+
}
117+
testObject.TaskOfCharProperty = Promise.resolve('Y');
118+
testObject.TaskOfCharProperty.then(value => {
119+
if (value === 'Y') return;
120+
console.error(`E2E TaskOfCharProperty value mismatch. Expected 'Y', got '${value}'`);
121+
}).catch(err => {
122+
console.error("E2E TaskOfCharProperty error:", err);
123+
});
124+
125+
testObject.StringProperty = "Updated Test";
126+
if (testObject.StringProperty !== "Updated Test") {
127+
console.error(`E2E StringProperty value mismatch. Expected 'Updated Test', got '${testObject.StringProperty}'`);
128+
}
129+
testObject.TaskOfStringProperty = Promise.resolve("Updated Task String");
130+
testObject.TaskOfStringProperty.then(value => {
131+
if (value === "Updated Task String") return;
132+
console.error(`E2E TaskOfStringProperty value mismatch. Expected 'Updated Task String', got '${value}'`);
133+
}).catch(err => {
134+
console.error("E2E TaskOfStringProperty error:", err);
135+
});
136+
137+
testObject.ExportedClassProperty.Id = 99;
138+
if (testObject.ExportedClassProperty.Id !== 99) {
139+
console.error(`E2E ExportedClassProperty.Id value mismatch. Expected 99, got '${testObject.ExportedClassProperty.Id}'`);
140+
}
141+
const newExport = new ExportedClass({ Id: 100 });
142+
testObject.ExportedClassProperty = newExport;
143+
if (testObject.ExportedClassProperty.Id !== 100) {
144+
console.error(`E2E ExportedClassProperty reassignment value mismatch. Expected 100, got '${testObject.ExportedClassProperty.Id}'`);
145+
}
146+
147+
console.log("E2E CompilationTest completed.");
111148
}

Sample/TypeShim.Sample.Client/@typeshim/wasm-exports/typeshim.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class TypeShimConfig {
1414
}
1515
options.setModuleImports("@typeshim", {
1616
unwrap: (obj: any) => obj,
17-
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName]
17+
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName],
18+
unwrapCharPromise: (promise: Promise<any>) => promise.then(c => c.charCodeAt(0))
1819
});
1920
TypeShimConfig._exports = options.assemblyExports;
2021
}
@@ -96,8 +97,8 @@ export interface AssemblyExports{
9697
set_BoolProperty(instance: ManagedObject, value: boolean): void;
9798
get_StringProperty(instance: ManagedObject): string;
9899
set_StringProperty(instance: ManagedObject, value: string): void;
99-
get_CharProperty(instance: ManagedObject): string;
100-
set_CharProperty(instance: ManagedObject, value: string): void;
100+
get_CharProperty(instance: ManagedObject): number;
101+
set_CharProperty(instance: ManagedObject, value: number): void;
101102
get_DoubleProperty(instance: ManagedObject): number;
102103
set_DoubleProperty(instance: ManagedObject, value: number): void;
103104
get_FloatProperty(instance: ManagedObject): number;
@@ -126,8 +127,8 @@ export interface AssemblyExports{
126127
set_TaskOfBoolProperty(instance: ManagedObject, value: Promise<boolean>): void;
127128
get_TaskOfByteProperty(instance: ManagedObject): Promise<number>;
128129
set_TaskOfByteProperty(instance: ManagedObject, value: Promise<number>): void;
129-
get_TaskOfCharProperty(instance: ManagedObject): Promise<string>;
130-
set_TaskOfCharProperty(instance: ManagedObject, value: Promise<string>): void;
130+
get_TaskOfCharProperty(instance: ManagedObject): Promise<number>;
131+
set_TaskOfCharProperty(instance: ManagedObject, value: Promise<number>): void;
131132
get_TaskOfStringProperty(instance: ManagedObject): Promise<string>;
132133
set_TaskOfStringProperty(instance: ManagedObject, value: Promise<string>): void;
133134
get_TaskOfDoubleProperty(instance: ManagedObject): Promise<number>;
@@ -383,11 +384,11 @@ export class CompilationTest extends ProxyBase {
383384
}
384385

385386
public get CharProperty(): string {
386-
return TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.get_CharProperty(this.instance);
387+
return String.fromCharCode(TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.get_CharProperty(this.instance));
387388
}
388389

389390
public set CharProperty(value: string) {
390-
TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.set_CharProperty(this.instance, value);
391+
TypeShimConfig.exports.TypeShim.Sample.CompilationTestInterop.set_CharProperty(this.instance, value.charCodeAt(0));
391392
}
392393

393394
public get DoubleProperty(): number {
@@ -505,11 +506,11 @@ export class CompilationTest extends ProxyBase {
505506
}
506507

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

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

515516
public get TaskOfStringProperty(): Promise<string> {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Text;
6+
using TypeShim.Generator.Parsing;
7+
using TypeShim.Generator.Typescript;
8+
using TypeShim.Shared;
9+
10+
namespace TypeShim.Generator.Tests.TypeScript;
11+
12+
internal class TypeScriptUserClassProxyRendererTests_Char
13+
{
14+
[Test]
15+
public void TypeScriptUserClassProxy_InstanceMethod_WithCharReturnType_RendersNumberToStringConversion()
16+
{
17+
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
18+
using System;
19+
using System.Threading.Tasks;
20+
namespace N1;
21+
[TSExport]
22+
public class C1
23+
{
24+
public char M1() {}
25+
}
26+
""");
27+
28+
SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
29+
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
30+
Assert.That(exportedClasses, Has.Count.EqualTo(1));
31+
INamedTypeSymbol classSymbol = exportedClasses[0];
32+
33+
InteropTypeInfoCache typeCache = new();
34+
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();
35+
36+
RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
37+
new TypescriptUserClassProxyRenderer(renderContext).Render();
38+
39+
AssertEx.EqualOrDiff(renderContext.ToString(), """
40+
export class C1 extends ProxyBase {
41+
constructor() {
42+
super(TypeShimConfig.exports.N1.C1Interop.ctor());
43+
}
44+
45+
public M1(): string {
46+
return String.fromCharCode(TypeShimConfig.exports.N1.C1Interop.M1(this.instance));
47+
}
48+
}
49+
50+
""");
51+
}
52+
53+
[Test]
54+
public void TypeScriptUserClassProxy_InstanceMethod_WithCharParameterType_RendersNumberToStringConversion()
55+
{
56+
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
57+
using System;
58+
using System.Threading.Tasks;
59+
namespace N1;
60+
[TSExport]
61+
public class C1
62+
{
63+
public void M1(char p1) {}
64+
}
65+
""");
66+
67+
SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
68+
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
69+
Assert.That(exportedClasses, Has.Count.EqualTo(1));
70+
INamedTypeSymbol classSymbol = exportedClasses[0];
71+
72+
InteropTypeInfoCache typeCache = new();
73+
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();
74+
75+
RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
76+
new TypescriptUserClassProxyRenderer(renderContext).Render();
77+
78+
AssertEx.EqualOrDiff(renderContext.ToString(), """
79+
export class C1 extends ProxyBase {
80+
constructor() {
81+
super(TypeShimConfig.exports.N1.C1Interop.ctor());
82+
}
83+
84+
public M1(p1: string): void {
85+
TypeShimConfig.exports.N1.C1Interop.M1(this.instance, p1.charCodeAt(0));
86+
}
87+
}
88+
89+
""");
90+
}
91+
92+
[Test]
93+
public void TypeScriptUserClassProxy_InstanceMethod_WithCharParameterAndReturnType_RendersNumberToStringConversion()
94+
{
95+
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
96+
using System;
97+
using System.Threading.Tasks;
98+
namespace N1;
99+
[TSExport]
100+
public class C1
101+
{
102+
public char M1(char p1) => p1;
103+
}
104+
""");
105+
106+
SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
107+
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
108+
Assert.That(exportedClasses, Has.Count.EqualTo(1));
109+
INamedTypeSymbol classSymbol = exportedClasses[0];
110+
111+
InteropTypeInfoCache typeCache = new();
112+
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();
113+
114+
RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
115+
new TypescriptUserClassProxyRenderer(renderContext).Render();
116+
117+
AssertEx.EqualOrDiff(renderContext.ToString(), """
118+
export class C1 extends ProxyBase {
119+
constructor() {
120+
super(TypeShimConfig.exports.N1.C1Interop.ctor());
121+
}
122+
123+
public M1(p1: string): string {
124+
return String.fromCharCode(TypeShimConfig.exports.N1.C1Interop.M1(this.instance, p1.charCodeAt(0)));
125+
}
126+
}
127+
128+
""");
129+
}
130+
131+
[Test]
132+
public void TypeScriptUserClassProxy_InstanceProperty_WithCharType_RendersNumberToStringConversion()
133+
{
134+
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText("""
135+
using System;
136+
using System.Threading.Tasks;
137+
namespace N1;
138+
[TSExport]
139+
public class C1
140+
{
141+
public char P1 { get; set; }
142+
}
143+
""");
144+
145+
SymbolExtractor symbolExtractor = new([CSharpFileInfo.Create(syntaxTree)]);
146+
List<INamedTypeSymbol> exportedClasses = [.. symbolExtractor.ExtractAllExportedSymbols()];
147+
Assert.That(exportedClasses, Has.Count.EqualTo(1));
148+
INamedTypeSymbol classSymbol = exportedClasses[0];
149+
150+
InteropTypeInfoCache typeCache = new();
151+
ClassInfo classInfo = new ClassInfoBuilder(classSymbol, typeCache).Build();
152+
153+
RenderContext renderContext = new(classInfo, [classInfo], RenderOptions.TypeScript);
154+
new TypescriptUserClassProxyRenderer(renderContext).Render();
155+
156+
AssertEx.EqualOrDiff(renderContext.ToString(), """
157+
export class C1 extends ProxyBase {
158+
constructor(jsObject: C1.Initializer) {
159+
super(TypeShimConfig.exports.N1.C1Interop.ctor(jsObject));
160+
}
161+
162+
public get P1(): string {
163+
return String.fromCharCode(TypeShimConfig.exports.N1.C1Interop.get_P1(this.instance));
164+
}
165+
166+
public set P1(value: string) {
167+
TypeShimConfig.exports.N1.C1Interop.set_P1(this.instance, value.charCodeAt(0));
168+
}
169+
}
170+
171+
""");
172+
}
173+
}

TypeShim.Generator/CSharp/JSObjectExtensionsRenderer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ public static partial class JSObjectTaskExtensions
253253
[return: JSMarshalAs<JSType.Promise<JSType.Number>>]
254254
public static partial Task<byte> MarshallAsByteTask([JSMarshalAs<JSType.Object>] JSObject jsObject);
255255
256-
[JSImport("unwrap", "@typeshim")]
256+
[JSImport("unwrapCharPromise", "@typeshim")]
257257
[return: JSMarshalAs<JSType.Promise<JSType.String>>]
258258
public static partial Task<char> MarshallAsCharTask([JSMarshalAs<JSType.Object>] JSObject jsObject);
259259

TypeShim.Generator/Typescript/TypeScriptMethodRenderer.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,31 @@ private void RenderMethodBody(MethodInfo methodInfo)
172172
else
173173
{
174174
ctx.Append(methodInfo.ReturnType.ManagedType == KnownManagedType.Void ? string.Empty : "return ");
175-
RenderInteropInvocation(methodInfo.Name, methodInfo.Parameters);
175+
176+
RenderCharConversionIfNecessary(methodInfo.ReturnType, () =>
177+
{
178+
RenderInteropInvocation(methodInfo.Name, methodInfo.Parameters);
179+
});
180+
176181
ctx.AppendLine(";");
177182
}
178183
}
179184
ctx.AppendLine("}");
180185

186+
void RenderCharConversionIfNecessary(InteropTypeInfo typeInfo, Action renderCharExpression)
187+
{
188+
// dotnet does not marshall chars as strings, instead as numbers. TypeShim converts to strings on the TS side.
189+
if (methodInfo.ReturnType.ManagedType == KnownManagedType.Char)
190+
ctx.Append("String.fromCharCode(");
191+
192+
renderCharExpression();
193+
194+
if (methodInfo.ReturnType.ManagedType == KnownManagedType.Char)
195+
ctx.Append(")");
196+
if (methodInfo.ReturnType is { ManagedType: KnownManagedType.Task, TypeArgument.ManagedType: KnownManagedType.Char })
197+
ctx.Append(".then(c => String.fromCharCode(c))");
198+
}
199+
181200
void RenderInlineProxyConstruction(InteropTypeInfo typeInfo, string proxyClassName, string sourceVarName)
182201
{
183202
if (typeInfo is { IsNullableType: true })
@@ -251,8 +270,9 @@ void RenderMethodInvocationParameters(string instanceParameterExpression)
251270
foreach (MethodParameterInfo parameter in methodParameters)
252271
{
253272
if (!isFirst) ctx.Append(", ");
254-
273+
255274
ctx.Append(parameter.IsInjectedInstanceParameter ? instanceParameterExpression : GetInteropInvocationVariable(parameter));
275+
RenderCharConversionIfNecessary(parameter);
256276
isFirst = false;
257277
}
258278
if (initializerObject == null) return;
@@ -265,6 +285,15 @@ void RenderInteropMethodAccessor(string methodName)
265285
{
266286
ctx.Append(ctx.Class.Namespace).Append('.').Append(RenderConstants.InteropClassName(ctx.Class)).Append('.').Append(methodName);
267287
}
288+
289+
void RenderCharConversionIfNecessary(MethodParameterInfo parameter)
290+
{
291+
// dotnet does not marshall chars as strings atm. We convert from/to numbers while this is the case.
292+
if (parameter.Type.ManagedType == KnownManagedType.Char)
293+
ctx.Append(".charCodeAt(0)");
294+
if (parameter.Type is { ManagedType: KnownManagedType.Task, TypeArgument.ManagedType: KnownManagedType.Char })
295+
ctx.Append(".then(c => c.charCodeAt(0))");
296+
}
268297
}
269298

270299
private static string GetInteropInvocationVariable(MethodParameterInfo param) // TODO: get from ctx localscope (check param.Name call sites!)

TypeShim.Generator/Typescript/TypeScriptPreambleRenderer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ static initialize(options: { assemblyExports: AssemblyExports, setModuleImports:
2626
}
2727
options.setModuleImports("@typeshim", {
2828
unwrap: (obj: any) => obj,
29-
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName]
29+
unwrapProperty: (obj: any, propertyName: string) => obj[propertyName],
30+
unwrapCharPromise: (promise: Promise<any>) => promise.then(c => c.charCodeAt(0))
3031
});
3132
TypeShimConfig._exports = options.assemblyExports;
3233
}

TypeShim.Shared/InteropTypeInfoBuilder.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,8 +313,10 @@ private TypeScriptSymbolNameTemplate GetInteropSimpleTypeScriptSymbolTemplate(Kn
313313
{
314314
return managedType switch
315315
{
316-
KnownManagedType.Object // only objects are represented differently on the interop boundary
316+
KnownManagedType.Object // objects are represented differently on the interop boundary
317317
=> TypeScriptSymbolNameTemplate.ForUserType("ManagedObject"),
318+
KnownManagedType.Char // chars are represented as numbers on the interop boundary (is intended: https://github.com/dotnet/runtime/issues/123187)
319+
=> TypeScriptSymbolNameTemplate.ForSimpleType("number"),
318320
_ => GetSimpleTypeScriptSymbolTemplate(managedType, originalSyntax, true, false)
319321
};
320322
}

0 commit comments

Comments
 (0)