From ac74c298e17ae4b4cd5a873a3d2b3aced310893e Mon Sep 17 00:00:00 2001 From: Perryvw Date: Mon, 5 Jul 2021 22:26:22 +0200 Subject: [PATCH 01/28] Initial lualib promise class implementation --- src/lualib/Promise.ts | 125 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/lualib/Promise.ts diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts new file mode 100644 index 000000000..647b315ff --- /dev/null +++ b/src/lualib/Promise.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/promise-function-async */ + +// Promises implemented based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise +// and https://promisesaplus.com/ + +enum __TS__PromiseState { + Pending, + Fulfilled, + Rejected, +} + +type FulfillCallback = (value: TData) => TResult | PromiseLike; +type RejectCallback = (reason: any) => TResult | PromiseLike; + +function __TS__PromiseDeferred() { + let resolve: FulfillCallback; + let reject: RejectCallback; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function __TS__IsPromiseLike(thing: unknown): thing is PromiseLike { + return thing instanceof __TS__Promise; +} + +class __TS__Promise implements Promise { + private state = __TS__PromiseState.Pending; + private value?: T; + + private fulfilledCallbacks: Array> = []; + private rejectedCallbacks: Array> = []; + private finallyCallbacks: Array<() => void> = []; + + public [Symbol.toStringTag]: string; + + constructor(private executor: (resolve: (data: T) => void, reject: (reason: string) => void) => void) { + this.execute(); + } + + public then( + onfulfilled?: FulfillCallback, + onrejected?: RejectCallback + ): Promise { + const { promise, resolve, reject } = __TS__PromiseDeferred(); + + function handleCallbackData(data: TResult | PromiseLike) { + if (__TS__IsPromiseLike(data)) { + const nextpromise = data as __TS__Promise; + if (nextpromise.state === __TS__PromiseState.Fulfilled) { + resolve(nextpromise.value); + } else if (nextpromise.state === __TS__PromiseState.Rejected) { + reject(nextpromise.value); + } else { + nextpromise.then(resolve, reject); + } + } else { + resolve(data); + } + } + + if (onfulfilled) { + if (this.fulfilledCallbacks.length === 0) { + this.fulfilledCallbacks.push(value => { + handleCallbackData(onfulfilled(value)); + }); + } else { + this.fulfilledCallbacks.push(onfulfilled); + } + } + if (onrejected) { + if (this.rejectedCallbacks.length === 0) { + this.rejectedCallbacks.push(value => { + handleCallbackData(onrejected(value)); + }); + } else { + this.rejectedCallbacks.push(onrejected); + } + } + + return promise; + } + public catch(onrejected?: (reason: any) => TResult | PromiseLike): Promise { + return this.then(undefined, onrejected); + } + public finally(onfinally?: () => void): Promise { + if (onfinally) { + this.finallyCallbacks.push(onfinally); + } + return this; + } + + private execute(): void { + this.executor(this.resolve, this.reject); + } + + private resolve(data: T): void { + if (this.state === __TS__PromiseState.Pending) { + this.state = __TS__PromiseState.Fulfilled; + this.value = data; + + for (const callback of this.fulfilledCallbacks) { + callback(data); + } + for (const callback of this.finallyCallbacks) { + callback(); + } + } + } + + private reject(reason: string): void { + if (this.state === __TS__PromiseState.Pending) { + this.state = __TS__PromiseState.Rejected; + for (const callback of this.rejectedCallbacks) { + callback(reason); + } + for (const callback of this.finallyCallbacks) { + callback(); + } + } + } +} From 7ca303064729880a212d60c620865c782b9a8e59 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Thu, 8 Jul 2021 22:42:19 +0200 Subject: [PATCH 02/28] First promise tests --- src/LuaLib.ts | 2 + src/lualib/Promise.ts | 2 +- src/transformation/visitors/class/new.ts | 8 +- test/unit/builtins/promise.spec.ts | 156 +++++++++++++++++++++++ test/util.ts | 6 +- 5 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 test/unit/builtins/promise.spec.ts diff --git a/src/LuaLib.ts b/src/LuaLib.ts index d98b78044..5934a9a12 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -59,6 +59,7 @@ export enum LuaLibFeature { ObjectValues = "ObjectValues", ParseFloat = "ParseFloat", ParseInt = "ParseInt", + Promise = "Promise", Set = "Set", SetDescriptor = "SetDescriptor", WeakMap = "WeakMap", @@ -102,6 +103,7 @@ const luaLibDependencies: Partial> = { Iterator: [LuaLibFeature.Symbol], ObjectDefineProperty: [LuaLibFeature.CloneDescriptor, LuaLibFeature.SetDescriptor], ObjectFromEntries: [LuaLibFeature.Iterator, LuaLibFeature.Symbol], + Promise: [LuaLibFeature.ArrayPush, LuaLibFeature.Class, LuaLibFeature.FunctionBind, LuaLibFeature.InstanceOf], Map: [LuaLibFeature.InstanceOf, LuaLibFeature.Iterator, LuaLibFeature.Symbol, LuaLibFeature.Class], Set: [LuaLibFeature.InstanceOf, LuaLibFeature.Iterator, LuaLibFeature.Symbol, LuaLibFeature.Class], WeakMap: [LuaLibFeature.InstanceOf, LuaLibFeature.Iterator, LuaLibFeature.Symbol, LuaLibFeature.Class], diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index 647b315ff..bc0db7101 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -94,7 +94,7 @@ class __TS__Promise implements Promise { } private execute(): void { - this.executor(this.resolve, this.reject); + this.executor(this.resolve.bind(this), this.reject.bind(this)); } private resolve(data: T): void { diff --git a/src/transformation/visitors/class/new.ts b/src/transformation/visitors/class/new.ts index 721cc26e5..7c3cc9393 100644 --- a/src/transformation/visitors/class/new.ts +++ b/src/transformation/visitors/class/new.ts @@ -4,6 +4,7 @@ import { FunctionVisitor, TransformationContext } from "../../context"; import { AnnotationKind, getTypeAnnotations } from "../../utils/annotations"; import { annotationInvalidArgumentCount, annotationRemoved } from "../../utils/diagnostics"; import { importLuaLibFeature, LuaLibFeature, transformLuaLibFunction } from "../../utils/lualib"; +import { isStandardLibraryType } from "../../utils/typescript"; import { transformArguments } from "../call"; import { isTableNewCall } from "../language-extensions/table"; @@ -60,7 +61,7 @@ export const transformNewExpression: FunctionVisitor = (node, return lua.createTableExpression(undefined, node); } - const name = context.transformExpression(node.expression); + let name = context.transformExpression(node.expression); const signature = context.checker.getResolvedSignature(node); const params = node.arguments ? transformArguments(context, node.arguments, signature) @@ -68,6 +69,11 @@ export const transformNewExpression: FunctionVisitor = (node, checkForLuaLibType(context, type); + if (isStandardLibraryType(context, type, "Promise")) { + importLuaLibFeature(context, LuaLibFeature.Promise) + name = lua.createIdentifier("__TS__Promise", node.expression); + } + const customConstructorAnnotation = annotations.get(AnnotationKind.CustomConstructor); if (customConstructorAnnotation) { if (customConstructorAnnotation.args.length === 1) { diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts new file mode 100644 index 000000000..44956ba2e --- /dev/null +++ b/test/unit/builtins/promise.spec.ts @@ -0,0 +1,156 @@ +import * as util from "../../util"; + +// Create a promise and store its resolve and reject functions, useful for testing +const defer = `function defer() { + let resolve: (data: any) => void = () => {}; + let reject: (reason: string) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}`; + +test("promise can be resolved", () => { + util.testFunction` + const { promise, resolve } = defer(); + + let result: string | undefined; + let rejectResult: string | undefined; + + promise.then( + data => { result = data; }, + reason => { rejectResult = reason; } + ); + + const beforeResolve = result; + + resolve("Hello!"); + + const afterResolve = result; + + return { beforeResolve, afterResolve, rejectResult }; + ` + .setTsHeader(defer) + .expectToEqual({ + beforeResolve: undefined, + afterResolve: "Hello!", + rejectResult: undefined, + }); +}); + +test("promise can be rejected", () => { + util.testFunction` + const { promise, reject } = defer(); + + let resolveResult: string | undefined; + let rejectResult: string | undefined; + + promise.then( + data => { resolveResult = data; }, + reason => { rejectResult = reason; } + ); + + const beforeReject = rejectResult; + + reject("Hello!"); + + const afterReject = rejectResult; + + return { beforeReject, afterReject, resolveResult }; + ` + .setTsHeader(defer) + .expectToEqual({ + beforeReject: undefined, + afterReject: "Hello!", + resolveResult: undefined, + }); +}); + +test("promise cannot be resolved more than once", () => { + util.testFunction` + const { promise, resolve } = defer(); + + let result: string[] = []; + + promise.then( + data => { result.push(data); }, + _ => {} + ); + + resolve("Hello!"); + resolve("World!"); // Second resolve should be ignored + + return result; + ` + .setTsHeader(defer) + .expectToEqual(["Hello!"]); +}); + +test("promise cannot be rejected more than once", () => { + util.testFunction` + const { promise, reject } = defer(); + + let result: string[] = []; + + promise.then( + _ => {}, + reason => { result.push(reason); } + ); + + reject("Hello!"); + reject("World!"); // Second reject should be ignored + + return result; + ` + .setTsHeader(defer) + .expectToEqual(["Hello!"]); +}); + +test("promise cannot be resolved then rejected", () => { + util.testFunction` + const { promise, resolve, reject } = defer(); + + let result: string[] = []; + + promise.then( + data => { result.push(data); }, + reason => { result.push(reason); } + ); + + resolve("Hello!"); + reject("World!"); // should be ignored because already resolved + + return result; + ` + .setTsHeader(defer) + .expectToEqual(["Hello!"]); +}); + +test("promise can be observed more than once", () => { + util.testFunction` + const { promise, resolve } = defer(); + + let result1: string | undefined; + let result2: string | undefined; + + promise.then( + data => { result1 = data; }, + _ => {} + ); + + promise.then( + data => { result2 = data; }, + _ => {} + ); + + resolve("Hello!"); + + return { result1, result2 }; + ` + .setTsHeader(defer) + .expectToEqual({ + result1: "Hello!", + result2: "Hello!", + }); +}); diff --git a/test/util.ts b/test/util.ts index 39259e784..f5cfa65dc 100644 --- a/test/util.ts +++ b/test/util.ts @@ -265,9 +265,11 @@ export abstract class TestBuilder { // Actions - public debug(): this { + public debug(includeLualib = false): this { const transpiledFiles = this.getLuaResult().transpiledFiles; - const luaCode = transpiledFiles.map(f => `[${f.outPath}]:\n${f.lua?.replace(/^/gm, " ")}`); + const luaCode = transpiledFiles + .filter(f => includeLualib || f.outPath !== "lualib_bundle.lua") + .map(f => `[${f.outPath}]:\n${f.lua?.replace(/^/gm, " ")}`); const value = prettyFormat(this.getLuaExecutionResult()).replace(/^/gm, " "); console.log(`Lua Code:\n${luaCode.join("\n")}\n\nValue:\n${value}`); return this; From d388154bcf4f18a3b3ddb71b61857f9bd7c80a2f Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sun, 11 Jul 2021 21:54:41 +0200 Subject: [PATCH 03/28] More promise tests --- test/unit/builtins/promise.spec.ts | 206 ++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 21 deletions(-) diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index 44956ba2e..f2ffed29e 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -1,7 +1,7 @@ import * as util from "../../util"; // Create a promise and store its resolve and reject functions, useful for testing -const defer = `function defer() { +const deferPromise = `function defer() { let resolve: (data: any) => void = () => {}; let reject: (reason: string) => void = () => {}; const promise = new Promise((res, rej) => { @@ -11,6 +11,14 @@ const defer = `function defer() { return { promise, resolve, reject }; }`; +test("can create resolved promise", () => { + util.testExpression`Promise.resolve(42)`.expectNoExecutionError(); +}); + +test("can create rejected promise", () => { + util.testExpression`Promise.reject(42)`.expectNoExecutionError(); +}); + test("promise can be resolved", () => { util.testFunction` const { promise, resolve } = defer(); @@ -31,7 +39,7 @@ test("promise can be resolved", () => { return { beforeResolve, afterResolve, rejectResult }; ` - .setTsHeader(defer) + .setTsHeader(deferPromise) .expectToEqual({ beforeResolve: undefined, afterResolve: "Hello!", @@ -59,7 +67,7 @@ test("promise can be rejected", () => { return { beforeReject, afterReject, resolveResult }; ` - .setTsHeader(defer) + .setTsHeader(deferPromise) .expectToEqual({ beforeReject: undefined, afterReject: "Hello!", @@ -74,8 +82,7 @@ test("promise cannot be resolved more than once", () => { let result: string[] = []; promise.then( - data => { result.push(data); }, - _ => {} + data => { result.push(data); } ); resolve("Hello!"); @@ -83,7 +90,7 @@ test("promise cannot be resolved more than once", () => { return result; ` - .setTsHeader(defer) + .setTsHeader(deferPromise) .expectToEqual(["Hello!"]); }); @@ -103,11 +110,11 @@ test("promise cannot be rejected more than once", () => { return result; ` - .setTsHeader(defer) + .setTsHeader(deferPromise) .expectToEqual(["Hello!"]); }); -test("promise cannot be resolved then rejected", () => { +test("promise cannot be rejected after resolving", () => { util.testFunction` const { promise, resolve, reject } = defer(); @@ -123,34 +130,191 @@ test("promise cannot be resolved then rejected", () => { return result; ` - .setTsHeader(defer) + .setTsHeader(deferPromise) .expectToEqual(["Hello!"]); }); -test("promise can be observed more than once", () => { +test("promise cannot be resolved after rejecting", () => { + util.testFunction` + const { promise, resolve, reject } = defer(); + + let result: string[] = []; + + promise.then( + data => { result.push(data); }, + reason => { result.push(reason); } + ); + + reject("Hello!"); + resolve("World!"); // should be ignored because already rejected + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["Hello!"]); +}); + +test("promise can be (then-resolve) observed more than once", () => { util.testFunction` const { promise, resolve } = defer(); - let result1: string | undefined; - let result2: string | undefined; + const result = []; promise.then( - data => { result1 = data; }, - _ => {} + data => { result.push("then 1: " + data); } ); promise.then( - data => { result2 = data; }, - _ => {} + data => { result.push("then 2: " + data); } ); resolve("Hello!"); - return { result1, result2 }; + return result; ` - .setTsHeader(defer) - .expectToEqual({ - result1: "Hello!", - result2: "Hello!", + .setTsHeader(deferPromise) + .expectToEqual(["then 1: Hello!", "then 2: Hello!"]); +}); + +test("promise can be (then-reject) observed more than once", () => { + util.testFunction` + const { promise, reject } = defer(); + + const result = []; + + promise.then( + undefined, + reason => { result.push("then 1: " + reason); } + ); + + promise.then( + undefined, + reason => { result.push("then 2: " + reason); }, + ); + + reject("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["then 1: Hello!", "then 2: Hello!"]); +}); + +test("promise can be (catch) observed more than once", () => { + util.testFunction` + const { promise, reject } = defer(); + + const result = []; + + promise.catch( + reason => { result.push("catch 1: " + reason); } + ); + + promise.catch( + reason => { result.push("catch 2: " + reason); }, + ); + + reject("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["catch 1: Hello!", "catch 2: Hello!"]); +}); + +test("promise chained resolve resolves all", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + + const result = []; + + promise3.then(data => { + result.push("promise3: " + data); + resolve2(data); + }); + promise2.then(data => { + result.push("promise2: " + data); + resolve1(data); + }); + promise1.then(data => { + result.push("promise1: " + data); }); + + resolve3("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise3: Hello!", "promise2: Hello!", "promise1: Hello!"]); +}); + +test("promise then returns a literal", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const result = [] + + const promise2 = promise.then(data => { + result.push("promise resolved with: " + data); + return "promise 1 resolved: " + data; + }); + + promise2.then(data => { + result.push("promise2 resolved with: " + data); + }); + + resolve("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise resolved with: Hello!", "promise2 resolved with: promise 1 resolved: Hello!"]); +}); + +test("promise then returns a resolved promise", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const result = [] + + const promise2 = promise.then(data => { + result.push("promise resolved with: " + data); + return Promise.resolve("promise 1 resolved: " + data); + }); + + promise2.then(data => { + result.push("promise2 resolved with: " + data); + }); + + resolve("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise3: Hello!", "promise2: Hello!", "promise1: Hello!"]); +}); + +test("promise then returns a rejected promise", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const result = [] + + const promise2 = promise.then(data => { + result.push("promise resolved with: " + data); + return Promise.reject("reject!"); + }); + + promise2.catch(reason => { + result.push("promise2 rejected with: " + reason); + }); + + resolve("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise3: Hello!", "promise2: Hello!", "promise1: Hello!"]); }); From 46b82c138f4c8162f6ae9e1b5a0e903fa1b83dd8 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Mon, 12 Jul 2021 22:54:10 +0200 Subject: [PATCH 04/28] Promise class implementation --- src/lualib/Promise.ts | 68 ++++++++--- src/transformation/builtins/index.ts | 3 + src/transformation/builtins/promise.ts | 54 +++++++++ src/transformation/visitors/class/new.ts | 7 +- test/unit/builtins/promise.spec.ts | 143 ++++++++++++++++++++++- 5 files changed, 248 insertions(+), 27 deletions(-) create mode 100644 src/transformation/builtins/promise.ts diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index bc0db7101..478c4d8a6 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -2,7 +2,7 @@ // Promises implemented based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise // and https://promisesaplus.com/ - + enum __TS__PromiseState { Pending, Fulfilled, @@ -10,7 +10,7 @@ enum __TS__PromiseState { } type FulfillCallback = (value: TData) => TResult | PromiseLike; -type RejectCallback = (reason: any) => TResult | PromiseLike; +type RejectCallback = (reason: string) => TResult | PromiseLike; function __TS__PromiseDeferred() { let resolve: FulfillCallback; @@ -30,6 +30,7 @@ function __TS__IsPromiseLike(thing: unknown): thing is PromiseLike { class __TS__Promise implements Promise { private state = __TS__PromiseState.Pending; private value?: T; + private rejectionReason?: string; private fulfilledCallbacks: Array> = []; private rejectedCallbacks: Array> = []; @@ -37,6 +38,22 @@ class __TS__Promise implements Promise { public [Symbol.toStringTag]: string; + public static resolve(this: void, data: TData): Promise { + // Create and return a promise instance that is already resolved + const promise = new __TS__Promise(() => {}); + promise.state = __TS__PromiseState.Fulfilled; + promise.value = data; + return promise; + } + + public static reject(this: void, reason: string): Promise { + // Create and return a promise instance that is already rejected + const promise = new __TS__Promise(() => {}); + promise.state = __TS__PromiseState.Rejected; + promise.rejectionReason = reason; + return promise; + } + constructor(private executor: (resolve: (data: T) => void, reject: (reason: string) => void) => void) { this.execute(); } @@ -47,40 +64,51 @@ class __TS__Promise implements Promise { ): Promise { const { promise, resolve, reject } = __TS__PromiseDeferred(); + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then#return_value + function returnedPromiseHandler(f: FulfillCallback | RejectCallback) { + return value => { + try { + handleCallbackData(f(value)); + } catch (e) { + // If a handler function throws an error, the promise returned by then gets rejected with the thrown error as its value + reject(e); + } + } + } function handleCallbackData(data: TResult | PromiseLike) { if (__TS__IsPromiseLike(data)) { const nextpromise = data as __TS__Promise; if (nextpromise.state === __TS__PromiseState.Fulfilled) { + // If a handler function returns an already fulfilled promise, + // the promise returned by then gets fulfilled with that promise's value resolve(nextpromise.value); } else if (nextpromise.state === __TS__PromiseState.Rejected) { - reject(nextpromise.value); + // If a handler function returns an already rejected promise, + // the promise returned by then gets fulfilled with that promise's value + reject(nextpromise.rejectionReason); } else { - nextpromise.then(resolve, reject); + // If a handler function returns another pending promise object, the resolution/rejection + // of the promise returned by then will be subsequent to the resolution/rejection of + // the promise returned by the handler. + data.then(resolve, reject); } } else { + // If a handler returns a value, the promise returned by then gets resolved with the returned value as its value + // If a handler doesn't return anything, the promise returned by then gets resolved with undefined resolve(data); } } if (onfulfilled) { - if (this.fulfilledCallbacks.length === 0) { - this.fulfilledCallbacks.push(value => { - handleCallbackData(onfulfilled(value)); - }); - } else { - this.fulfilledCallbacks.push(onfulfilled); - } + this.fulfilledCallbacks.push( + this.fulfilledCallbacks.length === 0 ? returnedPromiseHandler(onfulfilled) : onfulfilled + ); } if (onrejected) { - if (this.rejectedCallbacks.length === 0) { - this.rejectedCallbacks.push(value => { - handleCallbackData(onrejected(value)); - }); - } else { - this.rejectedCallbacks.push(onrejected); - } + this.rejectedCallbacks.push( + this.rejectedCallbacks.length === 0 ? returnedPromiseHandler(onrejected) : onrejected + ); } - return promise; } public catch(onrejected?: (reason: any) => TResult | PromiseLike): Promise { @@ -114,6 +142,8 @@ class __TS__Promise implements Promise { private reject(reason: string): void { if (this.state === __TS__PromiseState.Pending) { this.state = __TS__PromiseState.Rejected; + this.rejectionReason = reason; + for (const callback of this.rejectedCallbacks) { callback(reason); } diff --git a/src/transformation/builtins/index.ts b/src/transformation/builtins/index.ts index 6d7f0bd8b..53473b420 100644 --- a/src/transformation/builtins/index.ts +++ b/src/transformation/builtins/index.ts @@ -22,6 +22,7 @@ import { transformGlobalCall } from "./global"; import { transformMathCall, transformMathProperty } from "./math"; import { transformNumberConstructorCall, transformNumberPrototypeCall } from "./number"; import { transformObjectConstructorCall, transformObjectPrototypeCall } from "./object"; +import { transformPromiseConstructorCall } from "./promise"; import { transformStringConstructorCall, transformStringProperty, transformStringPrototypeCall } from "./string"; import { transformSymbolConstructorCall } from "./symbol"; @@ -93,6 +94,8 @@ export function transformBuiltinCallExpression( return transformSymbolConstructorCall(context, node); case "NumberConstructor": return transformNumberConstructorCall(context, node); + case "PromiseConstructor": + return transformPromiseConstructorCall(context, node); } } diff --git a/src/transformation/builtins/promise.ts b/src/transformation/builtins/promise.ts new file mode 100644 index 000000000..0ed6b82cb --- /dev/null +++ b/src/transformation/builtins/promise.ts @@ -0,0 +1,54 @@ +import * as ts from "typescript"; +import * as lua from "../../LuaAST"; +import { TransformationContext } from "../context"; +import { unsupportedProperty } from "../utils/diagnostics"; +import { importLuaLibFeature, LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; +import { PropertyCallExpression, transformArguments } from "../visitors/call"; + +export function transformNewPromise( + context: TransformationContext, + node: ts.NewExpression, + args: lua.Expression[] +): lua.Expression { + importLuaLibFeature(context, LuaLibFeature.Promise); + + const name = lua.createIdentifier("__TS__Promise", node.expression); + return transformLuaLibFunction(context, LuaLibFeature.New, node, name, ...args); +} + +export function transformPromiseConstructorCall( + context: TransformationContext, + node: PropertyCallExpression +): lua.Expression | undefined { + const expression = node.expression; + const signature = context.checker.getResolvedSignature(node); + const params = transformArguments(context, node.arguments, signature); + + const expressionName = expression.name.text; + switch (expressionName) { + case "resolve": + importLuaLibFeature(context, LuaLibFeature.Promise); + return lua.createCallExpression( + lua.createTableIndexExpression( + lua.createIdentifier("__TS__Promise"), + lua.createStringLiteral("resolve"), + expression + ), + params, + node + ); + case "reject": + importLuaLibFeature(context, LuaLibFeature.Promise); + return lua.createCallExpression( + lua.createTableIndexExpression( + lua.createIdentifier("__TS__Promise"), + lua.createStringLiteral("reject"), + expression + ), + params, + node + ); + default: + context.diagnostics.push(unsupportedProperty(expression.name, "Promise", expressionName)); + } +} diff --git a/src/transformation/visitors/class/new.ts b/src/transformation/visitors/class/new.ts index 7c3cc9393..1f1e6414f 100644 --- a/src/transformation/visitors/class/new.ts +++ b/src/transformation/visitors/class/new.ts @@ -1,5 +1,6 @@ import * as ts from "typescript"; import * as lua from "../../../LuaAST"; +import { transformNewPromise } from "../../builtins/promise"; import { FunctionVisitor, TransformationContext } from "../../context"; import { AnnotationKind, getTypeAnnotations } from "../../utils/annotations"; import { annotationInvalidArgumentCount, annotationRemoved } from "../../utils/diagnostics"; @@ -61,7 +62,6 @@ export const transformNewExpression: FunctionVisitor = (node, return lua.createTableExpression(undefined, node); } - let name = context.transformExpression(node.expression); const signature = context.checker.getResolvedSignature(node); const params = node.arguments ? transformArguments(context, node.arguments, signature) @@ -70,10 +70,11 @@ export const transformNewExpression: FunctionVisitor = (node, checkForLuaLibType(context, type); if (isStandardLibraryType(context, type, "Promise")) { - importLuaLibFeature(context, LuaLibFeature.Promise) - name = lua.createIdentifier("__TS__Promise", node.expression); + return transformNewPromise(context, node, params); } + const name = context.transformExpression(node.expression); + const customConstructorAnnotation = annotations.get(AnnotationKind.CustomConstructor); if (customConstructorAnnotation) { if (customConstructorAnnotation.args.length === 1) { diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index f2ffed29e..c67694c6b 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -12,11 +12,23 @@ const deferPromise = `function defer() { }`; test("can create resolved promise", () => { - util.testExpression`Promise.resolve(42)`.expectNoExecutionError(); + util.testFunction` + const { state, value } = Promise.resolve(42) as any; + return { state, value }; + `.expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: 42, + }); }); test("can create rejected promise", () => { - util.testExpression`Promise.reject(42)`.expectNoExecutionError(); + util.testFunction` + const { state, rejectionReason } = Promise.reject("test rejection") as any; + return { state, rejectionReason }; + `.expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: "test rejection", + }); }); test("promise can be resolved", () => { @@ -293,7 +305,7 @@ test("promise then returns a resolved promise", () => { return result; ` .setTsHeader(deferPromise) - .expectToEqual(["promise3: Hello!", "promise2: Hello!", "promise1: Hello!"]); + .expectToEqual(["promise resolved with: Hello!", "promise2 resolved with: promise 1 resolved: Hello!"]); }); test("promise then returns a rejected promise", () => { @@ -304,7 +316,7 @@ test("promise then returns a rejected promise", () => { const promise2 = promise.then(data => { result.push("promise resolved with: " + data); - return Promise.reject("reject!"); + return Promise.reject("promise 1: reject!"); }); promise2.catch(reason => { @@ -316,5 +328,126 @@ test("promise then returns a rejected promise", () => { return result; ` .setTsHeader(deferPromise) - .expectToEqual(["promise3: Hello!", "promise2: Hello!", "promise1: Hello!"]); + .expectToEqual(["promise resolved with: Hello!", "promise2 rejected with: promise 1: reject!"]); +}); + +test("promise then returns a pending promise (resolves)", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const result = []; + + let resolve2: any; + + const promise2 = promise.then(data => { + result.push("promise resolved with: " + data); + + const promise3 = new Promise((res) => { + resolve2 = res; + }); + + promise3.then(data2 => { + result.push("promise3 resolved with: " + data2); + }); + + return promise3; + }); + + promise2.then(data => { + result.push("promise2 resolved with: " + data); + }); + + // Resolve promise 1 + resolve("Hello!"); + + // Resolve promise 2 and 3 + resolve2("World!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise resolved with: Hello!", "promise3 resolved with: World!", "promise2 resolved with: World!"]); +}); + +test("promise then returns a pending promise (rejects)", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const result = []; + + let reject: any; + + const promise2 = promise.then(data => { + result.push("promise resolved with: " + data); + + const promise3 = new Promise((_, rej) => { + reject = rej; + }); + + promise3.catch(reason => { + result.push("promise3 rejected with: " + reason); + }); + + return promise3; + }); + + promise2.catch(reason => { + result.push("promise2 rejected with: " + reason); + }); + + // Resolve promise 1 + resolve("Hello!"); + + // Reject promise 2 and 3 + reject("World!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise resolved with: Hello!", "promise3 rejected with: World!", "promise2 rejected with: World!"]); +}); + +test("promise then onFulfilled throws", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const result = [] + + const promise2 = promise.then(data => { + throw "fulfill exception!" + }); + + promise2.catch(reason => { + result.push("promise2 rejected with: " + reason); + }); + + resolve("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise2 rejected with: fulfill exception!"]); +}); + +test("promise then onRejected throws", () => { + util.testFunction` + const { promise, reject } = defer(); + + const result = [] + + const promise2 = promise.then( + _ => {}, + reason => { throw "fulfill exception from onReject!" } + ); + + promise2.catch(reason => { + result.push("promise2 rejected with: " + reason); + }); + + reject("Hello!"); + + return result; + ` + .setTsHeader(deferPromise) + .expectToEqual(["promise2 rejected with: fulfill exception from onReject!"]); }); From f0b325db16709f29ef4fff500047e55dfefb3475 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Wed, 14 Jul 2021 23:27:20 +0200 Subject: [PATCH 05/28] Implemented Promise.all --- src/LuaLib.ts | 1 + src/lualib/Promise.ts | 14 +- src/lualib/PromiseAll.ts | 47 ++++ src/lualib/declarations/global.d.ts | 1 + src/transformation/builtins/promise.ts | 2 + test/unit/builtins/promise.spec.ts | 302 +++++++++++++++++-------- 6 files changed, 258 insertions(+), 109 deletions(-) create mode 100644 src/lualib/PromiseAll.ts diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 5934a9a12..0e6ad1d2b 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -60,6 +60,7 @@ export enum LuaLibFeature { ParseFloat = "ParseFloat", ParseInt = "ParseInt", Promise = "Promise", + PromiseAll = "PromiseAll", Set = "Set", SetDescriptor = "SetDescriptor", WeakMap = "WeakMap", diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index 478c4d8a6..b4780485f 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -28,9 +28,9 @@ function __TS__IsPromiseLike(thing: unknown): thing is PromiseLike { } class __TS__Promise implements Promise { - private state = __TS__PromiseState.Pending; - private value?: T; - private rejectionReason?: string; + public state = __TS__PromiseState.Pending; + public value?: T; + public rejectionReason?: string; private fulfilledCallbacks: Array> = []; private rejectedCallbacks: Array> = []; @@ -54,8 +54,8 @@ class __TS__Promise implements Promise { return promise; } - constructor(private executor: (resolve: (data: T) => void, reject: (reason: string) => void) => void) { - this.execute(); + constructor(executor: (resolve: (data: T) => void, reject: (reason: string) => void) => void) { + executor(this.resolve.bind(this), this.reject.bind(this)); } public then( @@ -121,10 +121,6 @@ class __TS__Promise implements Promise { return this; } - private execute(): void { - this.executor(this.resolve.bind(this), this.reject.bind(this)); - } - private resolve(data: T): void { if (this.state === __TS__PromiseState.Pending) { this.state = __TS__PromiseState.Fulfilled; diff --git a/src/lualib/PromiseAll.ts b/src/lualib/PromiseAll.ts new file mode 100644 index 000000000..2522a5f73 --- /dev/null +++ b/src/lualib/PromiseAll.ts @@ -0,0 +1,47 @@ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all +async function __TS__PromiseAll(this: void, values: Iterable>): Promise { + const results: T[] = []; + + const toResolve = new LuaTable>(); + let numToResolve = 0; + + let i = 0; + for (const value of values) { + if (value instanceof __TS__Promise) { + if (value.state === __TS__PromiseState.Fulfilled) { + results[i] = value.value; + } else if (value.state === __TS__PromiseState.Rejected) { + return Promise.reject(value.rejectionReason); + } else { + numToResolve++; + toResolve.set(i, value); + } + } else { + results[i] = value as T; + } + i++; + } + + if (numToResolve === 0) { + return Promise.resolve(results); + } + + return new Promise((resolve, reject) => { + for (const [index, promise] of pairs(toResolve)) { + promise.then( + data => { + // When resolved, store result and if there is nothing left to resolve, resolve the returned promise + results[index] = data; + numToResolve--; + if (numToResolve === 0) { + resolve(results); + } + }, + reason => { + // When rejected, immediately reject the returned promise + reject(reason); + } + ); + } + }); +} diff --git a/src/lualib/declarations/global.d.ts b/src/lualib/declarations/global.d.ts index 488586275..c176b6231 100644 --- a/src/lualib/declarations/global.d.ts +++ b/src/lualib/declarations/global.d.ts @@ -29,4 +29,5 @@ declare function unpack(list: T[], i?: number, j?: number): T[]; declare function select(index: number, ...args: T[]): T; declare function select(index: "#", ...args: T[]): number; +declare function pairs(t: LuaTable): LuaIterable, Record>; declare function ipairs(t: Record): LuaIterable, Record>; diff --git a/src/transformation/builtins/promise.ts b/src/transformation/builtins/promise.ts index 0ed6b82cb..01a165d69 100644 --- a/src/transformation/builtins/promise.ts +++ b/src/transformation/builtins/promise.ts @@ -26,6 +26,8 @@ export function transformPromiseConstructorCall( const expressionName = expression.name.text; switch (expressionName) { + case "all": + return transformLuaLibFunction(context, LuaLibFeature.PromiseAll, node, ...params); case "resolve": importLuaLibFeature(context, LuaLibFeature.Promise); return lua.createCallExpression( diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index c67694c6b..ca54cd1f4 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -1,7 +1,16 @@ import * as util from "../../util"; -// Create a promise and store its resolve and reject functions, useful for testing -const deferPromise = `function defer() { +const promiseTestLib = ` +// Some logging utility, useful for asserting orders of operations + +const allLogs: any[] = []; +function log(...values: any[]) { + allLogs.push(...values); +} + +// Create a promise and store its resolve and reject functions, useful for creating pending promises + +function defer() { let resolve: (data: any) => void = () => {}; let reject: (reason: string) => void = () => {}; const promise = new Promise((res, rej) => { @@ -51,7 +60,7 @@ test("promise can be resolved", () => { return { beforeResolve, afterResolve, rejectResult }; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual({ beforeResolve: undefined, afterResolve: "Hello!", @@ -65,7 +74,7 @@ test("promise can be rejected", () => { let resolveResult: string | undefined; let rejectResult: string | undefined; - + promise.then( data => { resolveResult = data; }, reason => { rejectResult = reason; } @@ -79,7 +88,7 @@ test("promise can be rejected", () => { return { beforeReject, afterReject, resolveResult }; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual({ beforeReject: undefined, afterReject: "Hello!", @@ -91,18 +100,16 @@ test("promise cannot be resolved more than once", () => { util.testFunction` const { promise, resolve } = defer(); - let result: string[] = []; - promise.then( - data => { result.push(data); } + data => { log(data); } ); resolve("Hello!"); resolve("World!"); // Second resolve should be ignored - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["Hello!"]); }); @@ -110,19 +117,17 @@ test("promise cannot be rejected more than once", () => { util.testFunction` const { promise, reject } = defer(); - let result: string[] = []; - promise.then( _ => {}, - reason => { result.push(reason); } + reason => { log(reason); } ); reject("Hello!"); reject("World!"); // Second reject should be ignored - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["Hello!"]); }); @@ -130,19 +135,17 @@ test("promise cannot be rejected after resolving", () => { util.testFunction` const { promise, resolve, reject } = defer(); - let result: string[] = []; - promise.then( - data => { result.push(data); }, - reason => { result.push(reason); } + data => { log(data); }, + reason => { log(reason); } ); resolve("Hello!"); reject("World!"); // should be ignored because already resolved - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["Hello!"]); }); @@ -150,19 +153,17 @@ test("promise cannot be resolved after rejecting", () => { util.testFunction` const { promise, resolve, reject } = defer(); - let result: string[] = []; - promise.then( - data => { result.push(data); }, - reason => { result.push(reason); } + data => { log(data); }, + reason => { log(reason); } ); reject("Hello!"); resolve("World!"); // should be ignored because already rejected - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["Hello!"]); }); @@ -170,21 +171,19 @@ test("promise can be (then-resolve) observed more than once", () => { util.testFunction` const { promise, resolve } = defer(); - const result = []; - promise.then( - data => { result.push("then 1: " + data); } + data => { log("then 1: " + data); } ); promise.then( - data => { result.push("then 2: " + data); } + data => { log("then 2: " + data); } ); resolve("Hello!"); - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["then 1: Hello!", "then 2: Hello!"]); }); @@ -192,23 +191,21 @@ test("promise can be (then-reject) observed more than once", () => { util.testFunction` const { promise, reject } = defer(); - const result = []; - promise.then( undefined, - reason => { result.push("then 1: " + reason); } + reason => { log("then 1: " + reason); } ); promise.then( undefined, - reason => { result.push("then 2: " + reason); }, + reason => { log("then 2: " + reason); }, ); reject("Hello!"); - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["then 1: Hello!", "then 2: Hello!"]); }); @@ -216,21 +213,19 @@ test("promise can be (catch) observed more than once", () => { util.testFunction` const { promise, reject } = defer(); - const result = []; - promise.catch( - reason => { result.push("catch 1: " + reason); } + reason => { log("catch 1: " + reason); } ); promise.catch( - reason => { result.push("catch 2: " + reason); }, + reason => { log("catch 2: " + reason); }, ); reject("Hello!"); - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["catch 1: Hello!", "catch 2: Hello!"]); }); @@ -240,25 +235,23 @@ test("promise chained resolve resolves all", () => { const { promise: promise2, resolve: resolve2 } = defer(); const { promise: promise3, resolve: resolve3 } = defer(); - const result = []; - promise3.then(data => { - result.push("promise3: " + data); + log("promise3: " + data); resolve2(data); }); promise2.then(data => { - result.push("promise2: " + data); + log("promise2: " + data); resolve1(data); }); promise1.then(data => { - result.push("promise1: " + data); + log("promise1: " + data); }); resolve3("Hello!"); - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["promise3: Hello!", "promise2: Hello!", "promise1: Hello!"]); }); @@ -266,22 +259,20 @@ test("promise then returns a literal", () => { util.testFunction` const { promise, resolve } = defer(); - const result = [] - const promise2 = promise.then(data => { - result.push("promise resolved with: " + data); + log("promise resolved with: " + data); return "promise 1 resolved: " + data; }); promise2.then(data => { - result.push("promise2 resolved with: " + data); + log("promise2 resolved with: " + data); }); resolve("Hello!"); - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["promise resolved with: Hello!", "promise2 resolved with: promise 1 resolved: Hello!"]); }); @@ -289,22 +280,20 @@ test("promise then returns a resolved promise", () => { util.testFunction` const { promise, resolve } = defer(); - const result = [] - const promise2 = promise.then(data => { - result.push("promise resolved with: " + data); + log("promise resolved with: " + data); return Promise.resolve("promise 1 resolved: " + data); }); promise2.then(data => { - result.push("promise2 resolved with: " + data); + log("promise2 resolved with: " + data); }); resolve("Hello!"); - - return result; + + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["promise resolved with: Hello!", "promise2 resolved with: promise 1 resolved: Hello!"]); }); @@ -312,22 +301,20 @@ test("promise then returns a rejected promise", () => { util.testFunction` const { promise, resolve } = defer(); - const result = [] - const promise2 = promise.then(data => { - result.push("promise resolved with: " + data); + log("promise resolved with: " + data); return Promise.reject("promise 1: reject!"); }); promise2.catch(reason => { - result.push("promise2 rejected with: " + reason); + log("promise2 rejected with: " + reason); }); resolve("Hello!"); - - return result; + + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["promise resolved with: Hello!", "promise2 rejected with: promise 1: reject!"]); }); @@ -335,26 +322,24 @@ test("promise then returns a pending promise (resolves)", () => { util.testFunction` const { promise, resolve } = defer(); - const result = []; - let resolve2: any; const promise2 = promise.then(data => { - result.push("promise resolved with: " + data); + log("promise resolved with: " + data); const promise3 = new Promise((res) => { resolve2 = res; }); promise3.then(data2 => { - result.push("promise3 resolved with: " + data2); + log("promise3 resolved with: " + data2); }); return promise3; }); promise2.then(data => { - result.push("promise2 resolved with: " + data); + log("promise2 resolved with: " + data); }); // Resolve promise 1 @@ -362,37 +347,39 @@ test("promise then returns a pending promise (resolves)", () => { // Resolve promise 2 and 3 resolve2("World!"); - - return result; + + return allLogs; ` - .setTsHeader(deferPromise) - .expectToEqual(["promise resolved with: Hello!", "promise3 resolved with: World!", "promise2 resolved with: World!"]); + .setTsHeader(promiseTestLib) + .expectToEqual([ + "promise resolved with: Hello!", + "promise3 resolved with: World!", + "promise2 resolved with: World!", + ]); }); test("promise then returns a pending promise (rejects)", () => { util.testFunction` const { promise, resolve } = defer(); - const result = []; - let reject: any; const promise2 = promise.then(data => { - result.push("promise resolved with: " + data); + log("promise resolved with: " + data); const promise3 = new Promise((_, rej) => { reject = rej; }); promise3.catch(reason => { - result.push("promise3 rejected with: " + reason); + log("promise3 rejected with: " + reason); }); return promise3; }); promise2.catch(reason => { - result.push("promise2 rejected with: " + reason); + log("promise2 rejected with: " + reason); }); // Resolve promise 1 @@ -400,32 +387,34 @@ test("promise then returns a pending promise (rejects)", () => { // Reject promise 2 and 3 reject("World!"); - - return result; + + return allLogs; ` - .setTsHeader(deferPromise) - .expectToEqual(["promise resolved with: Hello!", "promise3 rejected with: World!", "promise2 rejected with: World!"]); + .setTsHeader(promiseTestLib) + .expectToEqual([ + "promise resolved with: Hello!", + "promise3 rejected with: World!", + "promise2 rejected with: World!", + ]); }); test("promise then onFulfilled throws", () => { util.testFunction` const { promise, resolve } = defer(); - const result = [] - const promise2 = promise.then(data => { throw "fulfill exception!" }); promise2.catch(reason => { - result.push("promise2 rejected with: " + reason); + log("promise2 rejected with: " + reason); }); resolve("Hello!"); - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["promise2 rejected with: fulfill exception!"]); }); @@ -433,21 +422,134 @@ test("promise then onRejected throws", () => { util.testFunction` const { promise, reject } = defer(); - const result = [] - const promise2 = promise.then( _ => {}, reason => { throw "fulfill exception from onReject!" } ); promise2.catch(reason => { - result.push("promise2 rejected with: " + reason); + log("promise2 rejected with: " + reason); }); reject("Hello!"); - return result; + return allLogs; ` - .setTsHeader(deferPromise) + .setTsHeader(promiseTestLib) .expectToEqual(["promise2 rejected with: fulfill exception from onReject!"]); }); + +describe("Promise.all", () => { + test("resolves once all arguments are resolved", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + + const promise = Promise.all([promise1, promise2, promise3]); + promise.then(([result1, result2, result3]) => { + log(result1, result2, result3); + }); + + resolve1("promise 1 result!"); + resolve2("promise 2 result!"); + resolve3("promise 3 result!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["promise 1 result!", "promise 2 result!", "promise 3 result!"]); + }); + + test("rejects on first rejection", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, reject: reject2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + + const promise = Promise.all([promise1, promise2, promise3]); + promise.then( + ([result1, result2, result3]) => { + log(result1, result2, result3); + }, + reason => { + log(reason); + } + ); + + resolve1("promise 1 result!"); + reject2("promise 2 rejects!"); + resolve3("promise 3 result!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["promise 2 rejects!"]); + }); + + test("handles already-resolved promises", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + + const promise = Promise.all([promise1, Promise.resolve("already resolved!")]); + promise.then(([result1, result2]) => { + log(result1, result2); + }); + + resolve1("promise 1 result!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["promise 1 result!", "already resolved!"]); + }); + + test("handles non-promise data", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + + const promise = Promise.all([42, promise1, "foo"]); + promise.then(([result1, result2, result3]) => { + log(result1, result2, result3); + }); + + resolve1("promise 1 result!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([42, "promise 1 result!", "foo"]); + }); + + // NOTE: deviation from spec! + test("returns already-resolved promise if no pending promises in arguments", () => { + util.testFunction` + const { state, value } = Promise.all([42, Promise.resolve("already resolved!"), "foo"]) as any; + return { state, value }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: [42, "already resolved!", "foo"] + }); + }); + + // NOTE: deviation from spec! + test("returns already-rejected promise if already rejected promise in arguments", () => { + util.testFunction` + const { state, rejectionReason } = Promise.all([42, Promise.reject("already rejected!")]) as any; + return { state, rejectionReason }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: "already rejected!" + }); + }); +}); + +describe("Promise.allSettled", () => {}); + +describe("Promise.any", () => {}); + +describe("Promise.race", () => {}); From d2db83597d3017e2bbde99fbc1ce679dd24a58b4 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Fri, 16 Jul 2021 22:51:05 +0200 Subject: [PATCH 06/28] Promise.any --- src/LuaLib.ts | 1 + src/lualib/Promise.ts | 12 +-- src/lualib/PromiseAny.ts | 43 +++++++++ src/transformation/builtins/promise.ts | 2 + test/unit/builtins/promise.spec.ts | 117 ++++++++++++++++++++++++- 5 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 src/lualib/PromiseAny.ts diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 0e6ad1d2b..62504203a 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -61,6 +61,7 @@ export enum LuaLibFeature { ParseInt = "ParseInt", Promise = "Promise", PromiseAll = "PromiseAll", + PromiseAny = "PromiseAny", Set = "Set", SetDescriptor = "SetDescriptor", WeakMap = "WeakMap", diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index b4780485f..55fd3318e 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -10,7 +10,7 @@ enum __TS__PromiseState { } type FulfillCallback = (value: TData) => TResult | PromiseLike; -type RejectCallback = (reason: string) => TResult | PromiseLike; +type RejectCallback = (reason: any) => TResult | PromiseLike; function __TS__PromiseDeferred() { let resolve: FulfillCallback; @@ -30,13 +30,13 @@ function __TS__IsPromiseLike(thing: unknown): thing is PromiseLike { class __TS__Promise implements Promise { public state = __TS__PromiseState.Pending; public value?: T; - public rejectionReason?: string; + public rejectionReason?: any; private fulfilledCallbacks: Array> = []; private rejectedCallbacks: Array> = []; private finallyCallbacks: Array<() => void> = []; - public [Symbol.toStringTag]: string; + public [Symbol.toStringTag]: string; // Required to implement interface, no output Lua public static resolve(this: void, data: TData): Promise { // Create and return a promise instance that is already resolved @@ -46,7 +46,7 @@ class __TS__Promise implements Promise { return promise; } - public static reject(this: void, reason: string): Promise { + public static reject(this: void, reason: any): Promise { // Create and return a promise instance that is already rejected const promise = new __TS__Promise(() => {}); promise.state = __TS__PromiseState.Rejected; @@ -54,7 +54,7 @@ class __TS__Promise implements Promise { return promise; } - constructor(executor: (resolve: (data: T) => void, reject: (reason: string) => void) => void) { + constructor(executor: (resolve: (data: T) => void, reject: (reason: any) => void) => void) { executor(this.resolve.bind(this), this.reject.bind(this)); } @@ -135,7 +135,7 @@ class __TS__Promise implements Promise { } } - private reject(reason: string): void { + private reject(reason: any): void { if (this.state === __TS__PromiseState.Pending) { this.state = __TS__PromiseState.Rejected; this.rejectionReason = reason; diff --git a/src/lualib/PromiseAny.ts b/src/lualib/PromiseAny.ts new file mode 100644 index 000000000..5d996129a --- /dev/null +++ b/src/lualib/PromiseAny.ts @@ -0,0 +1,43 @@ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any +async function __TS__PromiseAny(this: void, values: Array> | Iterable>): Promise { + + const rejections: string[] = []; + const pending: Array> = []; + + for (const value of values) { + if (value instanceof __TS__Promise) { + if (value.state === __TS__PromiseState.Fulfilled) { + return Promise.resolve(value.value); + } else if (value.state === __TS__PromiseState.Rejected) { + rejections.push(value.rejectionReason); + } else { + pending.push(value); + } + } else { + return Promise.resolve(value); + } + } + + if (pending.length === 0) { + return Promise.reject("No promises to resolve with .any()"); + } + + let numResolved = 0; + + return new Promise((resolve, reject) => { + for (const promise of pending) { + promise.then(data => { resolve(data); }, + reason => { + rejections.push(reason); + numResolved++; + if (numResolved === pending.length) { + reject({ + name: "AggregateError", + message: "All Promises rejected", + errors: rejections + }) + } + }) + } + }) +} diff --git a/src/transformation/builtins/promise.ts b/src/transformation/builtins/promise.ts index 01a165d69..4825ec9ba 100644 --- a/src/transformation/builtins/promise.ts +++ b/src/transformation/builtins/promise.ts @@ -28,6 +28,8 @@ export function transformPromiseConstructorCall( switch (expressionName) { case "all": return transformLuaLibFunction(context, LuaLibFeature.PromiseAll, node, ...params); + case "any": + return transformLuaLibFunction(context, LuaLibFeature.PromiseAny, node, ...params); case "resolve": importLuaLibFeature(context, LuaLibFeature.Promise); return lua.createCallExpression( diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index ca54cd1f4..6c33ed5b4 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -530,7 +530,7 @@ describe("Promise.all", () => { .setTsHeader(promiseTestLib) .expectToEqual({ state: 1, // __TS__PromiseState.Fulfilled - value: [42, "already resolved!", "foo"] + value: [42, "already resolved!", "foo"], }); }); @@ -543,13 +543,124 @@ describe("Promise.all", () => { .setTsHeader(promiseTestLib) .expectToEqual({ state: 2, // __TS__PromiseState.Rejected - rejectionReason: "already rejected!" + rejectionReason: "already rejected!", }); }); }); describe("Promise.allSettled", () => {}); -describe("Promise.any", () => {}); +describe("Promise.any", () => { + test("resolves once first promise resolves", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + + const promise = Promise.any([promise1, promise2, promise3]); + promise.then(data => { + log(data); + }); + + resolve2("promise 2 result!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["promise 2 result!"]); + }); + + test("rejects once all promises reject", () => { + util.testFunction` + const { promise: promise1, reject: reject1 } = defer(); + const { promise: promise2, reject: reject2 } = defer(); + const { promise: promise3, reject: reject3 } = defer(); + + const promise = Promise.any([promise1, promise2, promise3]); + promise.catch(reason => { + log(reason); + }); + + reject2("promise 2 rejected!"); + reject3("promise 3 rejected!"); + reject1("promise 1 rejected!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { + name: "AggregateError", + message: "All Promises rejected", + errors: ["promise 2 rejected!", "promise 3 rejected!", "promise 1 rejected!"], + }, + ]); + }); + + test("handles already rejected promises", () => { + util.testFunction` + const { promise: promise1, reject: reject1 } = defer(); + const { promise: promise2, reject: reject2 } = defer(); + + const promise = Promise.any([promise1, Promise.reject("already rejected!"), promise2]); + promise.catch(reason => { + log(reason); + }); + + reject2("promise 2 rejected!"); + reject1("promise 1 rejected!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { + name: "AggregateError", + message: "All Promises rejected", + errors: ["already rejected!", "promise 2 rejected!", "promise 1 rejected!"], + }, + ]); + }); + + test("rejects if iterable is empty", () => { + util.testFunction` + const { state, rejectionReason } = Promise.any([]) as any; + return { state, rejectionReason }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: "No promises to resolve with .any()", + }); + }); + + test("immediately resolves with literal", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const { state, value } = Promise.any([promise, "my literal"]) as any; + return { state, value }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: "my literal", + }); + }); + + test("immediately resolves with resolved promise", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const { state, value } = Promise.any([promise, Promise.resolve("my resolved promise")]) as any; + return { state, value }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: "my resolved promise", + }); + }); +}); describe("Promise.race", () => {}); From 7f662e679239543c8ef4d229572b50d204bd5f98 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sat, 17 Jul 2021 14:15:50 +0200 Subject: [PATCH 07/28] Promise.race --- src/LuaLib.ts | 1 + src/lualib/PromiseAll.ts | 8 ++- src/lualib/PromiseAny.ts | 12 +++- src/lualib/PromiseRace.ts | 33 +++++++++ src/transformation/builtins/promise.ts | 2 + test/unit/builtins/promise.spec.ts | 96 +++++++++++++++++++++++++- 6 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 src/lualib/PromiseRace.ts diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 62504203a..6c018d58a 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -62,6 +62,7 @@ export enum LuaLibFeature { Promise = "Promise", PromiseAll = "PromiseAll", PromiseAny = "PromiseAny", + PromiseRace = "PromiseRace", Set = "Set", SetDescriptor = "SetDescriptor", WeakMap = "WeakMap", diff --git a/src/lualib/PromiseAll.ts b/src/lualib/PromiseAll.ts index 2522a5f73..17181e30b 100644 --- a/src/lualib/PromiseAll.ts +++ b/src/lualib/PromiseAll.ts @@ -9,19 +9,24 @@ async function __TS__PromiseAll(this: void, values: Iterable(this: void, values: Iterable { - // When resolved, store result and if there is nothing left to resolve, resolve the returned promise + // When resolved, store value in results array results[index] = data; numToResolve--; if (numToResolve === 0) { + // If there are no more promises to resolve, resolve with our filled results array resolve(results); } }, diff --git a/src/lualib/PromiseAny.ts b/src/lualib/PromiseAny.ts index 5d996129a..51b025b96 100644 --- a/src/lualib/PromiseAny.ts +++ b/src/lualib/PromiseAny.ts @@ -7,17 +7,22 @@ async function __TS__PromiseAny(this: void, values: Array> for (const value of values) { if (value instanceof __TS__Promise) { if (value.state === __TS__PromiseState.Fulfilled) { + // If value is a resolved promise, return a new resolved promise with its value return Promise.resolve(value.value); } else if (value.state === __TS__PromiseState.Rejected) { + // If value is a rejected promise, add its value to our list of rejections rejections.push(value.rejectionReason); } else { + // If value is a pending promise, add it to the list of pending promises pending.push(value); } } else { + // If value is not a promise, return a resolved promise with it as its value return Promise.resolve(value); } } + // If we have not returned yet and there are no pending promises, reject if (pending.length === 0) { return Promise.reject("No promises to resolve with .any()"); } @@ -26,11 +31,16 @@ async function __TS__PromiseAny(this: void, values: Array> return new Promise((resolve, reject) => { for (const promise of pending) { - promise.then(data => { resolve(data); }, + promise.then(data => { + // If any pending promise resolves, resolve this promise with its data + resolve(data); + }, reason => { + // If a pending promise rejects, add its rejection to our rejection list rejections.push(reason); numResolved++; if (numResolved === pending.length) { + // If there are no more pending promises, reject with the list of rejections reject({ name: "AggregateError", message: "All Promises rejected", diff --git a/src/lualib/PromiseRace.ts b/src/lualib/PromiseRace.ts new file mode 100644 index 000000000..68c838c63 --- /dev/null +++ b/src/lualib/PromiseRace.ts @@ -0,0 +1,33 @@ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race +async function __TS__PromiseRace(this: void, values: Array>): Promise { + const pending: Array> = []; + + for (const value of values) { + if (value instanceof __TS__Promise) { + if (value.state === __TS__PromiseState.Fulfilled) { + // If value is a fulfilled promise, return a resolved promise with its value + return Promise.resolve(value.value); + } else if (value.state === __TS__PromiseState.Rejected) { + // If value is a rejected promise, return rejected promise with its value + return Promise.reject(value.rejectionReason); + } else { + // If value is a pending promise, add it to the list of pending promises + pending.push(value); + } + } else { + // If value is not a promise, return a promise resolved with it as its value + return Promise.resolve(value); + } + } + + // If not yet returned, wait for any pending promise to resolve or reject. + // If there are no pending promise, this promise will be pending forever as per specification. + return new Promise((resolve, reject) => { + for (const promise of pending) { + promise.then( + value => resolve(value), + reason => reject(reason) + ); + } + }); +} diff --git a/src/transformation/builtins/promise.ts b/src/transformation/builtins/promise.ts index 4825ec9ba..b8d80253d 100644 --- a/src/transformation/builtins/promise.ts +++ b/src/transformation/builtins/promise.ts @@ -30,6 +30,8 @@ export function transformPromiseConstructorCall( return transformLuaLibFunction(context, LuaLibFeature.PromiseAll, node, ...params); case "any": return transformLuaLibFunction(context, LuaLibFeature.PromiseAny, node, ...params); + case "race": + return transformLuaLibFunction(context, LuaLibFeature.PromiseRace, node, ...params); case "resolve": importLuaLibFeature(context, LuaLibFeature.Promise); return lua.createCallExpression( diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index 6c33ed5b4..cdec13218 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -663,4 +663,98 @@ describe("Promise.any", () => { }); }); -describe("Promise.race", () => {}); +describe("Promise.race", () => { + test("resolves once first promise resolves", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + + const promise = Promise.race([promise1, promise2, promise3]); + promise.then(data => { + log(data); + }); + + resolve2("promise 2 result!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["promise 2 result!"]); + }); + + test("rejects once first promise rejects", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, reject: reject2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + + const promise = Promise.race([promise1, promise2, promise3]); + promise.catch(reason => { + log(reason); + }); + + reject2("promise 2 rejected!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["promise 2 rejected!"]); + }); + + test("returns resolved promise if arguments contain resolved promise", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + + const { state, value } = Promise.race([promise1, Promise.resolve("already resolved!"), promise2]) as any; + return { state, value }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: "already resolved!", + }); + }); + + test("returns resolved promise if arguments contain literal", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + + const { state, value } = Promise.race([promise1, "my literal", promise2]) as any; + return { state, value }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: "my literal", + }); + }); + + test("returns rejected promise if arguments contain rejected promise", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + + const { state, rejectionReason } = Promise.race([promise1, Promise.reject("already rejected!"), promise2]) as any; + return { state, rejectionReason }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: "already rejected!", + }); + }); + + test("returns forever pending promise if argument array is empty", () => { + util.testFunction` + const { state } = Promise.race([]) as any; + return { state }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 0, // __TS__PromiseState.Pending + }); + }) +}); From c0bbb42a06f546cf6eb225d5a9c69dc29100b04e Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sat, 17 Jul 2021 16:13:22 +0200 Subject: [PATCH 08/28] Promise.allSettled --- src/LuaLib.ts | 1 + src/lualib/Promise.ts | 2 +- src/lualib/PromiseAllSettled.ts | 61 ++++++++++ src/lualib/PromiseAny.ts | 39 +++--- src/transformation/builtins/promise.ts | 30 ++--- test/unit/builtins/promise.spec.ts | 159 ++++++++++++++++++++++++- 6 files changed, 248 insertions(+), 44 deletions(-) create mode 100644 src/lualib/PromiseAllSettled.ts diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 6c018d58a..cfe9b5e5c 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -61,6 +61,7 @@ export enum LuaLibFeature { ParseInt = "ParseInt", Promise = "Promise", PromiseAll = "PromiseAll", + PromiseAllSettled = "PromiseAllSettled", PromiseAny = "PromiseAny", PromiseRace = "PromiseRace", Set = "Set", diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index 55fd3318e..8b759fdcf 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -73,7 +73,7 @@ class __TS__Promise implements Promise { // If a handler function throws an error, the promise returned by then gets rejected with the thrown error as its value reject(e); } - } + }; } function handleCallbackData(data: TResult | PromiseLike) { if (__TS__IsPromiseLike(data)) { diff --git a/src/lualib/PromiseAllSettled.ts b/src/lualib/PromiseAllSettled.ts new file mode 100644 index 000000000..4b33c15ed --- /dev/null +++ b/src/lualib/PromiseAllSettled.ts @@ -0,0 +1,61 @@ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled +async function __TS__PromiseAllSettled( + this: void, + values: Iterable +): Promise ? U : T>>> { + const results: Array ? U : T>> = []; + + const toResolve = new LuaTable>(); + let numToResolve = 0; + + let i = 0; + for (const value of values) { + if (value instanceof __TS__Promise) { + if (value.state === __TS__PromiseState.Fulfilled) { + // If value is a resolved promise, add a fulfilled PromiseSettledResult + results[i] = { status: "fulfilled", value: value.value }; + } else if (value.state === __TS__PromiseState.Rejected) { + // If value is a rejected promise, add a rejected PromiseSettledResult + results[i] = { status: "rejected", reason: value.rejectionReason }; + } else { + // If value is a pending promise, add it to the list of pending promises + numToResolve++; + toResolve.set(i, value); + } + } else { + // If value is not a promise, add it to the results as fulfilled PromiseSettledResult + results[i] = { status: "fulfilled", value: value as any }; + } + i++; + } + + // If there are no remaining pending promises, return a resolved promise with the results + if (numToResolve === 0) { + return Promise.resolve(results); + } + + return new Promise(resolve => { + for (const [index, promise] of pairs(toResolve)) { + promise.then( + data => { + // When resolved, add a fulfilled PromiseSettledResult + results[index] = { status: "fulfilled", value: data as any }; + numToResolve--; + if (numToResolve === 0) { + // If there are no more promises to resolve, resolve with our filled results array + resolve(results); + } + }, + reason => { + // When resolved, add a rejected PromiseSettledResult + results[index] = { status: "rejected", reason }; + numToResolve--; + if (numToResolve === 0) { + // If there are no more promises to resolve, resolve with our filled results array + resolve(results); + } + } + ); + } + }); +} diff --git a/src/lualib/PromiseAny.ts b/src/lualib/PromiseAny.ts index 51b025b96..4780a7298 100644 --- a/src/lualib/PromiseAny.ts +++ b/src/lualib/PromiseAny.ts @@ -1,6 +1,5 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any -async function __TS__PromiseAny(this: void, values: Array> | Iterable>): Promise { - +async function __TS__PromiseAny(this: void, values: Iterable>): Promise { const rejections: string[] = []; const pending: Array> = []; @@ -31,23 +30,25 @@ async function __TS__PromiseAny(this: void, values: Array> return new Promise((resolve, reject) => { for (const promise of pending) { - promise.then(data => { - // If any pending promise resolves, resolve this promise with its data - resolve(data); - }, - reason => { - // If a pending promise rejects, add its rejection to our rejection list - rejections.push(reason); - numResolved++; - if (numResolved === pending.length) { - // If there are no more pending promises, reject with the list of rejections - reject({ - name: "AggregateError", - message: "All Promises rejected", - errors: rejections - }) + promise.then( + data => { + // If any pending promise resolves, resolve this promise with its data + resolve(data); + }, + reason => { + // If a pending promise rejects, add its rejection to our rejection list + rejections.push(reason); + numResolved++; + if (numResolved === pending.length) { + // If there are no more pending promises, reject with the list of rejections + reject({ + name: "AggregateError", + message: "All Promises rejected", + errors: rejections, + }); + } } - }) + ); } - }) + }); } diff --git a/src/transformation/builtins/promise.ts b/src/transformation/builtins/promise.ts index b8d80253d..66992875f 100644 --- a/src/transformation/builtins/promise.ts +++ b/src/transformation/builtins/promise.ts @@ -28,33 +28,27 @@ export function transformPromiseConstructorCall( switch (expressionName) { case "all": return transformLuaLibFunction(context, LuaLibFeature.PromiseAll, node, ...params); + case "allSettled": + return transformLuaLibFunction(context, LuaLibFeature.PromiseAllSettled, node, ...params); case "any": return transformLuaLibFunction(context, LuaLibFeature.PromiseAny, node, ...params); case "race": return transformLuaLibFunction(context, LuaLibFeature.PromiseRace, node, ...params); case "resolve": importLuaLibFeature(context, LuaLibFeature.Promise); - return lua.createCallExpression( - lua.createTableIndexExpression( - lua.createIdentifier("__TS__Promise"), - lua.createStringLiteral("resolve"), - expression - ), - params, - node - ); + return lua.createCallExpression(createStaticPromiseFunctionAccessor("resolve", expression), params, node); case "reject": importLuaLibFeature(context, LuaLibFeature.Promise); - return lua.createCallExpression( - lua.createTableIndexExpression( - lua.createIdentifier("__TS__Promise"), - lua.createStringLiteral("reject"), - expression - ), - params, - node - ); + return lua.createCallExpression(createStaticPromiseFunctionAccessor("reject", expression), params, node); default: context.diagnostics.push(unsupportedProperty(expression.name, "Promise", expressionName)); } } + +function createStaticPromiseFunctionAccessor(functionName: string, node: ts.Node) { + return lua.createTableIndexExpression( + lua.createIdentifier("__TS__Promise"), + lua.createStringLiteral(functionName), + node + ); +} diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index cdec13218..6b13f94a7 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -548,7 +548,154 @@ describe("Promise.all", () => { }); }); -describe("Promise.allSettled", () => {}); +describe("Promise.allSettled", () => { + test("resolves once all arguments are resolved", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + + const promise = Promise.allSettled([promise1, promise2, promise3]); + promise.then(([result1, result2, result3]) => { + log(result1, result2, result3); + }); + + resolve3("promise 3 result!"); + resolve1("promise 1 result!"); + resolve2("promise 2 result!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { status: "fulfilled", value: "promise 1 result!" }, + { status: "fulfilled", value: "promise 2 result!" }, + { status: "fulfilled", value: "promise 3 result!" }, + ]); + }); + + test("resolves once all arguments are rejected", () => { + util.testFunction` + const { promise: promise1, reject: reject1 } = defer(); + const { promise: promise2, reject: reject2 } = defer(); + const { promise: promise3, reject: reject3 } = defer(); + + const promise = Promise.allSettled([promise1, promise2, promise3]); + promise.then(([result1, result2, result3]) => { + log(result1, result2, result3); + }); + + reject2("promise 2 rejected!"); + reject1("promise 1 rejected!"); + reject3("promise 3 rejected!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { status: "rejected", reason: "promise 1 rejected!" }, + { status: "rejected", reason: "promise 2 rejected!" }, + { status: "rejected", reason: "promise 3 rejected!" }, + ]); + }); + + test("resolves once all arguments are rejected or resolved", () => { + util.testFunction` + const { promise: promise1, reject: reject1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + const { promise: promise3, reject: reject3 } = defer(); + + const promise = Promise.allSettled([promise1, promise2, promise3]); + promise.then(([result1, result2, result3]) => { + log(result1, result2, result3); + }); + + resolve2("promise 2 resolved!"); + reject1("promise 1 rejected!"); + reject3("promise 3 rejected!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { status: "rejected", reason: "promise 1 rejected!" }, + { status: "fulfilled", value: "promise 2 resolved!" }, + { status: "rejected", reason: "promise 3 rejected!" }, + ]); + }); + + test("handles already resolved promises", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const returnedPromise = Promise.allSettled([Promise.resolve("already resolved"), promise]); + returnedPromise.then(([result1, result2]) => { + log(result1, result2); + }); + + resolve("promise resolved!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { status: "fulfilled", value: "already resolved" }, + { status: "fulfilled", value: "promise resolved!" }, + ]); + }); + + test("handles already rejected promises", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const returnedPromise = Promise.allSettled([Promise.reject("already rejected"), promise]); + returnedPromise.then(([result1, result2]) => { + log(result1, result2); + }); + + resolve("promise resolved!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { status: "rejected", reason: "already rejected" }, + { status: "fulfilled", value: "promise resolved!" }, + ]); + }); + + test("handles literal arguments", () => { + util.testFunction` + const { promise, resolve } = defer(); + + const returnedPromise = Promise.allSettled(["my literal", promise]); + returnedPromise.then(([result1, result2]) => { + log(result1, result2); + }); + + resolve("promise resolved!"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + { status: "fulfilled", value: "my literal" }, + { status: "fulfilled", value: "promise resolved!" }, + ]); + }); + + test("returns resolved promise for empty argument list", () => { + util.testFunction` + const { state, value } = Promise.allSettled([]) as any; + return { state, value }; + ` + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: [], + }); + }); +}); describe("Promise.any", () => { test("resolves once first promise resolves", () => { @@ -752,9 +899,9 @@ describe("Promise.race", () => { const { state } = Promise.race([]) as any; return { state }; ` - .setTsHeader(promiseTestLib) - .expectToEqual({ - state: 0, // __TS__PromiseState.Pending - }); - }) + .setTsHeader(promiseTestLib) + .expectToEqual({ + state: 0, // __TS__PromiseState.Pending + }); + }); }); From 8f800b918ee75c39156e8ae9abf4fcb2d93bf1f5 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sat, 17 Jul 2021 16:14:07 +0200 Subject: [PATCH 09/28] fix prettier --- src/lualib/declarations/global.d.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lualib/declarations/global.d.ts b/src/lualib/declarations/global.d.ts index c176b6231..6600dc2f7 100644 --- a/src/lualib/declarations/global.d.ts +++ b/src/lualib/declarations/global.d.ts @@ -29,5 +29,7 @@ declare function unpack(list: T[], i?: number, j?: number): T[]; declare function select(index: number, ...args: T[]): T; declare function select(index: "#", ...args: T[]): number; -declare function pairs(t: LuaTable): LuaIterable, Record>; +declare function pairs( + t: LuaTable +): LuaIterable, Record>; declare function ipairs(t: Record): LuaIterable, Record>; From 47f179ee1f1a7231c7d94a659e58f4c35b7ef3ca Mon Sep 17 00:00:00 2001 From: Perryvw Date: Tue, 20 Jul 2021 22:03:55 +0200 Subject: [PATCH 10/28] Add promise example usage test --- test/unit/builtins/promise.spec.ts | 55 ++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index 6b13f94a7..ddf9893ae 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -439,6 +439,59 @@ test("promise then onRejected throws", () => { .expectToEqual(["promise2 rejected with: fulfill exception from onReject!"]); }); +test("example: asynchronous web request", () => { + const testHarness = ` + interface UserData { name: string, age: number} + const requests = new Map void>(); + function getUserData(id: number, callback: (userData: UserData) => void) { + requests.set(id, callback); + } + function emulateRequestReturn(id: number, data: UserData) { + requests.get(id)!(data); + } + `; + + util.testFunction` + // Wrap function getUserData(id: number, callback: (userData: UserData) => void) into a Promise + async function getUserDataAsync(id: number): Promise { + return new Promise(resolve => { + getUserData(id, resolve); + }); + } + + const user1DataPromise = getUserDataAsync(1); + const user2DataPromise = getUserDataAsync(2); + + user1DataPromise.then(() => log("received data for user 1")); + user2DataPromise.then(() => log("received data for user 2")); + + const allDataPromise = Promise.all([user1DataPromise, user2DataPromise]); + + allDataPromise.then(([user1data, user2data]) => { + log("all requests completed", user1data, user2data); + }); + + emulateRequestReturn(2, { name: "bar", age: 42 }); + emulateRequestReturn(1, { name: "foo", age: 35 }); + + return allLogs; + ` + .setTsHeader(testHarness + promiseTestLib) + .expectToEqual([ + "received data for user 2", + "received data for user 1", + "all requests completed", + { + name: "foo", + age: 35, + }, + { + name: "bar", + age: 42, + }, + ]); +}); + describe("Promise.all", () => { test("resolves once all arguments are resolved", () => { util.testFunction` @@ -521,7 +574,6 @@ describe("Promise.all", () => { .expectToEqual([42, "promise 1 result!", "foo"]); }); - // NOTE: deviation from spec! test("returns already-resolved promise if no pending promises in arguments", () => { util.testFunction` const { state, value } = Promise.all([42, Promise.resolve("already resolved!"), "foo"]) as any; @@ -534,7 +586,6 @@ describe("Promise.all", () => { }); }); - // NOTE: deviation from spec! test("returns already-rejected promise if already rejected promise in arguments", () => { util.testFunction` const { state, rejectionReason } = Promise.all([42, Promise.reject("already rejected!")]) as any; From 5931c3add9f59bb8099f65634873a1bf8c3ff93e Mon Sep 17 00:00:00 2001 From: Perryvw Date: Tue, 20 Jul 2021 22:28:17 +0200 Subject: [PATCH 11/28] Added missing lualib dependencies for PromiseConstructor functions --- src/LuaLib.ts | 16 ++++++++++++++++ src/lualib/PromiseRace.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 9239c5c71..91ce5b68a 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -111,6 +111,22 @@ const luaLibDependencies: Partial> = { ObjectDefineProperty: [LuaLibFeature.CloneDescriptor, LuaLibFeature.SetDescriptor], ObjectFromEntries: [LuaLibFeature.Iterator, LuaLibFeature.Symbol], Promise: [LuaLibFeature.ArrayPush, LuaLibFeature.Class, LuaLibFeature.FunctionBind, LuaLibFeature.InstanceOf], + PromiseAll: [LuaLibFeature.InstanceOf, LuaLibFeature.New, LuaLibFeature.Promise, LuaLibFeature.Iterator], + PromiseAllSettled: [LuaLibFeature.InstanceOf, LuaLibFeature.New, LuaLibFeature.Promise, LuaLibFeature.Iterator], + PromiseAny: [ + LuaLibFeature.ArrayPush, + LuaLibFeature.InstanceOf, + LuaLibFeature.New, + LuaLibFeature.Promise, + LuaLibFeature.Iterator, + ], + PromiseRace: [ + LuaLibFeature.ArrayPush, + LuaLibFeature.InstanceOf, + LuaLibFeature.New, + LuaLibFeature.Promise, + LuaLibFeature.Iterator, + ], Map: [LuaLibFeature.InstanceOf, LuaLibFeature.Iterator, LuaLibFeature.Symbol, LuaLibFeature.Class], Set: [LuaLibFeature.InstanceOf, LuaLibFeature.Iterator, LuaLibFeature.Symbol, LuaLibFeature.Class], WeakMap: [LuaLibFeature.InstanceOf, LuaLibFeature.Iterator, LuaLibFeature.Symbol, LuaLibFeature.Class], diff --git a/src/lualib/PromiseRace.ts b/src/lualib/PromiseRace.ts index 68c838c63..be587e1b2 100644 --- a/src/lualib/PromiseRace.ts +++ b/src/lualib/PromiseRace.ts @@ -1,5 +1,5 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race -async function __TS__PromiseRace(this: void, values: Array>): Promise { +async function __TS__PromiseRace(this: void, values: Iterable>): Promise { const pending: Array> = []; for (const value of values) { From 95c5fe3fdf8949da0fd7de4d1c9572e880104e11 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Thu, 22 Jul 2021 20:56:14 +0200 Subject: [PATCH 12/28] Immediately call then/catch/finally callbacks on promises that are already resolved --- src/lualib/Promise.ts | 114 +++++++++++++++++++---------- test/unit/builtins/promise.spec.ts | 50 +++++++++++++ 2 files changed, 124 insertions(+), 40 deletions(-) diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index 8b759fdcf..37f55bca1 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -38,6 +38,7 @@ class __TS__Promise implements Promise { public [Symbol.toStringTag]: string; // Required to implement interface, no output Lua + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve public static resolve(this: void, data: TData): Promise { // Create and return a promise instance that is already resolved const promise = new __TS__Promise(() => {}); @@ -46,6 +47,7 @@ class __TS__Promise implements Promise { return promise; } + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/reject public static reject(this: void, reason: any): Promise { // Create and return a promise instance that is already rejected const promise = new __TS__Promise(() => {}); @@ -58,70 +60,59 @@ class __TS__Promise implements Promise { executor(this.resolve.bind(this), this.reject.bind(this)); } + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then public then( onfulfilled?: FulfillCallback, onrejected?: RejectCallback ): Promise { const { promise, resolve, reject } = __TS__PromiseDeferred(); - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then#return_value - function returnedPromiseHandler(f: FulfillCallback | RejectCallback) { - return value => { - try { - handleCallbackData(f(value)); - } catch (e) { - // If a handler function throws an error, the promise returned by then gets rejected with the thrown error as its value - reject(e); - } - }; - } - function handleCallbackData(data: TResult | PromiseLike) { - if (__TS__IsPromiseLike(data)) { - const nextpromise = data as __TS__Promise; - if (nextpromise.state === __TS__PromiseState.Fulfilled) { - // If a handler function returns an already fulfilled promise, - // the promise returned by then gets fulfilled with that promise's value - resolve(nextpromise.value); - } else if (nextpromise.state === __TS__PromiseState.Rejected) { - // If a handler function returns an already rejected promise, - // the promise returned by then gets fulfilled with that promise's value - reject(nextpromise.rejectionReason); - } else { - // If a handler function returns another pending promise object, the resolution/rejection - // of the promise returned by then will be subsequent to the resolution/rejection of - // the promise returned by the handler. - data.then(resolve, reject); - } - } else { - // If a handler returns a value, the promise returned by then gets resolved with the returned value as its value - // If a handler doesn't return anything, the promise returned by then gets resolved with undefined - resolve(data); + if (onfulfilled) { + const internalCallback = + this.fulfilledCallbacks.length === 0 + ? this.createPromiseResolvingCallback(onfulfilled, resolve, reject) + : onfulfilled; + this.fulfilledCallbacks.push(internalCallback); + + if (this.state === __TS__PromiseState.Fulfilled) { + // If promise already resolved, immediately call callback + internalCallback(this.value); } } - if (onfulfilled) { - this.fulfilledCallbacks.push( - this.fulfilledCallbacks.length === 0 ? returnedPromiseHandler(onfulfilled) : onfulfilled - ); - } if (onrejected) { - this.rejectedCallbacks.push( - this.rejectedCallbacks.length === 0 ? returnedPromiseHandler(onrejected) : onrejected - ); + const internalCallback = + this.rejectedCallbacks.length === 0 + ? this.createPromiseResolvingCallback(onrejected, resolve, reject) + : onrejected; + this.rejectedCallbacks.push(internalCallback); + + if (this.state === __TS__PromiseState.Rejected) { + // If promise already rejected, immediately call callback + internalCallback(this.rejectionReason); + } } return promise; } + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch public catch(onrejected?: (reason: any) => TResult | PromiseLike): Promise { return this.then(undefined, onrejected); } + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/finally public finally(onfinally?: () => void): Promise { if (onfinally) { this.finallyCallbacks.push(onfinally); + + if (this.state !== __TS__PromiseState.Pending) { + // If promise already resolved or rejected, immediately fire finally callback + onfinally(); + } } return this; } private resolve(data: T): void { + // Resolve this promise, if it is still pending. This function is passed to the constructor function. if (this.state === __TS__PromiseState.Pending) { this.state = __TS__PromiseState.Fulfilled; this.value = data; @@ -136,6 +127,7 @@ class __TS__Promise implements Promise { } private reject(reason: any): void { + // Reject this promise, if it is still pending. This function is passed to the constructor function. if (this.state === __TS__PromiseState.Pending) { this.state = __TS__PromiseState.Rejected; this.rejectionReason = reason; @@ -148,4 +140,46 @@ class __TS__Promise implements Promise { } } } + + private createPromiseResolvingCallback( + f: FulfillCallback | RejectCallback, + resolve: FulfillCallback, + reject: RejectCallback + ) { + return value => { + try { + this.handleCallbackData(f(value), resolve, reject); + } catch (e) { + // If a handler function throws an error, the promise returned by then gets rejected with the thrown error as its value + reject(e); + } + }; + } + private handleCallbackData( + data: TResult | PromiseLike, + resolve: FulfillCallback, + reject: RejectCallback + ) { + if (__TS__IsPromiseLike(data)) { + const nextpromise = data as __TS__Promise; + if (nextpromise.state === __TS__PromiseState.Fulfilled) { + // If a handler function returns an already fulfilled promise, + // the promise returned by then gets fulfilled with that promise's value + resolve(nextpromise.value); + } else if (nextpromise.state === __TS__PromiseState.Rejected) { + // If a handler function returns an already rejected promise, + // the promise returned by then gets fulfilled with that promise's value + reject(nextpromise.rejectionReason); + } else { + // If a handler function returns another pending promise object, the resolution/rejection + // of the promise returned by then will be subsequent to the resolution/rejection of + // the promise returned by the handler. + data.then(resolve, reject); + } + } else { + // If a handler returns a value, the promise returned by then gets resolved with the returned value as its value + // If a handler doesn't return anything, the promise returned by then gets resolved with undefined + resolve(data); + } + } } diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index ddf9893ae..46b4df439 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -439,6 +439,56 @@ test("promise then onRejected throws", () => { .expectToEqual(["promise2 rejected with: fulfill exception from onReject!"]); }); +test("then on resolved promise immediately calls callback", () => { + util.testFunction` + Promise.resolve(42).then(data => { log(data); }); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([42]); +}); + +test("then on rejected promise immediately calls callback", () => { + util.testFunction` + Promise.reject("already rejected").then(data => { log("resolved", data); }, reason => { log("rejected", reason); }); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["rejected", "already rejected"]); +}); + +test("catch on rejected promise immediately calls callback", () => { + util.testFunction` + Promise.reject("already rejected").catch(reason => { log(reason); }); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["already rejected"]); +}); + +test("finally on resolved promise immediately calls callback", () => { + util.testFunction` + Promise.resolve(42).finally(() => { log("finally"); }); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["finally"]); +}); + +test("finally on rejected promise immediately calls callback", () => { + util.testFunction` + Promise.reject("already rejected").finally(() => { log("finally"); }); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["finally"]); +}); + test("example: asynchronous web request", () => { const testHarness = ` interface UserData { name: string, age: number} From cc74c3aaa9b62a076f1767e0a3cb3d0afa102aa4 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Fri, 30 Jul 2021 21:30:44 +0200 Subject: [PATCH 13/28] Transform all references to Promise to __TS__Promise --- src/transformation/builtins/promise.ts | 15 +++++++-------- src/transformation/visitors/class/new.ts | 6 ------ src/transformation/visitors/identifier.ts | 7 +++++++ test/unit/builtins/promise.spec.ts | 4 ++++ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/transformation/builtins/promise.ts b/src/transformation/builtins/promise.ts index 66992875f..cd30aa718 100644 --- a/src/transformation/builtins/promise.ts +++ b/src/transformation/builtins/promise.ts @@ -3,17 +3,16 @@ import * as lua from "../../LuaAST"; import { TransformationContext } from "../context"; import { unsupportedProperty } from "../utils/diagnostics"; import { importLuaLibFeature, LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; +import { isStandardLibraryType } from "../utils/typescript"; import { PropertyCallExpression, transformArguments } from "../visitors/call"; -export function transformNewPromise( - context: TransformationContext, - node: ts.NewExpression, - args: lua.Expression[] -): lua.Expression { - importLuaLibFeature(context, LuaLibFeature.Promise); +export function isPromiseClass(context: TransformationContext, node: ts.Identifier) { + const type = context.checker.getTypeAtLocation(node); + return isStandardLibraryType(context, type, undefined) && node.text === "Promise"; +} - const name = lua.createIdentifier("__TS__Promise", node.expression); - return transformLuaLibFunction(context, LuaLibFeature.New, node, name, ...args); +export function createPromiseIdentifier(original: ts.Node) { + return lua.createIdentifier("__TS__Promise", original); } export function transformPromiseConstructorCall( diff --git a/src/transformation/visitors/class/new.ts b/src/transformation/visitors/class/new.ts index 1f1e6414f..97e1c3407 100644 --- a/src/transformation/visitors/class/new.ts +++ b/src/transformation/visitors/class/new.ts @@ -1,11 +1,9 @@ import * as ts from "typescript"; import * as lua from "../../../LuaAST"; -import { transformNewPromise } from "../../builtins/promise"; import { FunctionVisitor, TransformationContext } from "../../context"; import { AnnotationKind, getTypeAnnotations } from "../../utils/annotations"; import { annotationInvalidArgumentCount, annotationRemoved } from "../../utils/diagnostics"; import { importLuaLibFeature, LuaLibFeature, transformLuaLibFunction } from "../../utils/lualib"; -import { isStandardLibraryType } from "../../utils/typescript"; import { transformArguments } from "../call"; import { isTableNewCall } from "../language-extensions/table"; @@ -69,10 +67,6 @@ export const transformNewExpression: FunctionVisitor = (node, checkForLuaLibType(context, type); - if (isStandardLibraryType(context, type, "Promise")) { - return transformNewPromise(context, node, params); - } - const name = context.transformExpression(node.expression); const customConstructorAnnotation = annotations.get(AnnotationKind.CustomConstructor); diff --git a/src/transformation/visitors/identifier.ts b/src/transformation/visitors/identifier.ts index 17706848c..3d39b16a4 100644 --- a/src/transformation/visitors/identifier.ts +++ b/src/transformation/visitors/identifier.ts @@ -1,6 +1,7 @@ import * as ts from "typescript"; import * as lua from "../../LuaAST"; import { transformBuiltinIdentifierExpression } from "../builtins"; +import { createPromiseIdentifier, isPromiseClass } from "../builtins/promise"; import { FunctionVisitor, TransformationContext } from "../context"; import { AnnotationKind, isForRangeType } from "../utils/annotations"; import { @@ -12,6 +13,7 @@ import { annotationRemoved, } from "../utils/diagnostics"; import { createExportedIdentifier, getSymbolExportScope } from "../utils/export"; +import { importLuaLibFeature, LuaLibFeature } from "../utils/lualib"; import { createSafeName, hasUnsafeIdentifierName } from "../utils/safe-names"; import { getIdentifierSymbolId } from "../utils/symbols"; import { isMultiFunctionNode } from "./language-extensions/multi"; @@ -48,6 +50,11 @@ export function transformIdentifier(context: TransformationContext, identifier: context.diagnostics.push(annotationRemoved(identifier, AnnotationKind.ForRange)); } + if (isPromiseClass(context, identifier)) { + importLuaLibFeature(context, LuaLibFeature.Promise); + return createPromiseIdentifier(identifier); + } + const text = hasUnsafeIdentifierName(context, identifier) ? createSafeName(identifier.text) : identifier.text; const symbolId = getIdentifierSymbolId(context, identifier); diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index 46b4df439..a43e62952 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -542,6 +542,10 @@ test("example: asynchronous web request", () => { ]); }); +test("promise is instanceof promise", () => { + util.testExpression`Promise.resolve(4) instanceof Promise`.expectToMatchJsResult(); +}); + describe("Promise.all", () => { test("resolves once all arguments are resolved", () => { util.testFunction` From 3fad722788e3bb4e8c1c5831736cda0a4e99f02a Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sat, 14 Aug 2021 12:27:27 +0200 Subject: [PATCH 14/28] PR feedback --- src/lualib/Promise.ts | 35 +++++++++++++++++------------- src/lualib/PromiseAll.ts | 18 +++++++-------- src/lualib/PromiseAllSettled.ts | 18 +++++++-------- src/lualib/PromiseAny.ts | 18 +++++++-------- src/lualib/PromiseRace.ts | 18 +++++++-------- test/unit/builtins/promise.spec.ts | 10 +++++++++ 6 files changed, 66 insertions(+), 51 deletions(-) diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index 37f55bca1..4cef01e5e 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -57,21 +57,26 @@ class __TS__Promise implements Promise { } constructor(executor: (resolve: (data: T) => void, reject: (reason: any) => void) => void) { - executor(this.resolve.bind(this), this.reject.bind(this)); + try { + executor(this.resolve.bind(this), this.reject.bind(this)); + } catch (e) { + // When a promise executor throws, the promise should be rejected with the thrown object as reason + this.reject(e); + } } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then public then( - onfulfilled?: FulfillCallback, - onrejected?: RejectCallback + onFulfilled?: FulfillCallback, + onRejected?: RejectCallback ): Promise { const { promise, resolve, reject } = __TS__PromiseDeferred(); - if (onfulfilled) { + if (onFulfilled) { const internalCallback = this.fulfilledCallbacks.length === 0 - ? this.createPromiseResolvingCallback(onfulfilled, resolve, reject) - : onfulfilled; + ? this.createPromiseResolvingCallback(onFulfilled, resolve, reject) + : onFulfilled; this.fulfilledCallbacks.push(internalCallback); if (this.state === __TS__PromiseState.Fulfilled) { @@ -80,11 +85,11 @@ class __TS__Promise implements Promise { } } - if (onrejected) { + if (onRejected) { const internalCallback = this.rejectedCallbacks.length === 0 - ? this.createPromiseResolvingCallback(onrejected, resolve, reject) - : onrejected; + ? this.createPromiseResolvingCallback(onRejected, resolve, reject) + : onRejected; this.rejectedCallbacks.push(internalCallback); if (this.state === __TS__PromiseState.Rejected) { @@ -95,17 +100,17 @@ class __TS__Promise implements Promise { return promise; } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch - public catch(onrejected?: (reason: any) => TResult | PromiseLike): Promise { - return this.then(undefined, onrejected); + public catch(onRejected?: (reason: any) => TResult | PromiseLike): Promise { + return this.then(undefined, onRejected); } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/finally - public finally(onfinally?: () => void): Promise { - if (onfinally) { - this.finallyCallbacks.push(onfinally); + public finally(onFinally?: () => void): Promise { + if (onFinally) { + this.finallyCallbacks.push(onFinally); if (this.state !== __TS__PromiseState.Pending) { // If promise already resolved or rejected, immediately fire finally callback - onfinally(); + onFinally(); } } return this; diff --git a/src/lualib/PromiseAll.ts b/src/lualib/PromiseAll.ts index 17181e30b..1b89f3c99 100644 --- a/src/lualib/PromiseAll.ts +++ b/src/lualib/PromiseAll.ts @@ -1,27 +1,27 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all -async function __TS__PromiseAll(this: void, values: Iterable>): Promise { +async function __TS__PromiseAll(this: void, iterable: Iterable>): Promise { const results: T[] = []; const toResolve = new LuaTable>(); let numToResolve = 0; let i = 0; - for (const value of values) { - if (value instanceof __TS__Promise) { - if (value.state === __TS__PromiseState.Fulfilled) { + for (const item of iterable) { + if (item instanceof __TS__Promise) { + if (item.state === __TS__PromiseState.Fulfilled) { // If value is a resolved promise, add its value to our results array - results[i] = value.value; - } else if (value.state === __TS__PromiseState.Rejected) { + results[i] = item.value; + } else if (item.state === __TS__PromiseState.Rejected) { // If value is a rejected promise, return a rejected promise with the rejection reason - return Promise.reject(value.rejectionReason); + return Promise.reject(item.rejectionReason); } else { // If value is a pending promise, add it to the list of pending promises numToResolve++; - toResolve.set(i, value); + toResolve.set(i, item); } } else { // If value is not a promise, add it to the results array - results[i] = value as T; + results[i] = item as T; } i++; } diff --git a/src/lualib/PromiseAllSettled.ts b/src/lualib/PromiseAllSettled.ts index 4b33c15ed..df74cc5cb 100644 --- a/src/lualib/PromiseAllSettled.ts +++ b/src/lualib/PromiseAllSettled.ts @@ -1,7 +1,7 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled async function __TS__PromiseAllSettled( this: void, - values: Iterable + iterable: Iterable ): Promise ? U : T>>> { const results: Array ? U : T>> = []; @@ -9,22 +9,22 @@ async function __TS__PromiseAllSettled( let numToResolve = 0; let i = 0; - for (const value of values) { - if (value instanceof __TS__Promise) { - if (value.state === __TS__PromiseState.Fulfilled) { + for (const item of iterable) { + if (item instanceof __TS__Promise) { + if (item.state === __TS__PromiseState.Fulfilled) { // If value is a resolved promise, add a fulfilled PromiseSettledResult - results[i] = { status: "fulfilled", value: value.value }; - } else if (value.state === __TS__PromiseState.Rejected) { + results[i] = { status: "fulfilled", value: item.value }; + } else if (item.state === __TS__PromiseState.Rejected) { // If value is a rejected promise, add a rejected PromiseSettledResult - results[i] = { status: "rejected", reason: value.rejectionReason }; + results[i] = { status: "rejected", reason: item.rejectionReason }; } else { // If value is a pending promise, add it to the list of pending promises numToResolve++; - toResolve.set(i, value); + toResolve.set(i, item); } } else { // If value is not a promise, add it to the results as fulfilled PromiseSettledResult - results[i] = { status: "fulfilled", value: value as any }; + results[i] = { status: "fulfilled", value: item as any }; } i++; } diff --git a/src/lualib/PromiseAny.ts b/src/lualib/PromiseAny.ts index 4780a7298..3eef8937a 100644 --- a/src/lualib/PromiseAny.ts +++ b/src/lualib/PromiseAny.ts @@ -1,23 +1,23 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any -async function __TS__PromiseAny(this: void, values: Iterable>): Promise { +async function __TS__PromiseAny(this: void, iterable: Iterable>): Promise { const rejections: string[] = []; const pending: Array> = []; - for (const value of values) { - if (value instanceof __TS__Promise) { - if (value.state === __TS__PromiseState.Fulfilled) { + for (const item of iterable) { + if (item instanceof __TS__Promise) { + if (item.state === __TS__PromiseState.Fulfilled) { // If value is a resolved promise, return a new resolved promise with its value - return Promise.resolve(value.value); - } else if (value.state === __TS__PromiseState.Rejected) { + return Promise.resolve(item.value); + } else if (item.state === __TS__PromiseState.Rejected) { // If value is a rejected promise, add its value to our list of rejections - rejections.push(value.rejectionReason); + rejections.push(item.rejectionReason); } else { // If value is a pending promise, add it to the list of pending promises - pending.push(value); + pending.push(item); } } else { // If value is not a promise, return a resolved promise with it as its value - return Promise.resolve(value); + return Promise.resolve(item); } } diff --git a/src/lualib/PromiseRace.ts b/src/lualib/PromiseRace.ts index be587e1b2..51efc5908 100644 --- a/src/lualib/PromiseRace.ts +++ b/src/lualib/PromiseRace.ts @@ -1,22 +1,22 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race -async function __TS__PromiseRace(this: void, values: Iterable>): Promise { +async function __TS__PromiseRace(this: void, iterable: Iterable>): Promise { const pending: Array> = []; - for (const value of values) { - if (value instanceof __TS__Promise) { - if (value.state === __TS__PromiseState.Fulfilled) { + for (const item of iterable) { + if (item instanceof __TS__Promise) { + if (item.state === __TS__PromiseState.Fulfilled) { // If value is a fulfilled promise, return a resolved promise with its value - return Promise.resolve(value.value); - } else if (value.state === __TS__PromiseState.Rejected) { + return Promise.resolve(item.value); + } else if (item.state === __TS__PromiseState.Rejected) { // If value is a rejected promise, return rejected promise with its value - return Promise.reject(value.rejectionReason); + return Promise.reject(item.rejectionReason); } else { // If value is a pending promise, add it to the list of pending promises - pending.push(value); + pending.push(item); } } else { // If value is not a promise, return a promise resolved with it as its value - return Promise.resolve(value); + return Promise.resolve(item); } } diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index a43e62952..99fb44a3f 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -40,6 +40,16 @@ test("can create rejected promise", () => { }); }); +test("promise constructor executor throwing rejects promise", () => { + util.testFunction` + const { state, rejectionReason } = new Promise(() => { throw "executor exception"; }) as any; + return { state, rejectionReason }; + `.expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: "executor exception", + }); +}); + test("promise can be resolved", () => { util.testFunction` const { promise, resolve } = defer(); From 20660f079a2cc2ec8f1e5ecaf454964aaaa439ca Mon Sep 17 00:00:00 2001 From: Perryvw Date: Tue, 17 Aug 2021 17:07:30 +0200 Subject: [PATCH 15/28] Removed incorrect asyncs --- src/lualib/PromiseAll.ts | 3 ++- src/lualib/PromiseAllSettled.ts | 3 ++- src/lualib/PromiseAny.ts | 3 ++- src/lualib/PromiseRace.ts | 3 ++- test/unit/builtins/promise.spec.ts | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lualib/PromiseAll.ts b/src/lualib/PromiseAll.ts index 1b89f3c99..e0d940977 100644 --- a/src/lualib/PromiseAll.ts +++ b/src/lualib/PromiseAll.ts @@ -1,5 +1,6 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all -async function __TS__PromiseAll(this: void, iterable: Iterable>): Promise { +// eslint-disable-next-line @typescript-eslint/promise-function-async +function __TS__PromiseAll(this: void, iterable: Iterable>): Promise { const results: T[] = []; const toResolve = new LuaTable>(); diff --git a/src/lualib/PromiseAllSettled.ts b/src/lualib/PromiseAllSettled.ts index df74cc5cb..165e226f4 100644 --- a/src/lualib/PromiseAllSettled.ts +++ b/src/lualib/PromiseAllSettled.ts @@ -1,5 +1,6 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled -async function __TS__PromiseAllSettled( +// eslint-disable-next-line @typescript-eslint/promise-function-async +function __TS__PromiseAllSettled( this: void, iterable: Iterable ): Promise ? U : T>>> { diff --git a/src/lualib/PromiseAny.ts b/src/lualib/PromiseAny.ts index 3eef8937a..eb70bf12f 100644 --- a/src/lualib/PromiseAny.ts +++ b/src/lualib/PromiseAny.ts @@ -1,5 +1,6 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any -async function __TS__PromiseAny(this: void, iterable: Iterable>): Promise { +// eslint-disable-next-line @typescript-eslint/promise-function-async +function __TS__PromiseAny(this: void, iterable: Iterable>): Promise { const rejections: string[] = []; const pending: Array> = []; diff --git a/src/lualib/PromiseRace.ts b/src/lualib/PromiseRace.ts index 51efc5908..3b3a1fb7b 100644 --- a/src/lualib/PromiseRace.ts +++ b/src/lualib/PromiseRace.ts @@ -1,5 +1,6 @@ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race -async function __TS__PromiseRace(this: void, iterable: Iterable>): Promise { +// eslint-disable-next-line @typescript-eslint/promise-function-async +function __TS__PromiseRace(this: void, iterable: Iterable>): Promise { const pending: Array> = []; for (const item of iterable) { diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index 99fb44a3f..73be3e40c 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -513,7 +513,7 @@ test("example: asynchronous web request", () => { util.testFunction` // Wrap function getUserData(id: number, callback: (userData: UserData) => void) into a Promise - async function getUserDataAsync(id: number): Promise { + function getUserDataAsync(id: number): Promise { return new Promise(resolve => { getUserData(id, resolve); }); From 1b7442910d9f97faaaa723d1258ad895518d7afe Mon Sep 17 00:00:00 2001 From: Perryvw Date: Tue, 17 Aug 2021 21:35:39 +0200 Subject: [PATCH 16/28] Add test for direct chaining --- test/unit/builtins/promise.spec.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index 73be3e40c..ea7d91432 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -499,6 +499,36 @@ test("finally on rejected promise immediately calls callback", () => { .expectToEqual(["finally"]); }); +test("direct chaining", () => { + util.testFunction` + const { promise, resolve } = defer(); + + promise + .then(data => { + log("resolving then1", data); + return "then 1 data"; + }).then(data => { + log("resolving then2", data); + throw "test throw"; + }).catch(reason => { + log("handling catch", reason); + }); + + resolve("test data"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + "resolving then1", + "test data", + "resolving then2", + "then 1 data", + "handling catch", + "test throw", + ]); +}); + test("example: asynchronous web request", () => { const testHarness = ` interface UserData { name: string, age: number} From 780abbada1e26fb33b355b1b44023f133fbdc016 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Tue, 17 Aug 2021 21:35:55 +0200 Subject: [PATCH 17/28] Add test for finally and correct wrong behavior it caught --- src/lualib/Promise.ts | 14 +++--- test/unit/builtins/promise.spec.ts | 69 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/lualib/Promise.ts b/src/lualib/Promise.ts index 4cef01e5e..c8fdea117 100644 --- a/src/lualib/Promise.ts +++ b/src/lualib/Promise.ts @@ -73,23 +73,20 @@ class __TS__Promise implements Promise { const { promise, resolve, reject } = __TS__PromiseDeferred(); if (onFulfilled) { - const internalCallback = - this.fulfilledCallbacks.length === 0 - ? this.createPromiseResolvingCallback(onFulfilled, resolve, reject) - : onFulfilled; + const internalCallback = this.createPromiseResolvingCallback(onFulfilled, resolve, reject); this.fulfilledCallbacks.push(internalCallback); if (this.state === __TS__PromiseState.Fulfilled) { // If promise already resolved, immediately call callback internalCallback(this.value); } + } else { + // We always want to resolve our child promise if this promise is resolved, even if we have no handler + this.fulfilledCallbacks.push(() => resolve(undefined)); } if (onRejected) { - const internalCallback = - this.rejectedCallbacks.length === 0 - ? this.createPromiseResolvingCallback(onRejected, resolve, reject) - : onRejected; + const internalCallback = this.createPromiseResolvingCallback(onRejected, resolve, reject); this.rejectedCallbacks.push(internalCallback); if (this.state === __TS__PromiseState.Rejected) { @@ -97,6 +94,7 @@ class __TS__Promise implements Promise { internalCallback(this.rejectionReason); } } + return promise; } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index ea7d91432..5a7f357b7 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -529,6 +529,75 @@ test("direct chaining", () => { ]); }); +describe("finally behaves same as then/catch", () => { + const thenCatchPromise = ` + const { promise, resolve, reject } = defer(); + promise + .then(data => { + log("do something", data); + log("final code"); + }) + .catch(reason => { + log("handling error", data); + log("final code"); + }); + `; + + const finallyPromise = ` + const { promise, resolve, reject } = defer(); + promise + .then(data => { + log("do something", data); + }) + .catch(reason => { + log("handling error", reason); + }) + .finally(() => { + log("final code"); + }); + `; + + test("when resolving", () => { + const thenResult = util.testFunction` + ${thenCatchPromise} + resolve("test data"); + return allLogs; + ` + .setTsHeader(promiseTestLib) + .getLuaExecutionResult(); + + const finallyResult = util.testFunction` + ${finallyPromise} + resolve("test data"); + return allLogs; + ` + .setTsHeader(promiseTestLib) + .getLuaExecutionResult(); + + expect(finallyResult).toEqual(thenResult); + }); + + test("when rejecting", () => { + const thenResult = util.testFunction` + ${thenCatchPromise} + reject("test rejection reason"); + return allLogs; + ` + .setTsHeader(promiseTestLib) + .getLuaExecutionResult(); + + const finallyResult = util.testFunction` + ${finallyPromise} + reject("test rejection reason"); + return allLogs; + ` + .setTsHeader(promiseTestLib) + .getLuaExecutionResult(); + + expect(finallyResult).toEqual(thenResult); + }); +}); + test("example: asynchronous web request", () => { const testHarness = ` interface UserData { name: string, age: number} From c0e82eff491141182d03c90f110af077c2071da3 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Tue, 17 Aug 2021 21:44:41 +0200 Subject: [PATCH 18/28] Added test throwing in parallel and chained then onFulfilleds --- test/unit/builtins/promise.spec.ts | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/unit/builtins/promise.spec.ts b/test/unit/builtins/promise.spec.ts index 5a7f357b7..4a22f7514 100644 --- a/test/unit/builtins/promise.spec.ts +++ b/test/unit/builtins/promise.spec.ts @@ -469,6 +469,60 @@ test("then on rejected promise immediately calls callback", () => { .expectToEqual(["rejected", "already rejected"]); }); +test("second then throws", () => { + util.testFunction` + const { promise, resolve } = defer(); + + promise.then(data => { + // nothing + log("then1", data) + }); + + promise.then(data => { + log("then2", data) + throw "test throw"; + }).catch(err => { + // caught + log("rejected: ", err) + }); + + resolve("mydata"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["then1", "mydata", "then2", "mydata", "rejected: ", "test throw"]); +}); + +test("chained then throws", () => { + util.testFunction` + const { promise, resolve } = defer(); + + promise.then(data => { + // nothing + log("then1", data) + }).then(data => { + log("then2", data) + throw "test throw"; + }).catch(err => { + // caught + log("rejected: ", err) + }); + + resolve("mydata"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + "then1", + "mydata", + "then2", // Does not have data because first then returned undefined + "rejected: ", + "test throw", + ]); +}); + test("catch on rejected promise immediately calls callback", () => { util.testFunction` Promise.reject("already rejected").catch(reason => { log(reason); }); From cca69493da8b6cf06d4b833a4ce71f19875b8d37 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Tue, 17 Aug 2021 21:49:22 +0200 Subject: [PATCH 19/28] Fixed pull request link in ArrayIsArray lualib comment --- src/lualib/ArrayIsArray.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lualib/ArrayIsArray.ts b/src/lualib/ArrayIsArray.ts index d981ec0f2..417ae6e6e 100644 --- a/src/lualib/ArrayIsArray.ts +++ b/src/lualib/ArrayIsArray.ts @@ -2,6 +2,6 @@ declare type NextEmptyCheck = (this: void, table: any, index: undefined) => unkn function __TS__ArrayIsArray(this: void, value: any): value is any[] { // Workaround to determine if value is an array or not (fails in case of objects without keys) - // See discussion in: https://github.com/TypeScriptToLua/TypeScriptToLua/pull/7 + // See discussion in: https://github.com/TypeScriptToLua/TypeScriptToLua/pull/737 return type(value) === "table" && (1 in value || (next as NextEmptyCheck)(value, undefined) === undefined); } From e623271025e21d56a182ec3e16942bdf45556b1b Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sun, 25 Jul 2021 18:03:19 +0200 Subject: [PATCH 20/28] Initial async await --- src/LuaLib.ts | 1 + src/lualib/Await.ts | 51 +++++++ src/transformation/visitors/async-await.ts | 25 ++++ src/transformation/visitors/function.ts | 3 +- src/transformation/visitors/index.ts | 2 + test/unit/builtins/async-await.spec.ts | 157 +++++++++++++++++++++ 6 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/lualib/Await.ts create mode 100644 src/transformation/visitors/async-await.ts create mode 100644 test/unit/builtins/async-await.spec.ts diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 669c68941..78040921b 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -28,6 +28,7 @@ export enum LuaLibFeature { ArrayFlat = "ArrayFlat", ArrayFlatMap = "ArrayFlatMap", ArraySetLength = "ArraySetLength", + Await = "Await", Class = "Class", ClassExtends = "ClassExtends", CloneDescriptor = "CloneDescriptor", diff --git a/src/lualib/Await.ts b/src/lualib/Await.ts new file mode 100644 index 000000000..35a991a61 --- /dev/null +++ b/src/lualib/Await.ts @@ -0,0 +1,51 @@ +// The following is a translation of the TypeScript async awaiter which uses generators and yields. +// For Lua we use coroutines instead. +// +// Source: +// +// var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { +// function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } +// return new (P || (P = Promise))(function (resolve, reject) { +// function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } +// function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } +// function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } +// step((generator = generator.apply(thisArg, _arguments || [])).next()); +// }); +// }; +// + +// eslint-disable-next-line @typescript-eslint/promise-function-async +function __TS__AsyncAwaiter(this: void, generator: (this: void) => void) { + return new Promise((resolve, reject) => { + const asyncCoroutine = coroutine.create(generator); + + // eslint-disable-next-line @typescript-eslint/promise-function-async + function adopt(value: unknown) { + return value instanceof __TS__Promise ? value : Promise.resolve(value); + } + function fulfilled(value) { + try { + const [running, result] = coroutine.resume(asyncCoroutine, value); + step(running, result); + } catch (e) { + reject(e); + } + } + let lastData: unknown; + function step(running: boolean, result: unknown) { + if (!running) { + resolve(lastData); + } else { + // Not possible to determine if a running === true will be the last one, once it's false the data to return is lost, so save it. + lastData = result; + adopt(result).then(fulfilled, reason => reject(reason)); + } + } + const [running, result] = coroutine.resume(asyncCoroutine); + step(running, result); + }); +} + +function __TS__Await(this: void, thing: unknown) { + return coroutine.yield(thing); +} diff --git a/src/transformation/visitors/async-await.ts b/src/transformation/visitors/async-await.ts new file mode 100644 index 000000000..b51b7b47c --- /dev/null +++ b/src/transformation/visitors/async-await.ts @@ -0,0 +1,25 @@ +import * as ts from "typescript"; +import * as lua from "../../LuaAST"; +import { FunctionVisitor, TransformationContext } from "../context"; +import { importLuaLibFeature, LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; + +export const transformAwaitExpression: FunctionVisitor = (node, context) => { + const expression = context.transformExpression(node.expression); + return transformLuaLibFunction(context, LuaLibFeature.Await, node, expression); +}; + +export function isAsyncFunction(declaration: ts.FunctionLikeDeclaration): boolean { + return declaration.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false; +} + +export function wrapInAsyncAwaiter(context: TransformationContext, statements: lua.Statement[]): lua.Statement[] { + importLuaLibFeature(context, LuaLibFeature.Await); + + return [ + lua.createReturnStatement([ + lua.createCallExpression(lua.createIdentifier("__TS__AsyncAwaiter"), [ + lua.createFunctionExpression(lua.createBlock(statements)), + ]), + ]), + ]; +} diff --git a/src/transformation/visitors/function.ts b/src/transformation/visitors/function.ts index cb1d29bfd..77d764ac8 100644 --- a/src/transformation/visitors/function.ts +++ b/src/transformation/visitors/function.ts @@ -13,6 +13,7 @@ import { } from "../utils/lua-ast"; import { LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; import { peekScope, performHoisting, popScope, pushScope, Scope, ScopeType } from "../utils/scope"; +import { isAsyncFunction, wrapInAsyncAwaiter } from "./async-await"; import { transformIdentifier } from "./identifier"; import { transformExpressionBodyToReturnStatement } from "./return"; import { transformBindingPattern } from "./variable-declaration"; @@ -196,7 +197,7 @@ export function transformFunctionToExpression( node ); const functionExpression = lua.createFunctionExpression( - lua.createBlock(transformedBody), + lua.createBlock(isAsyncFunction(node) ? wrapInAsyncAwaiter(context, transformedBody) : transformedBody), paramNames, dotsLiteral, flags, diff --git a/src/transformation/visitors/index.ts b/src/transformation/visitors/index.ts index b05527364..28f965932 100644 --- a/src/transformation/visitors/index.ts +++ b/src/transformation/visitors/index.ts @@ -41,6 +41,7 @@ import { typescriptVisitors } from "./typescript"; import { transformPostfixUnaryExpression, transformPrefixUnaryExpression } from "./unary-expression"; import { transformVariableStatement } from "./variable-declaration"; import { jsxVisitors } from "./jsx/jsx"; +import { transformAwaitExpression } from "./async-await"; const transformEmptyStatement: FunctionVisitor = () => undefined; const transformParenthesizedExpression: FunctionVisitor = (node, context) => @@ -51,6 +52,7 @@ export const standardVisitors: Visitors = { ...typescriptVisitors, ...jsxVisitors, [ts.SyntaxKind.ArrowFunction]: transformFunctionLikeDeclaration, + [ts.SyntaxKind.AwaitExpression]: transformAwaitExpression, [ts.SyntaxKind.BinaryExpression]: transformBinaryExpression, [ts.SyntaxKind.Block]: transformBlock, [ts.SyntaxKind.BreakStatement]: transformBreakStatement, diff --git a/test/unit/builtins/async-await.spec.ts b/test/unit/builtins/async-await.spec.ts new file mode 100644 index 000000000..523637758 --- /dev/null +++ b/test/unit/builtins/async-await.spec.ts @@ -0,0 +1,157 @@ +import * as util from "../../util"; + +const promiseTestLib = ` +// Some logging utility, useful for asserting orders of operations + +const allLogs: any[] = []; +function log(...values: any[]) { + allLogs.push(...values); +} + +// Create a promise and store its resolve and reject functions, useful for creating pending promises + +function defer() { + let resolve: (data: any) => void = () => {}; + let reject: (reason: string) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}`; + +test("can await already resolved promise", () => { + util.testFunction` + const result = []; + async function abc() { + return await Promise.resolve(30); + } + abc().then(value => result.push(value)); + + return result; + `.expectToEqual([30]); +}); + +test("can await already rejected promise", () => { + util.testFunction` + const result = []; + async function abc() { + return await Promise.reject("test rejection"); + } + abc().catch(reason => result.push(reason)); + + return result; + `.expectToEqual(["test rejection"]); +}); + +test("can await pending promise", () => { + util.testFunction` + const { promise, resolve } = defer(); + promise.then(data => log("resolving original promise", data)); + + async function abc() { + return await promise; + } + + const awaitingPromise = abc(); + awaitingPromise.then(data => log("resolving awaiting promise", data)); + + resolve("resolved data"); + + return allLogs; + + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["resolving original promise", "resolved data", "resolving awaiting promise", "resolved data"]); +}); + +test("can return non-promise from async function", () => { + util.testFunction` + const { promise, resolve } = defer(); + promise.then(data => log("resolving original promise", data)); + + async function abc() { + await promise; + return "abc return data" + } + + const awaitingPromise = abc(); + awaitingPromise.then(data => log("resolving awaiting promise", data)); + + resolve("resolved data"); + + return allLogs; + + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + "resolving original promise", + "resolved data", + "resolving awaiting promise", + "abc return data", + ]); +}); + +test("can have multiple awaits in async function", () => { + util.testFunction` + const { promise: promise1, resolve: resolve1 } = defer(); + const { promise: promise2, resolve: resolve2 } = defer(); + const { promise: promise3, resolve: resolve3 } = defer(); + promise1.then(data => log("resolving promise1", data)); + promise2.then(data => log("resolving promise2", data)); + promise3.then(data => log("resolving promise3", data)); + + async function abc() { + const result1 = await promise1; + const result2 = await promise2; + const result3 = await promise3; + return [result1, result2, result3]; + } + + const awaitingPromise = abc(); + awaitingPromise.then(data => log("resolving awaiting promise", data)); + + resolve1("data1"); + resolve2("data2"); + resolve3("data3"); + + return allLogs; + + ` + .setTsHeader(promiseTestLib) + .expectToEqual([ + "resolving promise1", + "data1", + "resolving promise2", + "data2", + "resolving promise3", + "data3", + "resolving awaiting promise", + ["data1", "data2", "data3"], + ]); +}); + +test("can await async function from async function", () => { + util.testFunction` + const { promise, resolve } = defer(); + promise.then(data => log("resolving original promise", data)); + + async function abc() { + return await promise; + } + + async function def() { + return await abc(); + } + + const awaitingPromise = def(); + awaitingPromise.then(data => log("resolving awaiting promise", data)); + + resolve("resolved data"); + + return allLogs; + + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["resolving original promise", "resolved data", "resolving awaiting promise", "resolved data"]); +}); From 83dee1e0834285d97a0d8b8f8f04490c5b04c73a Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sat, 14 Aug 2021 16:04:24 +0200 Subject: [PATCH 21/28] Disallow await in top-level scope --- src/transformation/utils/diagnostics.ts | 2 + src/transformation/visitors/sourceFile.ts | 17 +++++++ test/unit/builtins/async-await.spec.ts | 59 +++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/transformation/utils/diagnostics.ts b/src/transformation/utils/diagnostics.ts index dbbe9376a..673ea5d21 100644 --- a/src/transformation/utils/diagnostics.ts +++ b/src/transformation/utils/diagnostics.ts @@ -147,3 +147,5 @@ export const annotationDeprecated = createWarningDiagnosticFactory( export const notAllowedOptionalAssignment = createErrorDiagnosticFactory( "The left-hand side of an assignment expression may not be an optional property access." ); + +export const notAllowedTopLevelAwait = createErrorDiagnosticFactory("Await can only be used inside async functions."); diff --git a/src/transformation/visitors/sourceFile.ts b/src/transformation/visitors/sourceFile.ts index 604aa0c7e..4a3ea85c1 100644 --- a/src/transformation/visitors/sourceFile.ts +++ b/src/transformation/visitors/sourceFile.ts @@ -2,6 +2,7 @@ import * as ts from "typescript"; import * as lua from "../../LuaAST"; import { assert } from "../../utils"; import { FunctionVisitor } from "../context"; +import { notAllowedTopLevelAwait } from "../utils/diagnostics"; import { createExportsIdentifier } from "../utils/lua-ast"; import { getUsedLuaLibFeatures } from "../utils/lualib"; import { performHoisting, popScope, pushScope, ScopeType } from "../utils/scope"; @@ -23,6 +24,12 @@ export const transformSourceFileNode: FunctionVisitor = (node, co } } else { pushScope(context, ScopeType.File); + + // await cannot be used outside of async functions due to it using yield which needs to be inside a coroutine + for (const topLevelAwait of node.statements.filter(isTopLevelAwait)) { + context.diagnostics.push(notAllowedTopLevelAwait(topLevelAwait)); + } + statements = performHoisting(context, context.transformStatements(node.statements)); popScope(context); @@ -43,3 +50,13 @@ export const transformSourceFileNode: FunctionVisitor = (node, co const trivia = node.getFullText().match(/^#!.*\r?\n/)?.[0] ?? ""; return lua.createFile(statements, getUsedLuaLibFeatures(context), trivia, node); }; + +function isTopLevelAwait(statement: ts.Statement) { + return ( + (ts.isExpressionStatement(statement) && ts.isAwaitExpression(statement.expression)) || + (ts.isVariableStatement(statement) && + statement.declarationList.declarations.some( + declaration => declaration.initializer && ts.isAwaitExpression(declaration.initializer) + )) + ); +} diff --git a/test/unit/builtins/async-await.spec.ts b/test/unit/builtins/async-await.spec.ts index 523637758..325c15b82 100644 --- a/test/unit/builtins/async-await.spec.ts +++ b/test/unit/builtins/async-await.spec.ts @@ -1,3 +1,5 @@ +import { ModuleKind, ScriptTarget } from "typescript"; +import { notAllowedTopLevelAwait } from "../../../src/transformation/utils/diagnostics"; import * as util from "../../util"; const promiseTestLib = ` @@ -155,3 +157,60 @@ test("can await async function from async function", () => { .setTsHeader(promiseTestLib) .expectToEqual(["resolving original promise", "resolved data", "resolving awaiting promise", "resolved data"]); }); + +test("async function returning value is same as non-async function returning promise", () => { + util.testFunction` + function f(): Promise { + return Promise.resolve(42); + } + + async function fAsync(): Promise { + return 42; + } + + const { state: state1, value: value1 } = f() as any; + const { state: state2, value: value2 } = fAsync() as any; + + return { + state1, value1, + state2, value2 + }; + `.expectToEqual({ + state1: 1, // __TS__PromiseState.Fulfilled + value1: 42, + state2: 1, // __TS__PromiseState.Fulfilled + value2: 42, + }); +}); + +test("can call async function at top-level", () => { + util.testModule` + export let aStarted = false; + async function a() { + aStarted = true; + return 42; + } + + a(); // Call async function (but cannot await) + ` + .setOptions({ module: ModuleKind.ESNext, target: ScriptTarget.ES2017 }) + .expectToEqual({ + aStarted: true, + }); +}); + +test.each(["await a();", "const b = await a();", "export const b = await a();"])( + "cannot await at top-level (%p)", + awaitUsage => { + util.testModule` + async function a() { + return 42; + } + + ${awaitUsage} + export {} // Required to make TS happy, cannot await without import/exports + ` + .setOptions({ module: ModuleKind.ESNext, target: ScriptTarget.ES2017 }) + .expectToHaveDiagnostics([notAllowedTopLevelAwait.code]); + } +); From c4310f91a1d2d64a6eb56ce16798e1889d2ea4c3 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Sat, 14 Aug 2021 16:10:50 +0200 Subject: [PATCH 22/28] Add await rejection test --- test/unit/builtins/async-await.spec.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/unit/builtins/async-await.spec.ts b/test/unit/builtins/async-await.spec.ts index 325c15b82..072d9a509 100644 --- a/test/unit/builtins/async-await.spec.ts +++ b/test/unit/builtins/async-await.spec.ts @@ -183,6 +183,32 @@ test("async function returning value is same as non-async function returning pro }); }); +test("correctly handles awaited functions rejecting", () => { + util.testFunction` + const { promise: promise1, reject } = defer(); + const { promise: promise2 } = defer(); + + promise1.then(data => log("resolving promise1", data), reason => log("rejecting promise1", reason)); + promise2.then(data => log("resolving promise2", data)); + + async function abc() { + const result1 = await promise1; + const result2 = await promise2; + return [result1, result2]; + } + + const awaitingPromise = abc(); + awaitingPromise.catch(reason => log("awaiting promise was rejected because:", reason)); + + reject("test reject"); + + return allLogs; + + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["rejecting promise1", "test reject", "awaiting promise was rejected because:", "test reject"]); +}); + test("can call async function at top-level", () => { util.testModule` export let aStarted = false; From fa508b4223966a6c15b9d5952a66744e64846d0c Mon Sep 17 00:00:00 2001 From: Perryvw Date: Wed, 18 Aug 2021 12:25:09 +0200 Subject: [PATCH 23/28] Give await the correct lualib dependencies --- src/LuaLib.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 78040921b..9c50019f5 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -102,6 +102,7 @@ const luaLibDependencies: Partial> = { ArrayConcat: [LuaLibFeature.ArrayIsArray], ArrayFlat: [LuaLibFeature.ArrayConcat, LuaLibFeature.ArrayIsArray], ArrayFlatMap: [LuaLibFeature.ArrayConcat, LuaLibFeature.ArrayIsArray], + Await: [LuaLibFeature.InstanceOf, LuaLibFeature.New], Decorate: [LuaLibFeature.ObjectGetOwnPropertyDescriptor, LuaLibFeature.SetDescriptor, LuaLibFeature.ObjectAssign], DelegatedYield: [LuaLibFeature.StringAccess], Delete: [LuaLibFeature.ObjectGetOwnPropertyDescriptors], From eb04520d8bc50bac73e8232bcacf9f0e563734b1 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Wed, 18 Aug 2021 15:44:39 +0200 Subject: [PATCH 24/28] use coroutine.status instead of lastData --- src/lualib/Await.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lualib/Await.ts b/src/lualib/Await.ts index 35a991a61..f1d2c32e8 100644 --- a/src/lualib/Await.ts +++ b/src/lualib/Await.ts @@ -25,24 +25,21 @@ function __TS__AsyncAwaiter(this: void, generator: (this: void) => void) { } function fulfilled(value) { try { - const [running, result] = coroutine.resume(asyncCoroutine, value); - step(running, result); + const [_, result] = coroutine.resume(asyncCoroutine, value); + step(result); } catch (e) { reject(e); } } - let lastData: unknown; - function step(running: boolean, result: unknown) { - if (!running) { - resolve(lastData); + function step(result: unknown) { + if (coroutine.status(asyncCoroutine) === "dead") { + resolve(result); } else { - // Not possible to determine if a running === true will be the last one, once it's false the data to return is lost, so save it. - lastData = result; adopt(result).then(fulfilled, reason => reject(reason)); } } - const [running, result] = coroutine.resume(asyncCoroutine); - step(running, result); + const [_, result] = coroutine.resume(asyncCoroutine); + step(result); }); } From b487ff1b13911a376df99dce62df34478ca9df7b Mon Sep 17 00:00:00 2001 From: Perryvw Date: Wed, 18 Aug 2021 20:11:19 +0200 Subject: [PATCH 25/28] Better top level await check --- src/transformation/utils/typescript/index.ts | 16 ++++++++++++++ src/transformation/visitors/function.ts | 5 ++++- src/transformation/visitors/sourceFile.ts | 17 ++++++++------- test/unit/builtins/async-await.spec.ts | 22 +++++++++++++------- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/transformation/utils/typescript/index.ts b/src/transformation/utils/typescript/index.ts index 6d7bfa68c..92100a604 100644 --- a/src/transformation/utils/typescript/index.ts +++ b/src/transformation/utils/typescript/index.ts @@ -92,3 +92,19 @@ export function getFunctionTypeForCall(context: TransformationContext, node: ts. } return context.checker.getTypeFromTypeNode(typeDeclaration.type); } + +// https://github.com/microsoft/TypeScript/blob/663b19fe4a7c4d4ddaa61aedadd28da06acd27b6/src/services/documentHighlights.ts#L435 +// Do not cross function/class/interface/module/type boundaries. +export function traverseWithoutCrossingFunction(node: ts.Node, cb: (node: ts.Node) => void) { + cb(node); + if ( + !ts.isFunctionLike(node) && + !ts.isClassLike(node) && + !ts.isInterfaceDeclaration(node) && + !ts.isModuleDeclaration(node) && + !ts.isTypeAliasDeclaration(node) && + !ts.isTypeNode(node) + ) { + ts.forEachChild(node, child => traverseWithoutCrossingFunction(child, cb)); + } +} diff --git a/src/transformation/visitors/function.ts b/src/transformation/visitors/function.ts index 77d764ac8..1b1735324 100644 --- a/src/transformation/visitors/function.ts +++ b/src/transformation/visitors/function.ts @@ -196,8 +196,11 @@ export function transformFunctionToExpression( spreadIdentifier, node ); + + const possiblyAsyncBody = isAsyncFunction(node) ? wrapInAsyncAwaiter(context, transformedBody) : transformedBody; + const functionExpression = lua.createFunctionExpression( - lua.createBlock(isAsyncFunction(node) ? wrapInAsyncAwaiter(context, transformedBody) : transformedBody), + lua.createBlock(possiblyAsyncBody), paramNames, dotsLiteral, flags, diff --git a/src/transformation/visitors/sourceFile.ts b/src/transformation/visitors/sourceFile.ts index 4a3ea85c1..61acc124e 100644 --- a/src/transformation/visitors/sourceFile.ts +++ b/src/transformation/visitors/sourceFile.ts @@ -6,7 +6,7 @@ import { notAllowedTopLevelAwait } from "../utils/diagnostics"; import { createExportsIdentifier } from "../utils/lua-ast"; import { getUsedLuaLibFeatures } from "../utils/lualib"; import { performHoisting, popScope, pushScope, ScopeType } from "../utils/scope"; -import { hasExportEquals } from "../utils/typescript"; +import { hasExportEquals, traverseWithoutCrossingFunction } from "../utils/typescript"; export const transformSourceFileNode: FunctionVisitor = (node, context) => { let statements: lua.Statement[] = []; @@ -52,11 +52,12 @@ export const transformSourceFileNode: FunctionVisitor = (node, co }; function isTopLevelAwait(statement: ts.Statement) { - return ( - (ts.isExpressionStatement(statement) && ts.isAwaitExpression(statement.expression)) || - (ts.isVariableStatement(statement) && - statement.declarationList.declarations.some( - declaration => declaration.initializer && ts.isAwaitExpression(declaration.initializer) - )) - ); + // Check if expression contains an await child, without going into function declarations + let containsAwait = false; + traverseWithoutCrossingFunction(statement, node => { + if (ts.isAwaitExpression(node)) { + containsAwait = true; + } + }); + return containsAwait; } diff --git a/test/unit/builtins/async-await.spec.ts b/test/unit/builtins/async-await.spec.ts index 072d9a509..f738aa96b 100644 --- a/test/unit/builtins/async-await.spec.ts +++ b/test/unit/builtins/async-await.spec.ts @@ -225,10 +225,17 @@ test("can call async function at top-level", () => { }); }); -test.each(["await a();", "const b = await a();", "export const b = await a();"])( - "cannot await at top-level (%p)", - awaitUsage => { - util.testModule` +test.each([ + "await a();", + "const b = await a();", + "export const b = await a();", + "declare function foo(n: number): number; foo(await a());", + "declare function foo(n: number): number; const b = foo(await a());", + "const b = [await a()];", + "const b = [4, await a()];", + "const b = true ? 4 : await a();", +])("cannot await at top-level (%p)", awaitUsage => { + util.testModule` async function a() { return 42; } @@ -236,7 +243,6 @@ test.each(["await a();", "const b = await a();", "export const b = await a();"]) ${awaitUsage} export {} // Required to make TS happy, cannot await without import/exports ` - .setOptions({ module: ModuleKind.ESNext, target: ScriptTarget.ES2017 }) - .expectToHaveDiagnostics([notAllowedTopLevelAwait.code]); - } -); + .setOptions({ module: ModuleKind.ESNext, target: ScriptTarget.ES2017 }) + .expectToHaveDiagnostics([notAllowedTopLevelAwait.code]); +}); From 73c4d8363e658bc4a39567b0e9f5fc89a5a922be Mon Sep 17 00:00:00 2001 From: Perryvw Date: Wed, 18 Aug 2021 20:11:48 +0200 Subject: [PATCH 26/28] Add tests for async lambdas and throws in async functions --- src/lualib/Await.ts | 16 ++- test/unit/builtins/async-await.spec.ts | 137 ++++++++++++++++++++----- 2 files changed, 124 insertions(+), 29 deletions(-) diff --git a/src/lualib/Await.ts b/src/lualib/Await.ts index f1d2c32e8..4b723741b 100644 --- a/src/lualib/Await.ts +++ b/src/lualib/Await.ts @@ -25,8 +25,12 @@ function __TS__AsyncAwaiter(this: void, generator: (this: void) => void) { } function fulfilled(value) { try { - const [_, result] = coroutine.resume(asyncCoroutine, value); - step(result); + const [success, resultOrError] = coroutine.resume(asyncCoroutine, value); + if (success) { + step(resultOrError); + } else { + reject(resultOrError); + } } catch (e) { reject(e); } @@ -38,8 +42,12 @@ function __TS__AsyncAwaiter(this: void, generator: (this: void) => void) { adopt(result).then(fulfilled, reason => reject(reason)); } } - const [_, result] = coroutine.resume(asyncCoroutine); - step(result); + const [success, resultOrError] = coroutine.resume(asyncCoroutine); + if (success) { + step(resultOrError); + } else { + reject(resultOrError); + } }); } diff --git a/test/unit/builtins/async-await.spec.ts b/test/unit/builtins/async-await.spec.ts index f738aa96b..41a636739 100644 --- a/test/unit/builtins/async-await.spec.ts +++ b/test/unit/builtins/async-await.spec.ts @@ -67,12 +67,36 @@ test("can await pending promise", () => { .expectToEqual(["resolving original promise", "resolved data", "resolving awaiting promise", "resolved data"]); }); -test("can return non-promise from async function", () => { +test("can await non-promise values", () => { util.testFunction` + async function foo() { + return await "foo"; + } + + async function bar() { + return await { foo: await foo(), bar: "bar" }; + } + + async function baz() { + return (await bar()).foo + (await bar()).bar; + } + + const { state, value } = baz() as any; + return { state, value }; + `.expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: "foobar", + }); +}); + +test.each(["async function abc() {", "const abc = async () => {"])( + "can return non-promise from async function (%p)", + functionHeader => { + util.testFunction` const { promise, resolve } = defer(); promise.then(data => log("resolving original promise", data)); - async function abc() { + ${functionHeader} await promise; return "abc return data" } @@ -85,17 +109,20 @@ test("can return non-promise from async function", () => { return allLogs; ` - .setTsHeader(promiseTestLib) - .expectToEqual([ - "resolving original promise", - "resolved data", - "resolving awaiting promise", - "abc return data", - ]); -}); - -test("can have multiple awaits in async function", () => { - util.testFunction` + .setTsHeader(promiseTestLib) + .expectToEqual([ + "resolving original promise", + "resolved data", + "resolving awaiting promise", + "abc return data", + ]); + } +); + +test.each(["async function abc() {", "const abc = async () => {"])( + "can have multiple awaits in async function (%p)", + functionHeader => { + util.testFunction` const { promise: promise1, resolve: resolve1 } = defer(); const { promise: promise2, resolve: resolve2 } = defer(); const { promise: promise3, resolve: resolve3 } = defer(); @@ -103,7 +130,7 @@ test("can have multiple awaits in async function", () => { promise2.then(data => log("resolving promise2", data)); promise3.then(data => log("resolving promise3", data)); - async function abc() { + ${functionHeader} const result1 = await promise1; const result2 = await promise2; const result3 = await promise3; @@ -120,17 +147,31 @@ test("can have multiple awaits in async function", () => { return allLogs; ` - .setTsHeader(promiseTestLib) - .expectToEqual([ - "resolving promise1", - "data1", - "resolving promise2", - "data2", - "resolving promise3", - "data3", - "resolving awaiting promise", - ["data1", "data2", "data3"], - ]); + .setTsHeader(promiseTestLib) + .expectToEqual([ + "resolving promise1", + "data1", + "resolving promise2", + "data2", + "resolving promise3", + "data3", + "resolving awaiting promise", + ["data1", "data2", "data3"], + ]); + } +); + +test("can make async lambdas with expression body", () => { + util.testFunction` + const foo = async () => "foo"; + const bar = async () => await foo(); + + const { state, value } = bar() as any; + return { state, value }; + `.expectToEqual({ + state: 1, // __TS__PromiseState.Fulfilled + value: "foo", + }); }); test("can await async function from async function", () => { @@ -225,6 +266,52 @@ test("can call async function at top-level", () => { }); }); +test("async function throws error", () => { + util.testFunction` + async function a() { + throw "test throw"; + } + + const { state, rejectionReason } = a() as any; + return { state, rejectionReason }; + `.expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: "test throw", + }); +}); + +test("async lambda throws error", () => { + util.testFunction` + const a = async () => { + throw "test throw"; + } + + const { state, rejectionReason } = a() as any; + return { state, rejectionReason }; + `.expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: "test throw", + }); +}); + +test("async function throws object", () => { + util.testFunction` + async function a() { + throw new Error("test throw"); + } + + const { state, rejectionReason } = a() as any; + return { state, rejectionReason }; + `.expectToEqual({ + state: 2, // __TS__PromiseState.Rejected + rejectionReason: { + message: "test throw", + name: "Error", + stack: expect.stringContaining("stack traceback"), + }, + }); +}); + test.each([ "await a();", "const b = await a();", From 6d538b60a57bf076649c2f2d96dabeb4b4ce7337 Mon Sep 17 00:00:00 2001 From: Perryvw Date: Fri, 20 Aug 2021 11:11:51 +0200 Subject: [PATCH 27/28] Moved toplevel await check to transformAwaitExpression and removed superfluous try/catch --- src/lualib/Await.ts | 14 +++++--------- src/transformation/utils/diagnostics.ts | 4 +++- src/transformation/utils/typescript/index.ts | 16 ---------------- src/transformation/visitors/async-await.ts | 11 +++++++++++ src/transformation/visitors/sourceFile.ts | 19 +------------------ test/unit/builtins/async-await.spec.ts | 4 ++-- 6 files changed, 22 insertions(+), 46 deletions(-) diff --git a/src/lualib/Await.ts b/src/lualib/Await.ts index 4b723741b..61f383e96 100644 --- a/src/lualib/Await.ts +++ b/src/lualib/Await.ts @@ -24,15 +24,11 @@ function __TS__AsyncAwaiter(this: void, generator: (this: void) => void) { return value instanceof __TS__Promise ? value : Promise.resolve(value); } function fulfilled(value) { - try { - const [success, resultOrError] = coroutine.resume(asyncCoroutine, value); - if (success) { - step(resultOrError); - } else { - reject(resultOrError); - } - } catch (e) { - reject(e); + const [success, resultOrError] = coroutine.resume(asyncCoroutine, value); + if (success) { + step(resultOrError); + } else { + reject(resultOrError); } } function step(result: unknown) { diff --git a/src/transformation/utils/diagnostics.ts b/src/transformation/utils/diagnostics.ts index 673ea5d21..a7a63a717 100644 --- a/src/transformation/utils/diagnostics.ts +++ b/src/transformation/utils/diagnostics.ts @@ -148,4 +148,6 @@ export const notAllowedOptionalAssignment = createErrorDiagnosticFactory( "The left-hand side of an assignment expression may not be an optional property access." ); -export const notAllowedTopLevelAwait = createErrorDiagnosticFactory("Await can only be used inside async functions."); +export const awaitMustBeInAsyncFunction = createErrorDiagnosticFactory( + "Await can only be used inside async functions." +); diff --git a/src/transformation/utils/typescript/index.ts b/src/transformation/utils/typescript/index.ts index 92100a604..6d7bfa68c 100644 --- a/src/transformation/utils/typescript/index.ts +++ b/src/transformation/utils/typescript/index.ts @@ -92,19 +92,3 @@ export function getFunctionTypeForCall(context: TransformationContext, node: ts. } return context.checker.getTypeFromTypeNode(typeDeclaration.type); } - -// https://github.com/microsoft/TypeScript/blob/663b19fe4a7c4d4ddaa61aedadd28da06acd27b6/src/services/documentHighlights.ts#L435 -// Do not cross function/class/interface/module/type boundaries. -export function traverseWithoutCrossingFunction(node: ts.Node, cb: (node: ts.Node) => void) { - cb(node); - if ( - !ts.isFunctionLike(node) && - !ts.isClassLike(node) && - !ts.isInterfaceDeclaration(node) && - !ts.isModuleDeclaration(node) && - !ts.isTypeAliasDeclaration(node) && - !ts.isTypeNode(node) - ) { - ts.forEachChild(node, child => traverseWithoutCrossingFunction(child, cb)); - } -} diff --git a/src/transformation/visitors/async-await.ts b/src/transformation/visitors/async-await.ts index b51b7b47c..c6dcc86dc 100644 --- a/src/transformation/visitors/async-await.ts +++ b/src/transformation/visitors/async-await.ts @@ -1,9 +1,20 @@ import * as ts from "typescript"; import * as lua from "../../LuaAST"; import { FunctionVisitor, TransformationContext } from "../context"; +import { awaitMustBeInAsyncFunction } from "../utils/diagnostics"; import { importLuaLibFeature, LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; +import { findFirstNodeAbove } from "../utils/typescript"; export const transformAwaitExpression: FunctionVisitor = (node, context) => { + // Check if await is inside an async function, it is not allowed at top level or in non-async functions + const containingFunction = findFirstNodeAbove(node, ts.isFunctionLike); + if ( + containingFunction === undefined || + !containingFunction.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword) + ) { + context.diagnostics.push(awaitMustBeInAsyncFunction(node)); + } + const expression = context.transformExpression(node.expression); return transformLuaLibFunction(context, LuaLibFeature.Await, node, expression); }; diff --git a/src/transformation/visitors/sourceFile.ts b/src/transformation/visitors/sourceFile.ts index 61acc124e..41561c67f 100644 --- a/src/transformation/visitors/sourceFile.ts +++ b/src/transformation/visitors/sourceFile.ts @@ -2,11 +2,10 @@ import * as ts from "typescript"; import * as lua from "../../LuaAST"; import { assert } from "../../utils"; import { FunctionVisitor } from "../context"; -import { notAllowedTopLevelAwait } from "../utils/diagnostics"; import { createExportsIdentifier } from "../utils/lua-ast"; import { getUsedLuaLibFeatures } from "../utils/lualib"; import { performHoisting, popScope, pushScope, ScopeType } from "../utils/scope"; -import { hasExportEquals, traverseWithoutCrossingFunction } from "../utils/typescript"; +import { hasExportEquals } from "../utils/typescript"; export const transformSourceFileNode: FunctionVisitor = (node, context) => { let statements: lua.Statement[] = []; @@ -25,11 +24,6 @@ export const transformSourceFileNode: FunctionVisitor = (node, co } else { pushScope(context, ScopeType.File); - // await cannot be used outside of async functions due to it using yield which needs to be inside a coroutine - for (const topLevelAwait of node.statements.filter(isTopLevelAwait)) { - context.diagnostics.push(notAllowedTopLevelAwait(topLevelAwait)); - } - statements = performHoisting(context, context.transformStatements(node.statements)); popScope(context); @@ -50,14 +44,3 @@ export const transformSourceFileNode: FunctionVisitor = (node, co const trivia = node.getFullText().match(/^#!.*\r?\n/)?.[0] ?? ""; return lua.createFile(statements, getUsedLuaLibFeatures(context), trivia, node); }; - -function isTopLevelAwait(statement: ts.Statement) { - // Check if expression contains an await child, without going into function declarations - let containsAwait = false; - traverseWithoutCrossingFunction(statement, node => { - if (ts.isAwaitExpression(node)) { - containsAwait = true; - } - }); - return containsAwait; -} diff --git a/test/unit/builtins/async-await.spec.ts b/test/unit/builtins/async-await.spec.ts index 41a636739..a396c4aed 100644 --- a/test/unit/builtins/async-await.spec.ts +++ b/test/unit/builtins/async-await.spec.ts @@ -1,5 +1,5 @@ import { ModuleKind, ScriptTarget } from "typescript"; -import { notAllowedTopLevelAwait } from "../../../src/transformation/utils/diagnostics"; +import { awaitMustBeInAsyncFunction } from "../../../src/transformation/utils/diagnostics"; import * as util from "../../util"; const promiseTestLib = ` @@ -331,5 +331,5 @@ test.each([ export {} // Required to make TS happy, cannot await without import/exports ` .setOptions({ module: ModuleKind.ESNext, target: ScriptTarget.ES2017 }) - .expectToHaveDiagnostics([notAllowedTopLevelAwait.code]); + .expectToHaveDiagnostics([awaitMustBeInAsyncFunction.code]); }); From 01a0fcb4f11a45f075e9500e208113cd4b6a8092 Mon Sep 17 00:00:00 2001 From: Tom <26638278+tomblind@users.noreply.github.com> Date: Fri, 20 Aug 2021 06:51:56 -0600 Subject: [PATCH 28/28] fix for vararg access in async functions (#1096) Co-authored-by: Tom --- src/transformation/visitors/function.ts | 9 ++++--- src/transformation/visitors/spread.ts | 5 ++++ test/unit/builtins/async-await.spec.ts | 36 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/transformation/visitors/function.ts b/src/transformation/visitors/function.ts index 1b1735324..0061ea749 100644 --- a/src/transformation/visitors/function.ts +++ b/src/transformation/visitors/function.ts @@ -115,7 +115,10 @@ export function transformFunctionBody( ): [lua.Statement[], Scope] { const scope = pushScope(context, ScopeType.Function); scope.node = node; - const bodyStatements = transformFunctionBodyContent(context, body); + let bodyStatements = transformFunctionBodyContent(context, body); + if (node && isAsyncFunction(node)) { + bodyStatements = wrapInAsyncAwaiter(context, bodyStatements); + } const headerStatements = transformFunctionBodyHeader(context, scope, parameters, spreadIdentifier); popScope(context); return [[...headerStatements, ...bodyStatements], scope]; @@ -197,10 +200,8 @@ export function transformFunctionToExpression( node ); - const possiblyAsyncBody = isAsyncFunction(node) ? wrapInAsyncAwaiter(context, transformedBody) : transformedBody; - const functionExpression = lua.createFunctionExpression( - lua.createBlock(possiblyAsyncBody), + lua.createBlock(transformedBody), paramNames, dotsLiteral, flags, diff --git a/src/transformation/visitors/spread.ts b/src/transformation/visitors/spread.ts index 6e6ec391a..f8ebdc79d 100644 --- a/src/transformation/visitors/spread.ts +++ b/src/transformation/visitors/spread.ts @@ -37,6 +37,11 @@ export function isOptimizedVarArgSpread(context: TransformationContext, symbol: return false; } + // Scope cannot be an async function + if (scope.node.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)) { + return false; + } + // Identifier must be a vararg in the local function scope's parameters const isSpreadParameter = (p: ts.ParameterDeclaration) => p.dotDotDotToken && ts.isIdentifier(p.name) && context.checker.getSymbolAtLocation(p.name) === symbol; diff --git a/test/unit/builtins/async-await.spec.ts b/test/unit/builtins/async-await.spec.ts index a396c4aed..0a9c3513e 100644 --- a/test/unit/builtins/async-await.spec.ts +++ b/test/unit/builtins/async-await.spec.ts @@ -333,3 +333,39 @@ test.each([ .setOptions({ module: ModuleKind.ESNext, target: ScriptTarget.ES2017 }) .expectToHaveDiagnostics([awaitMustBeInAsyncFunction.code]); }); + +test("async function can access varargs", () => { + util.testFunction` + const { promise, resolve } = defer(); + + async function a(...args: string[]) { + log(await promise); + log(args[1]); + } + + const awaitingPromise = a("A", "B", "C"); + resolve("resolved"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["resolved", "B"]); +}); + +test("async function can forward varargs", () => { + util.testFunction` + const { promise, resolve } = defer(); + + async function a(...args: string[]) { + log(await promise); + log(...args); + } + + const awaitingPromise = a("A", "B", "C"); + resolve("resolved"); + + return allLogs; + ` + .setTsHeader(promiseTestLib) + .expectToEqual(["resolved", "A", "B", "C"]); +});