diff --git a/src/baseTranspiler.ts b/src/baseTranspiler.ts index 009dd9a..c2d7de3 100644 --- a/src/baseTranspiler.ts +++ b/src/baseTranspiler.ts @@ -190,6 +190,7 @@ class BaseTranspiler { uncamelcaseIdentifiers; asyncTranspiling; + implicitAsyncTranspiling; requiresReturnType; requiresParameterType; supportsFalsyOrTruthyValues; @@ -310,14 +311,28 @@ class BaseTranspiler { Logger.warning(`[${this.id}] Line: ${line} char: ${character}: ${target} : ${message}`); } - isAsyncFunction(node) { - let modifiers = node.modifiers; - if (modifiers === undefined) { + hasAsyncModifier(node) { + return (node.modifiers ?? []).some(mod => mod.kind === ts.SyntaxKind.AsyncKeyword); + } + + isPromiseType(type) { + return type !== undefined && this.getTypeFromRawType(type) === this.PROMISE_TYPE_KEYWORD; + } + + isImplicitAsyncFunction(node) { + // a function declared WITHOUT the `async` keyword that returns a Promise + // directly, ie: `watchTicker (symbol: string): Promise { return this.watch (...); }` + // (a common fast-path in JS for pure delegator/pass-through methods); + // target languages without that distinction transpile it as if it were `async` + if (!this.implicitAsyncTranspiling || !ts.isFunctionLike(node) || this.hasAsyncModifier(node)) { return false; } - modifiers = modifiers.filter(mod => mod.kind === ts.SyntaxKind.AsyncKeyword); + const signature = global.checker.getSignatureFromDeclaration(node); + return signature !== undefined && this.isPromiseType(global.checker.getReturnTypeOfSignature(signature)); + } - return modifiers.length > 0; + isAsyncFunction(node) { + return this.hasAsyncModifier(node) || this.isImplicitAsyncFunction(node); } getMethodOverride(node: ts.Node): ts.Node { @@ -607,16 +622,19 @@ class BaseTranspiler { } printModifiers(node) { - let modifiers = node.modifiers; - if (modifiers === undefined) { - return ""; - } + let modifiers = node.modifiers ?? []; modifiers = modifiers.filter(mod => this.FuncModifiers[mod.kind]); if (!this.asyncTranspiling) { modifiers = modifiers.filter(mod => mod.kind !== ts.SyntaxKind.AsyncKeyword); } - const res = modifiers.map(modifier => this.FuncModifiers[modifier.kind]).join(" "); + let res = modifiers.map(modifier => this.FuncModifiers[modifier.kind]).join(" "); + + if (this.asyncTranspiling && this.ASYNC_TOKEN && this.isImplicitAsyncFunction(node)) { + // non-async functions returning a Promise directly are transpiled + // as if they had been declared `async` + res = res ? res + " " + this.ASYNC_TOKEN : this.ASYNC_TOKEN; + } return res; } @@ -1663,6 +1681,35 @@ class BaseTranspiler { return awaitToken + expression; } + wrapSyntheticNode(synthetic, original) { + // give the synthetic wrapper the original node's source range and splice it + // into the parent chain, so position/type based helpers keep working + synthetic.pos = original.pos; + synthetic.end = original.end; + synthetic.parent = original.parent; + original.parent = synthetic; + return synthetic; + } + + wrapImplicitReturnAwait(node) { + // inside a non-async function transpiled as async (isImplicitAsyncFunction), + // returned Promises must be awaited to keep the generated code equivalent to + // the classic `async`/`return await` form: wrap them in a synthetic await + // expression so every language prints them through its usual await path + let exp = node.expression; + if (!exp || ts.isAwaitExpression(exp) + || !this.isImplicitAsyncFunction(ts.findAncestor(node, ts.isFunctionLike)) + || !this.isPromiseType(global.checker.getTypeAtLocation(exp))) { + return node; + } + if (ts.isConditionalExpression(exp) || ts.isBinaryExpression(exp)) { + // an await-token prefix would change precedence (e.g. `await a ? b : c`) + exp = this.wrapSyntheticNode(ts.factory.createParenthesizedExpression(exp), exp); + } + node.expression = this.wrapSyntheticNode(ts.factory.createAwaitExpression(exp), exp); + return node; + } + printConditionalExpression(node, identation) { const condition = this.printCondition(node.condition, identation); const whenTrue = this.printNode(node.whenTrue, 0); @@ -1856,7 +1903,7 @@ class BaseTranspiler { } else if (ts.isAsExpression(node)) { return this.printAsExpression(node, identation); } else if (ts.isReturnStatement(node)) { - return this.printReturnStatement(node, identation); + return this.printReturnStatement(this.wrapImplicitReturnAwait(node), identation); } else if (ts.isArrayBindingPattern(node)) { return this.printArrayBindingPattern(node, identation); } else if (ts.isParameter(node)) { diff --git a/src/csharpTranspiler.ts b/src/csharpTranspiler.ts index 8756516..bc846a2 100644 --- a/src/csharpTranspiler.ts +++ b/src/csharpTranspiler.ts @@ -65,6 +65,7 @@ export class CSharpTranspiler extends BaseTranspiler { this.requiresParameterType = true; this.requiresReturnType = true; this.asyncTranspiling = true; + this.implicitAsyncTranspiling = true; this.supportsFalsyOrTruthyValues = false; this.requiresCallExpressionCast = true; this.id = "C#"; diff --git a/src/goTranspiler.ts b/src/goTranspiler.ts index 8f46de3..ef8bd77 100644 --- a/src/goTranspiler.ts +++ b/src/goTranspiler.ts @@ -81,6 +81,7 @@ export class GoTranspiler extends BaseTranspiler { this.requiresParameterType = true; this.requiresReturnType = true; this.asyncTranspiling = false; + this.implicitAsyncTranspiling = true; this.supportsFalsyOrTruthyValues = false; this.requiresCallExpressionCast = true; this.wrapThisCalls = false; @@ -1148,7 +1149,7 @@ ${this.getIden(identation)}PanicOnError(${returnRandName})`; ts.isFunctionExpression(currentNode) || ts.isArrowFunction(currentNode) || ts.isMethodDeclaration(currentNode)) { - return currentNode.modifiers && currentNode.modifiers.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword); + return this.isAsyncFunction(currentNode); } // Move up the tree to the parent node currentNode = currentNode.parent; diff --git a/src/javaTranspiler.ts b/src/javaTranspiler.ts index 8253efb..99bf565 100644 --- a/src/javaTranspiler.ts +++ b/src/javaTranspiler.ts @@ -87,6 +87,7 @@ export class JavaTranspiler extends BaseTranspiler { this.requiresParameterType = true; this.requiresReturnType = true; this.asyncTranspiling = true; + this.implicitAsyncTranspiling = true; this.supportsFalsyOrTruthyValues = false; this.requiresCallExpressionCast = true; this.id = "Java"; diff --git a/tests/csharpTranspiler.test.ts b/tests/csharpTranspiler.test.ts index 4857312..932f343 100644 --- a/tests/csharpTranspiler.test.ts +++ b/tests/csharpTranspiler.test.ts @@ -991,4 +991,35 @@ describe('csharp transpiling tests', () => { const output = transpiler.transpileCSharp(ts).content; expect(output).toBe(csharp); }); + test('non-async Promise-returning delegator transpiles like async return await', () => { + // a method without `async` that returns a Promise (e.g. WS delegators + // like `watchTicker(...) { return this.watchTickerInner(...); }`) + // must produce the exact same C# as its `async`/`return await` twin: + // `async ... Task` signature + `return await ...`. + const input = + "class Exchange {\n" + + " async watchTickerInner(symbol: string): Promise {\n" + + " return { 'symbol': symbol };\n" + + " }\n" + + " watchTicker(symbol: string): Promise {\n" + + " return this.watchTickerInner(symbol);\n" + + " }\n" + + " async watchTickerClassic(symbol: string): Promise {\n" + + " return await this.watchTickerInner(symbol);\n" + + " }\n" + + "}" + const output = transpiler.transpileCSharp(input).content; + expect(output).toContain("public async virtual Task watchTicker(object symbol)"); + expect(output).toContain("return await this.watchTickerInner(symbol);"); + // must not return the bare Task without awaiting it + expect(output).not.toContain("return this.watchTickerInner(symbol);"); + // delegator body must be identical to the classic async/return await version + const getBody = (name: string) => { + const start = output.indexOf(`Task ${name}(`); + const open = output.indexOf('{', start); + const close = output.indexOf('}', open); + return output.slice(open, close + 1); + }; + expect(getBody('watchTicker')).toBe(getBody('watchTickerClassic')); + }); }); diff --git a/tests/goTranspiler.test.ts b/tests/goTranspiler.test.ts index 27f7baa..d9997bb 100644 --- a/tests/goTranspiler.test.ts +++ b/tests/goTranspiler.test.ts @@ -158,4 +158,41 @@ describe('go transpiling tests', () => { const output = transpiler.transpileGo(ts).content; expect(output).toBe(go); }); + test('non-async Promise-returning delegator transpiles like async return await', () => { + // a method without `async` that returns a Promise (e.g. WS delegators + // like `watchTicker(...) { return this.watchTickerInner(...); }`) + // must produce the exact same Go as its `async`/`return await` twin: + // channel-wrapped body with `<-` receive + PanicOnError on the result. + const input = + "class Exchange {\n" + + " async watchTickerInner(symbol: string): Promise {\n" + + " return { 'symbol': symbol };\n" + + " }\n" + + " watchTicker(symbol: string): Promise {\n" + + " return this.watchTickerInner(symbol);\n" + + " }\n" + + " async watchTickerClassic(symbol: string): Promise {\n" + + " return await this.watchTickerInner(symbol);\n" + + " }\n" + + "}" + const output = transpiler.transpileGo(input).content; + // extract each method body + const methods = output.split(/func\s+\(this \*Exchange\)/).slice(1); + expect(methods.length).toBe(3); + const [inner, delegator, classic] = methods; + // the delegator must be channel-wrapped and receive from the inner channel + expect(delegator).toContain("ch := make(chan any)"); + expect(delegator).toContain("<-this.WatchTickerInner(symbol)"); + expect(delegator).toContain("PanicOnError(retRes"); + // must NOT return the raw channel of the inner call + expect(delegator).not.toContain("ch <- this.WatchTickerInner"); + // normalized (method name + line-based retRes suffix stripped), the + // delegator must be identical to the classic async/return await version + const normalize = (s: string) => s + .replace(/retRes\d+/g, 'retRes') + .replace(/WatchTickerClassic|WatchTicker\b/g, 'METHOD') + .trim(); + expect(normalize(delegator)).toBe(normalize(classic)); + void inner; + }); }); diff --git a/tests/javaTranspiler.test.ts b/tests/javaTranspiler.test.ts index 862d391..ef395c5 100644 --- a/tests/javaTranspiler.test.ts +++ b/tests/javaTranspiler.test.ts @@ -2196,4 +2196,36 @@ describe('java transpiling tests', () => { expect((output.match(/final Object finalI\b/g) || []).length).toBe(2); expect((output.match(/final Object finalType/g) || []).length).toBe(1); }); + test('non-async Promise-returning delegator transpiles like async return await', () => { + // a method without `async` that returns a Promise (e.g. WS delegators + // like `watchTicker(...) { return this.watchTickerInner(...); }`) + // must produce the exact same Java as its `async`/`return await` twin: + // CompletableFuture signature + supplyAsync wrapper + `.join()` on the + // inner future (instead of returning the raw CompletableFuture, which + // would make the outer future resolve to a future). + const input = + "class Exchange {\n" + + " async watchTickerInner(symbol: string): Promise {\n" + + " return { 'symbol': symbol };\n" + + " }\n" + + " watchTicker(symbol: string): Promise {\n" + + " return this.watchTickerInner(symbol);\n" + + " }\n" + + " async watchTickerClassic(symbol: string): Promise {\n" + + " return await this.watchTickerInner(symbol);\n" + + " }\n" + + "}" + const output = transpiler.transpileJava(input).content; + expect(output).toContain("java.util.concurrent.CompletableFuture watchTicker("); + expect(output).toContain("return (this.watchTickerInner(symbol)).join();"); + // the delegator must be wrapped in supplyAsync like any async method + expect((output.match(/supplyAsync/g) || []).length).toBe(3); + // delegator body must be identical to the classic async/return await version + const getBody = (name: string) => { + const start = output.indexOf(`CompletableFuture ${name}(`); + const end = output.indexOf('});', start); + return output.slice(output.indexOf('{', start), end); + }; + expect(getBody('watchTicker')).toBe(getBody('watchTickerClassic')); + }); });