diff --git a/src/LuaLib.ts b/src/LuaLib.ts index d98b78044..e1a8d9efd 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -57,6 +57,9 @@ export enum LuaLibFeature { ObjectKeys = "ObjectKeys", ObjectRest = "ObjectRest", ObjectValues = "ObjectValues", + OptionalChainAccess = "OptionalChainAccess", + OptionalFunctionCall = "OptionalFunctionCall", + OptionalMethodCall = "OptionalMethodCall", ParseFloat = "ParseFloat", ParseInt = "ParseInt", Set = "Set", diff --git a/src/lualib/OptionalChainAccess.ts b/src/lualib/OptionalChainAccess.ts new file mode 100644 index 000000000..7924aa282 --- /dev/null +++ b/src/lualib/OptionalChainAccess.ts @@ -0,0 +1,10 @@ +function __TS__OptionalChainAccess( + this: void, + table: Record, + key: TKey +): TReturn | undefined { + if (table) { + return table[key]; + } + return undefined; +} diff --git a/src/lualib/OptionalFunctionCall.ts b/src/lualib/OptionalFunctionCall.ts new file mode 100644 index 000000000..85b52a047 --- /dev/null +++ b/src/lualib/OptionalFunctionCall.ts @@ -0,0 +1,10 @@ +function __TS__OptionalFunctionCall( + this: void, + f: (this: void, ...args: [...TArgs]) => TReturn, + ...args: [...TArgs] +): TReturn | undefined { + if (f) { + return f(...args); + } + return undefined; +} diff --git a/src/lualib/OptionalMethodCall.ts b/src/lualib/OptionalMethodCall.ts new file mode 100644 index 000000000..8829e4a4d --- /dev/null +++ b/src/lualib/OptionalMethodCall.ts @@ -0,0 +1,14 @@ +function __TS__OptionalMethodCall( + this: void, + table: Record TReturn>, + methodName: string, + ...args: [...TArgs] +): TReturn | undefined { + if (table) { + const method = table[methodName]; + if (method) { + return method.call(table, ...args); + } + } + return undefined; +} diff --git a/src/transformation/utils/diagnostics.ts b/src/transformation/utils/diagnostics.ts index fcc37b6f0..16a0c7395 100644 --- a/src/transformation/utils/diagnostics.ts +++ b/src/transformation/utils/diagnostics.ts @@ -143,5 +143,3 @@ export const annotationDeprecated = createWarningDiagnosticFactory( `'@${kind}' is deprecated and will be removed in a future update. Please update your code before upgrading to the next release, otherwise your project will no longer compile. ` + `See https://typescripttolua.github.io/docs/advanced/compiler-annotations#${kind.toLowerCase()} for more information.` ); - -export const optionalChainingNotSupported = createErrorDiagnosticFactory("Optional chaining is not supported yet."); diff --git a/src/transformation/visitors/access.ts b/src/transformation/visitors/access.ts index a41c4462f..8b795426b 100644 --- a/src/transformation/visitors/access.ts +++ b/src/transformation/visitors/access.ts @@ -3,7 +3,7 @@ import * as lua from "../../LuaAST"; import { transformBuiltinPropertyAccessExpression } from "../builtins"; import { FunctionVisitor, TransformationContext } from "../context"; import { AnnotationKind, getTypeAnnotations } from "../utils/annotations"; -import { annotationRemoved, invalidMultiReturnAccess, optionalChainingNotSupported } from "../utils/diagnostics"; +import { annotationRemoved, invalidMultiReturnAccess } from "../utils/diagnostics"; import { addToNumericExpression } from "../utils/lua-ast"; import { LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; import { isArrayType, isNumberType, isStringType } from "../utils/typescript"; @@ -53,6 +53,10 @@ export const transformElementAccessExpression: FunctionVisitor = (node, context) => { const right = lua.createStringLiteral(node.right.text, node.right); const left = context.transformExpression(node.left); diff --git a/src/transformation/visitors/binary-expression/index.ts b/src/transformation/visitors/binary-expression/index.ts index 0b4533a4a..ce73a7a70 100644 --- a/src/transformation/visitors/binary-expression/index.ts +++ b/src/transformation/visitors/binary-expression/index.ts @@ -54,7 +54,7 @@ export function transformBinaryOperation( if (operator === ts.SyntaxKind.QuestionQuestionToken) { assert(ts.isBinaryExpression(node)); - return transformNullishCoalescingExpression(context, node); + return transformNullishCoalescingExpression(context, node, left, right); } let luaOperator = simpleOperatorsToLua[operator]; @@ -162,7 +162,9 @@ export function transformBinaryExpressionStatement( function transformNullishCoalescingExpression( context: TransformationContext, - node: ts.BinaryExpression + node: ts.BinaryExpression, + transformedLeft: lua.Expression, + transformedRight: lua.Expression ): lua.Expression { const lhsType = context.checker.getTypeAtLocation(node.left); @@ -181,20 +183,15 @@ function transformNullishCoalescingExpression( // if ____ == nil then return rhs else return ____ end const ifStatement = lua.createIfStatement( nilComparison, - lua.createBlock([lua.createReturnStatement([context.transformExpression(node.right)])]), + lua.createBlock([lua.createReturnStatement([transformedRight])]), lua.createBlock([lua.createReturnStatement([lua.cloneIdentifier(lhsIdentifier)])]) ); // (function(lhs') if lhs' == nil then return rhs else return lhs' end)(lhs) return lua.createCallExpression(lua.createFunctionExpression(lua.createBlock([ifStatement]), [lhsIdentifier]), [ - context.transformExpression(node.left), + transformedLeft, ]); } else { // lhs or rhs - return lua.createBinaryExpression( - context.transformExpression(node.left), - context.transformExpression(node.right), - lua.SyntaxKind.OrOperator, - node - ); + return lua.createBinaryExpression(transformedLeft, transformedRight, lua.SyntaxKind.OrOperator, node); } } diff --git a/src/transformation/visitors/call.ts b/src/transformation/visitors/call.ts index f7a1b004e..ced618c4c 100644 --- a/src/transformation/visitors/call.ts +++ b/src/transformation/visitors/call.ts @@ -148,18 +148,29 @@ export function transformContextualCallExpression( node: ts.CallExpression | ts.TaggedTemplateExpression, args: ts.Expression[] | ts.NodeArray, signature?: ts.Signature -): lua.Expression { +): lua.CallExpression | lua.MethodCallExpression { const left = ts.isCallExpression(node) ? node.expression : node.tag; if (ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.name) && isValidLuaIdentifier(left.name.text)) { // table:name() const table = context.transformExpression(left.expression); - return lua.createMethodCallExpression( - table, - lua.createIdentifier(left.name.text, left.name), - transformArguments(context, args, signature), - node - ); + if (ts.isOptionalChain(node)) { + return transformLuaLibFunction( + context, + LuaLibFeature.OptionalMethodCall, + node, + table, + lua.createStringLiteral(left.name.text, left.name), + ...transformArguments(context, args, signature) + ); + } else { + return lua.createMethodCallExpression( + table, + lua.createIdentifier(left.name.text, left.name), + transformArguments(context, args, signature), + node + ); + } } else if (ts.isElementAccessExpression(left) || ts.isPropertyAccessExpression(left)) { if (isExpressionWithEvaluationEffect(left.expression)) { return transformToImmediatelyInvokedFunctionExpression( @@ -183,7 +194,10 @@ export function transformContextualCallExpression( } } -function transformPropertyCall(context: TransformationContext, node: PropertyCallExpression): lua.Expression { +function transformPropertyCall( + context: TransformationContext, + node: PropertyCallExpression +): lua.CallExpression | lua.MethodCallExpression { const signature = context.checker.getResolvedSignature(node); if (node.expression.expression.kind === ts.SyntaxKind.SuperKeyword) { @@ -197,17 +211,22 @@ function transformPropertyCall(context: TransformationContext, node: PropertyCal // table:name() return transformContextualCallExpression(context, node, node.arguments, signature); } else { - const table = context.transformExpression(node.expression.expression); - // table.name() - const name = node.expression.name.text; - const callPath = lua.createTableIndexExpression(table, lua.createStringLiteral(name), node.expression); + const callPath = context.transformExpression(node.expression); const parameters = transformArguments(context, node.arguments, signature); - return lua.createCallExpression(callPath, parameters, node); + + if (ts.isOptionalChain(node)) { + return transformLuaLibFunction(context, LuaLibFeature.OptionalFunctionCall, node, callPath, ...parameters); + } else { + return lua.createCallExpression(callPath, parameters, node); + } } } -function transformElementCall(context: TransformationContext, node: ts.CallExpression): lua.Expression { +function transformElementCall( + context: TransformationContext, + node: ts.CallExpression +): lua.CallExpression | lua.MethodCallExpression { const signature = context.checker.getResolvedSignature(node); const signatureDeclaration = signature?.getDeclaration(); if (!signatureDeclaration || getDeclarationContextType(context, signatureDeclaration) !== ContextType.Void) { @@ -229,11 +248,12 @@ export const transformCallExpression: FunctionVisitor = (node const returnValueIsUsed = node.parent && !ts.isExpressionStatement(node.parent); const wrapTupleReturn = isTupleReturn && !isTupleReturnForward && !isInDestructingAssignment(node) && !isInSpread && returnValueIsUsed; - const wrapResult = wrapTupleReturn || shouldMultiReturnCallBeWrapped(context, node); + const wrapResultInTable = wrapTupleReturn || shouldMultiReturnCallBeWrapped(context, node); + const wrapResultInOptional = ts.isOptionalChain(node); const builtinResult = transformBuiltinCallExpression(context, node); if (builtinResult) { - return wrapResult ? wrapInTable(builtinResult) : builtinResult; + return wrapResultInTable ? wrapInTable(builtinResult) : builtinResult; } if (isOperatorMapping(context, node)) { @@ -274,12 +294,13 @@ export const transformCallExpression: FunctionVisitor = (node } const result = transformPropertyCall(context, node as PropertyCallExpression); - return wrapResult ? wrapInTable(result) : result; + // transformPropertyCall already wraps optional so no need to do so here + return wrapResultInTable ? wrapInTable(result) : result; } if (ts.isElementAccessExpression(node.expression)) { const result = transformElementCall(context, node); - return wrapResult ? wrapInTable(result) : result; + return wrapIfRequired(context, wrapResultInTable, wrapResultInOptional, result, node); } const signature = context.checker.getResolvedSignature(node); @@ -309,5 +330,41 @@ export const transformCallExpression: FunctionVisitor = (node } const callExpression = lua.createCallExpression(callPath, parameters, node); - return wrapResult ? wrapInTable(callExpression) : callExpression; + return wrapIfRequired(context, wrapResultInTable, wrapResultInOptional, callExpression, node); }; + +function wrapIfRequired( + context: TransformationContext, + shouldWrapInTable: boolean, + shouldWrapOptional: boolean, + call: lua.CallExpression | lua.MethodCallExpression, + node: ts.Node +): lua.Expression { + const wrappedOptional = shouldWrapOptional ? wrapOptionalCall(context, call, node) : call; + return shouldWrapInTable ? wrapInTable(wrappedOptional) : wrappedOptional; +} + +function wrapOptionalCall( + context: TransformationContext, + call: lua.CallExpression | lua.MethodCallExpression, + node: ts.Node +): lua.CallExpression { + if (lua.isMethodCallExpression(call)) { + return transformLuaLibFunction( + context, + LuaLibFeature.OptionalMethodCall, + node, + call.prefixExpression, + lua.createStringLiteral(call.name.text), + ...call.params + ); + } else { + return transformLuaLibFunction( + context, + LuaLibFeature.OptionalFunctionCall, + node, + call.expression, + ...call.params + ); + } +} diff --git a/test/transpile/module-resolution.spec.ts b/test/transpile/module-resolution.spec.ts index 6c5bff4a2..872bd47c7 100644 --- a/test/transpile/module-resolution.spec.ts +++ b/test/transpile/module-resolution.spec.ts @@ -288,7 +288,6 @@ describe("module resolution with tsx", () => { test("project with tsx files", () => { util.testProject(path.join(projectPath, "tsconfig.json")) .setMainFileName(path.join(projectPath, "main.tsx")) - .debug() .expectToEqual({ result: "hello from other.tsx", indexResult: "hello from dir/index.tsx", diff --git a/test/unit/optionalChaining.spec.ts b/test/unit/optionalChaining.spec.ts index 33d0802db..26deff810 100644 --- a/test/unit/optionalChaining.spec.ts +++ b/test/unit/optionalChaining.spec.ts @@ -1,9 +1,160 @@ -import { optionalChainingNotSupported } from "../../src/transformation/utils/diagnostics"; import * as util from "../util"; -test("Diagnostic optional chaining is not supported yet", () => { +test.each(["null", "undefined", '{ foo: "foo" }'])("optional chaining (%p)", value => { util.testFunction` - let func = (value: number) => value != 0 ? {value} : undefined; - return func(1)?.value; - `.expectToHaveDiagnostics([optionalChainingNotSupported.code]); + const obj: any = ${value}; + return obj?.foo; + `.expectToMatchJsResult(); +}); + +test("long optional chain", () => { + util.testFunction` + const a = { b: { c: { d: { e: { f: "hello!"}}}}}; + return a.b?.c?.d.e.f; + `.expectToMatchJsResult(); +}); + +test.each(["undefined", "{}", "{ foo: {} }", "{ foo: {bar: 'baz'}}"])("nested optional chaining (%p)", value => { + util.testFunction` + const obj: { foo?: { bar?: string } } | undefined = ${value}; + return obj?.foo?.bar; + `.expectToMatchJsResult(); +}); + +test.each(["undefined", "{}", "{ foo: {} }", "{ foo: {bar: 'baz'}}"])( + "nested optional chaining combined with coalescing (%p)", + value => { + util.testFunction` + const obj: { foo?: { bar?: string } } | undefined = ${value}; + return obj?.foo?.bar ?? "not found"; + `.expectToMatchJsResult(); + } +); + +test.each(["[1, 2, 3, 4]", "undefined"])("optional array access (%p)", value => { + util.testFunction` + const arr: number[] | undefined = ${value}; + return arr?.[2]; + `.expectToMatchJsResult(); +}); + +test.each(["[1, [2, [3, [4, 5]]]]", "[1, [2, [3, undefined]]] ", "[1, undefined]"])( + "optional element access nested (%p)", + value => { + util.testFunction` + const arr: [number, [number, [number, [number, number] | undefined]]] | [number, undefined] = ${value}; + return arr[1]?.[1][1]?.[0]; + `.expectToMatchJsResult(); + } +); + +test.each(["{ }", "{ a: { } }", "{ a: { b: [{ c: 10 }] } }"])( + "optional nested element access properties (%p)", + value => { + util.testFunction` + const obj: {a?: {b?: Array<{c: number }> } } = ${value}; + return [obj["a"]?.["b"]?.[0]?.["c"] ?? "not found", obj["a"]?.["b"]?.[2]?.["c"] ?? "not found"]; + `.expectToMatchJsResult(); + } +); + +test("optional element function calls", () => { + util.testFunction` + const obj: { value: string; foo?(this: void, v: number): number; bar?(this: void, v: number): number; } = { + value: "foobar", + foo: (v: number) => v + 10 + } + const fooKey = "foo"; + const barKey = "bar"; + return obj[barKey]?.(5) ?? obj[fooKey]?.(15); + `.expectToMatchJsResult(); +}); + +test("optional element access method calls", () => { + util.testFunction` + const obj: { value: string; foo?(prefix: string): string; bar?(prefix: string): string; } = { + value: "foobar", + foo(prefix: string) { return prefix + this.value; } + } + const fooKey = "foo"; + const barKey = "bar"; + return obj[barKey]?.("bar?") ?? obj[fooKey]?.("foo?"); + `.expectToMatchJsResult(); +}); + +test("no side effects", () => { + util.testFunction` + function getFoo(): { foo: number } | undefined { + return { foo: 42 }; + } + let barCalls = 0; + function getBar(): { bar: number } | undefined { + barCalls += 1; + return undefined; + } + const result = getFoo()?.foo ?? getBar()?.bar; + return { result, barCalls }; + `.expectToMatchJsResult(); +}); + +describe("optional chaining function calls", () => { + test.each(["() => 4", "undefined"])("stand-alone optional function (%p)", value => { + util.testFunction` + const f: (() => number) | undefined = ${value}; + return f?.(); + `.expectToMatchJsResult(); + }); + + test("methods present", () => { + util.testFunction` + const objWithMethods = { + foo() { + return 3; + }, + bar(this: void) { + return 5; + } + }; + + return [objWithMethods?.foo(), objWithMethods?.bar()]; + `.expectToMatchJsResult(); + }); + + test("object with method can be undefined", () => { + util.testFunction` + const objWithMethods: { foo: () => number, bar: (this: void) => number } | undefined = undefined; + return [objWithMethods?.foo() ?? "no foo", objWithMethods?.bar() ?? "no bar"]; + `.expectToMatchJsResult(); + }); + + test("nested optional method call", () => { + util.testFunction` + type typeWithOptional = { a?: { b: { c: () => number } } }; + + const objWithMethods: typeWithOptional = {}; + const objWithMethods2: typeWithOptional = { a: { b: { c: () => 4 } } }; + + return { + expectNil: objWithMethods.a?.b.c(), + expectFour: objWithMethods2.a?.b.c() + }; + `.expectToMatchJsResult(); + }); + + test("methods are undefined", () => { + util.testFunction` + const objWithMethods: { foo?: () => number, bar?: (this: void) => number } = {}; + return [objWithMethods.foo?.() ?? "no foo", objWithMethods.bar?.() ?? "no bar"]; + `.expectToMatchJsResult(); + }); + + test("optional method of optional method result", () => { + util.testFunction` + const obj: { a?: () => {b: {c?: () => number }}} = {}; + const obj2: { a?: () => {b: {c?: () => number }}} = { a: () => ({b: {}})}; + const obj3: { a?: () => {b: {c?: () => number }}} = { a: () => ({b: { c: () => 5 }})}; + + return [obj.a?.().b.c?.() ?? "nil", obj2.a?.().b.c?.() ?? "nil", obj3.a?.().b.c?.() ?? "nil"]; + `.expectToMatchJsResult(); + }); });