From f5cce1d287818c3137c7356f1c1ab2e9abde1cbd Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 29 Nov 2021 12:53:49 -0700 Subject: [PATCH] implemented support for properties on functions --- src/LuaLib.ts | 2 +- src/transformation/builtins/function.ts | 7 +- src/transformation/builtins/index.ts | 4 +- .../utils/language-extensions.ts | 6 + src/transformation/utils/lua-ast.ts | 10 +- src/transformation/utils/typescript/types.ts | 5 +- src/transformation/visitors/function.ts | 59 +++++- .../visitors/variable-declaration.ts | 14 +- .../unit/functions/functionProperties.spec.ts | 175 ++++++++++++++++++ 9 files changed, 264 insertions(+), 18 deletions(-) create mode 100644 test/unit/functions/functionProperties.spec.ts diff --git a/src/LuaLib.ts b/src/LuaLib.ts index 226bbe15c..dd58b8e65 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -103,7 +103,7 @@ const luaLibDependencies: Partial> = { ArrayConcat: [LuaLibFeature.ArrayIsArray], ArrayFlat: [LuaLibFeature.ArrayConcat, LuaLibFeature.ArrayIsArray], ArrayFlatMap: [LuaLibFeature.ArrayConcat, LuaLibFeature.ArrayIsArray], - Await: [LuaLibFeature.InstanceOf, LuaLibFeature.New], + Await: [LuaLibFeature.InstanceOf, LuaLibFeature.New, LuaLibFeature.Promise], Decorate: [LuaLibFeature.ObjectGetOwnPropertyDescriptor, LuaLibFeature.SetDescriptor, LuaLibFeature.ObjectAssign], DelegatedYield: [LuaLibFeature.StringAccess], Delete: [LuaLibFeature.ObjectGetOwnPropertyDescriptors, LuaLibFeature.Error, LuaLibFeature.New], diff --git a/src/transformation/builtins/function.ts b/src/transformation/builtins/function.ts index aefe2a13e..3dfae1428 100644 --- a/src/transformation/builtins/function.ts +++ b/src/transformation/builtins/function.ts @@ -32,7 +32,7 @@ export function transformFunctionPrototypeCall( return transformLuaLibFunction(context, LuaLibFeature.FunctionBind, node, caller, ...params); case "call": return lua.createCallExpression(caller, params, node); - default: + case "toString": context.diagnostics.push(unsupportedProperty(expression.name, "function", expressionName)); } } @@ -60,7 +60,10 @@ export function transformFunctionProperty( ? lua.createBinaryExpression(nparams, lua.createNumericLiteral(1), lua.SyntaxKind.SubtractionOperator) : nparams; - default: + case "arguments": + case "caller": + case "displayName": + case "name": context.diagnostics.push(unsupportedProperty(node.name, "function", node.name.text)); } } diff --git a/src/transformation/builtins/index.ts b/src/transformation/builtins/index.ts index 5795b7619..a067ecde3 100644 --- a/src/transformation/builtins/index.ts +++ b/src/transformation/builtins/index.ts @@ -41,7 +41,7 @@ export function transformBuiltinPropertyAccessExpression( return transformArrayProperty(context, node); } - if (isFunctionType(context, ownerType)) { + if (isFunctionType(ownerType)) { return transformFunctionProperty(context, node); } @@ -131,7 +131,7 @@ export function transformBuiltinCallExpression( return transformArrayPrototypeCall(context, node); } - if (isFunctionType(context, ownerType) && hasStandardLibrarySignature(context, node)) { + if (isFunctionType(ownerType) && hasStandardLibrarySignature(context, node)) { if (isOptionalCall) return unsupportedOptionalCall(); return transformFunctionPrototypeCall(context, node); } diff --git a/src/transformation/utils/language-extensions.ts b/src/transformation/utils/language-extensions.ts index 3861cbf57..698dfc021 100644 --- a/src/transformation/utils/language-extensions.ts +++ b/src/transformation/utils/language-extensions.ts @@ -118,6 +118,12 @@ export function isExtensionType(type: ts.Type, extensionKind: ExtensionKind): bo return typeBrand !== undefined && type.getProperty(typeBrand) !== undefined; } +export function getExtensionKinds(type: ts.Type): ExtensionKind[] { + return (Object.keys(extensionKindToTypeBrand) as Array).filter( + e => type.getProperty(extensionKindToTypeBrand[e]) !== undefined + ); +} + export function isExtensionValue( context: TransformationContext, symbol: ts.Symbol, diff --git a/src/transformation/utils/lua-ast.ts b/src/transformation/utils/lua-ast.ts index 0670c51fc..9508479ce 100644 --- a/src/transformation/utils/lua-ast.ts +++ b/src/transformation/utils/lua-ast.ts @@ -157,7 +157,9 @@ export function createLocalOrExportedOrGlobalDeclaration( const isTopLevelVariable = scope.type === ScopeType.File; if (context.isModule || !isTopLevelVariable) { - if (!isFunctionDeclaration && hasMultipleReferences(scope, lhs)) { + const isLuaFunctionExpression = rhs && !Array.isArray(rhs) && lua.isFunctionExpression(rhs); + const isSafeRecursiveFunctionDeclaration = isFunctionDeclaration && isLuaFunctionExpression; + if (!isSafeRecursiveFunctionDeclaration && hasMultipleReferences(scope, lhs)) { // Split declaration and assignment of identifiers that reference themselves in their declaration. // Put declaration above preceding statements in case the identifier is referenced in those. const precedingDeclaration = lua.createVariableDeclarationStatement(lhs, undefined, tsOriginal); @@ -166,10 +168,8 @@ export function createLocalOrExportedOrGlobalDeclaration( assignment = lua.createAssignmentStatement(lhs, rhs, tsOriginal); } - if (!isFunctionDeclaration) { - // Remember local variable declarations for hoisting later - addScopeVariableDeclaration(scope, precedingDeclaration); - } + // Remember local variable declarations for hoisting later + addScopeVariableDeclaration(scope, precedingDeclaration); } else { declaration = lua.createVariableDeclarationStatement(lhs, rhs, tsOriginal); diff --git a/src/transformation/utils/typescript/types.ts b/src/transformation/utils/typescript/types.ts index 2c6e69dcb..74e41db39 100644 --- a/src/transformation/utils/typescript/types.ts +++ b/src/transformation/utils/typescript/types.ts @@ -107,7 +107,6 @@ export function isArrayType(context: TransformationContext, type: ts.Type): bool return forTypeOrAnySupertype(context, type, t => isExplicitArrayType(context, t)); } -export function isFunctionType(context: TransformationContext, type: ts.Type): boolean { - const typeNode = context.checker.typeToTypeNode(type, undefined, ts.NodeBuilderFlags.InTypeAlias); - return typeNode !== undefined && ts.isFunctionTypeNode(typeNode); +export function isFunctionType(type: ts.Type): boolean { + return type.getCallSignatures().length > 0; } diff --git a/src/transformation/visitors/function.ts b/src/transformation/visitors/function.ts index dd749d202..532851929 100644 --- a/src/transformation/visitors/function.ts +++ b/src/transformation/visitors/function.ts @@ -6,6 +6,7 @@ import { AnnotationKind, getNodeAnnotations } from "../utils/annotations"; import { annotationRemoved } from "../utils/diagnostics"; import { createDefaultExportStringLiteral, hasDefaultExportModifier } from "../utils/export"; import { ContextType, getFunctionContextType } from "../utils/function-context"; +import { getExtensionKinds } from "../utils/language-extensions"; import { createExportsIdentifier, createLocalOrExportedOrGlobalDeclaration, @@ -15,6 +16,7 @@ import { import { LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; import { transformInPrecedingStatementScope } from "../utils/preceding-statements"; import { peekScope, performHoisting, popScope, pushScope, Scope, ScopeType } from "../utils/scope"; +import { isFunctionType } from "../utils/typescript"; import { isAsyncFunction, wrapInAsyncAwaiter } from "./async-await"; import { transformIdentifier } from "./identifier"; import { transformExpressionBodyToReturnStatement } from "./return"; @@ -51,6 +53,42 @@ function isRestParameterReferenced(identifier: lua.Identifier, scope: Scope): bo return references !== undefined && references.length > 0; } +export function createCallableTable(functionExpression: lua.Expression): lua.Expression { + // __call metamethod receives the table as the first argument, so we need to add a dummy parameter + if (lua.isFunctionExpression(functionExpression)) { + functionExpression.params?.unshift(lua.createAnonymousIdentifier()); + } else { + // functionExpression may have been replaced (lib functions, etc...), + // so we create a forwarding function to eat the extra argument + functionExpression = lua.createFunctionExpression( + lua.createBlock([ + lua.createReturnStatement([lua.createCallExpression(functionExpression, [lua.createDotsLiteral()])]), + ]), + [lua.createAnonymousIdentifier()], + lua.createDotsLiteral(), + lua.FunctionExpressionFlags.Inline + ); + } + return lua.createCallExpression(lua.createIdentifier("setmetatable"), [ + lua.createTableExpression(), + lua.createTableExpression([ + lua.createTableFieldExpression(functionExpression, lua.createStringLiteral("__call")), + ]), + ]); +} + +export function isFunctionTypeWithProperties(functionType: ts.Type) { + if (functionType.isUnion()) { + return functionType.types.some(isFunctionTypeWithProperties); + } else { + return ( + isFunctionType(functionType) && + functionType.getProperties().length > 0 && + getExtensionKinds(functionType).length === 0 // ignore TSTL extension functions like $range + ); + } +} + export function transformFunctionBodyContent(context: TransformationContext, body: ts.ConciseBody): lua.Statement[] { if (!ts.isBlock(body)) { const [precedingStatements, returnStatement] = transformInPrecedingStatementScope(context, () => @@ -247,9 +285,16 @@ export function transformFunctionLikeDeclaration( // Only handle if the name is actually referenced inside the function if (isReferenced) { const nameIdentifier = transformIdentifier(context, node.name); - context.addPrecedingStatements( - lua.createVariableDeclarationStatement(nameIdentifier, functionExpression) - ); + if (isFunctionTypeWithProperties(context.checker.getTypeAtLocation(node))) { + context.addPrecedingStatements([ + lua.createVariableDeclarationStatement(nameIdentifier), + lua.createAssignmentStatement(nameIdentifier, createCallableTable(functionExpression)), + ]); + } else { + context.addPrecedingStatements( + lua.createVariableDeclarationStatement(nameIdentifier, functionExpression) + ); + } return lua.cloneIdentifier(nameIdentifier); } } @@ -291,7 +336,13 @@ export const transformFunctionDeclaration: FunctionVisitor = (expression, context) => { diff --git a/src/transformation/visitors/variable-declaration.ts b/src/transformation/visitors/variable-declaration.ts index a7e4a36ca..e7e604404 100644 --- a/src/transformation/visitors/variable-declaration.ts +++ b/src/transformation/visitors/variable-declaration.ts @@ -8,6 +8,7 @@ import { addExportToIdentifier } from "../utils/export"; import { createLocalOrExportedOrGlobalDeclaration, createUnpackCall, wrapInTable } from "../utils/lua-ast"; import { LuaLibFeature, transformLuaLibFunction } from "../utils/lualib"; import { transformInPrecedingStatementScope } from "../utils/preceding-statements"; +import { createCallableTable, isFunctionTypeWithProperties } from "./function"; import { transformIdentifier } from "./identifier"; import { isMultiReturnCall } from "./language-extensions/multi"; import { transformPropertyName } from "./literal"; @@ -257,7 +258,18 @@ export function transformVariableDeclaration( // Find variable identifier const identifierName = transformIdentifier(context, statement.name); const value = statement.initializer && context.transformExpression(statement.initializer); - return createLocalOrExportedOrGlobalDeclaration(context, identifierName, value, statement); + + // Wrap functions being assigned to a type that contains additional properties in a callable table + // This catches 'const foo = function() {}; foo.bar = "FOOBAR";' + const wrappedValue = + value && + // Skip named function expressions because they will have been wrapped already + !(statement.initializer && ts.isFunctionExpression(statement.initializer) && statement.initializer.name) && + isFunctionTypeWithProperties(context.checker.getTypeAtLocation(statement.name)) + ? createCallableTable(value) + : value; + + return createLocalOrExportedOrGlobalDeclaration(context, identifierName, wrappedValue, statement); } else if (ts.isArrayBindingPattern(statement.name) || ts.isObjectBindingPattern(statement.name)) { return transformBindingVariableDeclaration(context, statement.name, statement.initializer); } else { diff --git a/test/unit/functions/functionProperties.spec.ts b/test/unit/functions/functionProperties.spec.ts new file mode 100644 index 000000000..85cdf9e31 --- /dev/null +++ b/test/unit/functions/functionProperties.spec.ts @@ -0,0 +1,175 @@ +import * as util from "../../util"; + +test("property on function", () => { + util.testFunction` + function foo(s: string) { return s; } + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("property on void function", () => { + util.testFunction` + function foo(this: void, s: string) { return s; } + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("property on recursively referenced function", () => { + util.testFunction` + function foo(s: string) { return s + foo.bar; } + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("property on hoisted function", () => { + util.testFunction` + foo.bar = "bar"; + function foo(s: string) { return s; } + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("function merged with namespace", () => { + util.testModule` + function foo(s: string) { return s; } + namespace foo { + export let bar = "bar"; + } + export const result = foo("foo") + foo.bar; + ` + .setReturnExport("result") + .expectToEqual("foobar"); +}); + +test("function with property assigned to variable", () => { + util.testFunction` + const foo = function(s: string) { return s; }; + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("void function with property assigned to variable", () => { + util.testFunction` + const foo = function(this: void, s: string) { return s; }; + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("recursively referenced function with property assigned to variable", () => { + util.testFunction` + const foo = function(s: string) { return s + foo.bar; }; + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("named recursively referenced function with property assigned to variable", () => { + util.testFunction` + const foo = function baz(s: string) { return s + foo.bar + baz.bar; }; + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("arrow function with property assigned to variable", () => { + util.testFunction` + const foo: { (s: string): string; bar: string; } = s => s; + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("void arrow function with property assigned to variable", () => { + util.testFunction` + const foo: { (this: void, s: string): string; bar: string; } = s => s; + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("recursively referenced arrow function with property assigned to variable", () => { + util.testFunction` + const foo: { (s: string): string; bar: string; } = s => s + foo.bar; + foo.bar = "bar"; + return foo("foo") + foo.bar; + `.expectToMatchJsResult(); +}); + +test("property on generator function", () => { + util.testFunction` + function *foo(s: string) { yield s; } + foo.bar = "bar"; + for (const s of foo("foo")) { + return s + foo.bar; + } + `.expectToMatchJsResult(); +}); + +test("generator function assigned to variable", () => { + util.testFunction` + const foo = function *(s: string) { yield s; } + foo.bar = "bar"; + for (const s of foo("foo")) { + return s + foo.bar; + } + `.expectToMatchJsResult(); +}); + +test("property on async function", () => { + util.testFunction` + let result = ""; + async function foo(s: string) { result = s + foo.bar; } + foo.bar = "bar"; + void foo("foo"); + return result; + `.expectToMatchJsResult(); +}); + +test("async function with property assigned to variable", () => { + util.testFunction` + let result = ""; + const foo = async function(s: string) { result = s + foo.bar; } + foo.bar = "bar"; + void foo("foo"); + return result; + `.expectToMatchJsResult(); +}); + +test("async arrow function with property assigned to variable", () => { + util.testFunction` + let result = ""; + const foo: { (s: string): Promise; bar: string; } = async s => { result = s + foo.bar; }; + foo.bar = "bar"; + void foo("foo"); + return result; + `.expectToMatchJsResult(); +}); + +test("call function with property using call method", () => { + util.testFunction` + function foo(s: string) { return this + s; } + foo.baz = "baz"; + return foo.call("foo", "bar") + foo.baz; + `.expectToMatchJsResult(); +}); + +test("call function with property using apply method", () => { + util.testFunction` + function foo(s: string) { return this + s; } + foo.baz = "baz"; + return foo.apply("foo", ["bar"]) + foo.baz; + `.expectToMatchJsResult(); +}); + +test("call function with property using bind method", () => { + util.testFunction` + function foo(s: string) { return this + s; } + foo.baz = "baz"; + return foo.bind("foo", "bar")() + foo.baz; + `.expectToMatchJsResult(); +});