diff --git a/src/LuaLib.ts b/src/LuaLib.ts index b2f6383a6..4bf86aa5c 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -38,6 +38,7 @@ export enum LuaLibFeature { CloneDescriptor = "CloneDescriptor", CountVarargs = "CountVarargs", Decorate = "Decorate", + DecorateLegacy = "DecorateLegacy", DecorateParam = "DecorateParam", Delete = "Delete", DelegatedYield = "DelegatedYield", diff --git a/src/lualib/Decorate.ts b/src/lualib/Decorate.ts index f9f8d083e..fd65f44e8 100644 --- a/src/lualib/Decorate.ts +++ b/src/lualib/Decorate.ts @@ -1,47 +1,20 @@ /** - * SEE: https://github.com/Microsoft/TypeScript/blob/master/src/compiler/transformers/ts.ts#L3598 + * TypeScript 5.0 decorators */ -import { __TS__ObjectGetOwnPropertyDescriptor } from "./ObjectGetOwnPropertyDescriptor"; -import { __TS__SetDescriptor } from "./SetDescriptor"; import { Decorator } from "./Decorator"; -export function __TS__Decorate( - this: void, - decorators: Array>, - target: TTarget, - key?: TKey, - desc?: any +export function __TS__Decorate( + this: TClass, + originalValue: TTarget, + decorators: Array>, + context: DecoratorContext ): TTarget { - let result = target; + let result = originalValue; for (let i = decorators.length; i >= 0; i--) { const decorator = decorators[i]; if (decorator !== undefined) { - const oldResult = result; - - if (key === undefined) { - result = decorator(result); - } else if (desc === true) { - const value = rawget(target, key); - const descriptor = __TS__ObjectGetOwnPropertyDescriptor(target, key) ?? { - configurable: true, - writable: true, - value, - }; - const desc = decorator(target, key, descriptor) || descriptor; - const isSimpleValue = desc.configurable === true && desc.writable === true && !desc.get && !desc.set; - if (isSimpleValue) { - rawset(target, key, desc.value); - } else { - __TS__SetDescriptor(target, key, { ...descriptor, ...desc }); - } - } else if (desc === false) { - result = decorator(target, key, desc); - } else { - result = decorator(target, key); - } - - result = result || oldResult; + result = decorator.call(this, result, context) ?? result; } } diff --git a/src/lualib/DecorateLegacy.ts b/src/lualib/DecorateLegacy.ts new file mode 100644 index 000000000..0369623d3 --- /dev/null +++ b/src/lualib/DecorateLegacy.ts @@ -0,0 +1,54 @@ +/** + * Old-style decorators, activated by enabling the experimentalDecorators flag + */ +import { __TS__ObjectGetOwnPropertyDescriptor } from "./ObjectGetOwnPropertyDescriptor"; +import { __TS__SetDescriptor } from "./SetDescriptor"; + +export type LegacyDecorator = ( + target: TTarget, + key?: TKey, + descriptor?: PropertyDescriptor +) => TTarget; + +export function __TS__DecorateLegacy( + this: void, + decorators: Array>, + target: TTarget, + key?: TKey, + desc?: any +): TTarget { + let result = target; + + for (let i = decorators.length; i >= 0; i--) { + const decorator = decorators[i]; + if (decorator !== undefined) { + const oldResult = result; + + if (key === undefined) { + result = decorator(result); + } else if (desc === true) { + const value = rawget(target, key); + const descriptor = __TS__ObjectGetOwnPropertyDescriptor(target, key) ?? { + configurable: true, + writable: true, + value, + }; + const desc = decorator(target, key, descriptor) || descriptor; + const isSimpleValue = desc.configurable === true && desc.writable === true && !desc.get && !desc.set; + if (isSimpleValue) { + rawset(target, key, desc.value); + } else { + __TS__SetDescriptor(target, key, { ...descriptor, ...desc }); + } + } else if (desc === false) { + result = decorator(target, key, desc); + } else { + result = decorator(target, key); + } + + result = result || oldResult; + } + } + + return result; +} diff --git a/src/lualib/DecorateParam.ts b/src/lualib/DecorateParam.ts index 2eb59bc96..0165294b2 100644 --- a/src/lualib/DecorateParam.ts +++ b/src/lualib/DecorateParam.ts @@ -1,4 +1,4 @@ -import { Decorator } from "./Decorator"; +import type { LegacyDecorator } from "./DecorateLegacy"; type ParamDecorator = ( target: TTarget, @@ -9,6 +9,6 @@ export function __TS__DecorateParam -): Decorator { +): LegacyDecorator { return (target: TTarget, key?: TKey) => decorator(target, key, paramIndex); } diff --git a/src/lualib/Decorator.d.ts b/src/lualib/Decorator.d.ts index 338b11b3f..d8f84febf 100644 --- a/src/lualib/Decorator.d.ts +++ b/src/lualib/Decorator.d.ts @@ -1,5 +1 @@ -export type Decorator = ( - target: TTarget, - key?: TKey, - descriptor?: PropertyDescriptor -) => TTarget; +export type Decorator = (target: TTarget, context: DecoratorContext) => TTarget; diff --git a/src/transformation/utils/diagnostics.ts b/src/transformation/utils/diagnostics.ts index 8fe4a22f5..137bd15b8 100644 --- a/src/transformation/utils/diagnostics.ts +++ b/src/transformation/utils/diagnostics.ts @@ -168,3 +168,7 @@ export const invalidSpreadInCallExtension = createErrorDiagnosticFactory( export const cannotAssignToNodeOfKind = createErrorDiagnosticFactory( (kind: lua.SyntaxKind) => `Cannot create assignment assigning to a node of type ${lua.SyntaxKind[kind]}.` ); + +export const incompleteFieldDecoratorWarning = createWarningDiagnosticFactory( + "You are using a class field decorator, note that tstl ignores returned value initializers!" +); diff --git a/src/transformation/visitors/class/decorators.ts b/src/transformation/visitors/class/decorators.ts index 4b913485c..11e4b75c7 100644 --- a/src/transformation/visitors/class/decorators.ts +++ b/src/transformation/visitors/class/decorators.ts @@ -1,9 +1,13 @@ import * as ts from "typescript"; import * as lua from "../../../LuaAST"; import { TransformationContext } from "../../context"; -import { decoratorInvalidContext } from "../../utils/diagnostics"; +import { decoratorInvalidContext, incompleteFieldDecoratorWarning } from "../../utils/diagnostics"; import { ContextType, getFunctionContextType } from "../../utils/function-context"; import { LuaLibFeature, transformLuaLibFunction } from "../../utils/lualib"; +import { isNonNull } from "../../../utils"; +import { transformMemberExpressionOwnerName, transformMethodName } from "./members/method"; +import { transformPropertyName } from "../literal"; +import { isPrivateNode, isStaticNode } from "./utils"; export function transformDecoratorExpression(context: TransformationContext, decorator: ts.Decorator): lua.Expression { const expression = decorator.expression; @@ -16,7 +20,161 @@ export function transformDecoratorExpression(context: TransformationContext, dec return context.transformExpression(expression); } -export function createDecoratingExpression( +export function createClassDecoratingExpression( + context: TransformationContext, + classDeclaration: ts.ClassDeclaration | ts.ClassExpression, + className: lua.Expression +): lua.Expression { + const classDecorators = + ts.getDecorators(classDeclaration)?.map(d => transformDecoratorExpression(context, d)) ?? []; + + // If experimentalDecorators flag is set, decorate with legacy decorator logic + if (context.options.experimentalDecorators) { + return createLegacyDecoratingExpression(context, classDeclaration.kind, classDecorators, className); + } + + // Else: TypeScript 5.0 decorator + return createDecoratingExpression(context, className, className, classDecorators, { + kind: lua.createStringLiteral("class"), + name: lua.createStringLiteral(classDeclaration.name?.getText() ?? ""), + }); +} + +export function createClassMethodDecoratingExpression( + context: TransformationContext, + methodDeclaration: ts.MethodDeclaration, + originalMethod: lua.Expression, + className: lua.Identifier +): lua.Expression { + const parameterDecorators = getParameterDecorators(context, methodDeclaration); + const methodDecorators = + ts.getDecorators(methodDeclaration)?.map(d => transformDecoratorExpression(context, d)) ?? []; + + const methodName = transformMethodName(context, methodDeclaration); + + // If experimentalDecorators flag is set, decorate with legacy decorator logic + if (context.options.experimentalDecorators) { + const methodTable = transformMemberExpressionOwnerName(methodDeclaration, className); + return createLegacyDecoratingExpression( + context, + methodDeclaration.kind, + [...methodDecorators, ...parameterDecorators], + methodTable, + methodName + ); + } + + // Else: TypeScript 5.0 decorator + return createDecoratingExpression(context, className, originalMethod, methodDecorators, { + kind: lua.createStringLiteral("method"), + name: methodName, + private: lua.createBooleanLiteral(isPrivateNode(methodDeclaration)), + static: lua.createBooleanLiteral(isStaticNode(methodDeclaration)), + }); +} + +export function createClassAccessorDecoratingExpression( + context: TransformationContext, + accessor: ts.AccessorDeclaration, + originalAccessor: lua.Expression, + className: lua.Identifier +): lua.Expression { + const accessorDecorators = ts.getDecorators(accessor)?.map(d => transformDecoratorExpression(context, d)) ?? []; + const propertyName = transformPropertyName(context, accessor.name); + + // If experimentalDecorators flag is set, decorate with legacy decorator logic + if (context.options.experimentalDecorators) { + const propertyOwnerTable = transformMemberExpressionOwnerName(accessor, className); + + return createLegacyDecoratingExpression( + context, + accessor.kind, + accessorDecorators, + propertyOwnerTable, + propertyName + ); + } + + // Else: TypeScript 5.0 decorator + return createDecoratingExpression(context, className, originalAccessor, accessorDecorators, { + kind: lua.createStringLiteral(accessor.kind === ts.SyntaxKind.SetAccessor ? "setter" : "getter"), + name: propertyName, + private: lua.createBooleanLiteral(isPrivateNode(accessor)), + static: lua.createBooleanLiteral(isStaticNode(accessor)), + }); +} + +export function createClassPropertyDecoratingExpression( + context: TransformationContext, + property: ts.PropertyDeclaration, + className: lua.Identifier +): lua.Expression { + const decorators = ts.getDecorators(property) ?? []; + const propertyDecorators = decorators.map(d => transformDecoratorExpression(context, d)); + + // If experimentalDecorators flag is set, decorate with legacy decorator logic + if (context.options.experimentalDecorators) { + const propertyName = transformPropertyName(context, property.name); + const propertyOwnerTable = transformMemberExpressionOwnerName(property, className); + + return createLegacyDecoratingExpression( + context, + property.kind, + propertyDecorators, + propertyOwnerTable, + propertyName + ); + } + + // Else: TypeScript 5.0 decorator + + // Add a diagnostic when something is returned from a field decorator + for (const decorator of decorators) { + const signature = context.checker.getResolvedSignature(decorator); + const decoratorReturnType = signature?.getReturnType(); + // If return type of decorator is NOT void + if (decoratorReturnType && (decoratorReturnType.flags & ts.TypeFlags.Void) === 0) { + context.diagnostics.push(incompleteFieldDecoratorWarning(property)); + } + } + + return createDecoratingExpression(context, className, lua.createNilLiteral(), propertyDecorators, { + kind: lua.createStringLiteral("field"), + name: lua.createStringLiteral(property.name.getText()), + private: lua.createBooleanLiteral(isPrivateNode(property)), + static: lua.createBooleanLiteral(isStaticNode(property)), + }); +} + +function createDecoratingExpression( + context: TransformationContext, + className: lua.Expression, + originalValue: TValue, + decorators: lua.Expression[], + decoratorContext: Record +): lua.Expression { + const decoratorTable = lua.createTableExpression(decorators.map(d => lua.createTableFieldExpression(d))); + const decoratorContextTable = objectToLuaTableLiteral(decoratorContext); + + return transformLuaLibFunction( + context, + LuaLibFeature.Decorate, + undefined, + className, + originalValue, + decoratorTable, + decoratorContextTable + ); +} + +function objectToLuaTableLiteral(obj: Record): lua.Expression { + return lua.createTableExpression( + Object.entries(obj).map(([key, value]) => lua.createTableFieldExpression(value, lua.createStringLiteral(key))) + ); +} + +// Legacy decorators: +function createLegacyDecoratingExpression( context: TransformationContext, kind: ts.SyntaxKind, decorators: lua.Expression[], @@ -35,5 +193,39 @@ export function createDecoratingExpression( trailingExpressions.push(isMethodOrAccessor ? lua.createBooleanLiteral(true) : lua.createNilLiteral()); } - return transformLuaLibFunction(context, LuaLibFeature.Decorate, undefined, ...trailingExpressions); + return transformLuaLibFunction(context, LuaLibFeature.DecorateLegacy, undefined, ...trailingExpressions); +} + +function getParameterDecorators( + context: TransformationContext, + node: ts.FunctionLikeDeclarationBase +): lua.CallExpression[] { + return node.parameters + .flatMap((parameter, index) => + ts + .getDecorators(parameter) + ?.map(decorator => + transformLuaLibFunction( + context, + LuaLibFeature.DecorateParam, + node, + lua.createNumericLiteral(index), + transformDecoratorExpression(context, decorator) + ) + ) + ) + .filter(isNonNull); +} + +export function createConstructorDecoratingExpression( + context: TransformationContext, + node: ts.ConstructorDeclaration, + className: lua.Identifier +): lua.Statement | undefined { + const parameterDecorators = getParameterDecorators(context, node); + + if (parameterDecorators.length > 0) { + const decorateMethod = createLegacyDecoratingExpression(context, node.kind, parameterDecorators, className); + return lua.createExpressionStatement(decorateMethod); + } } diff --git a/src/transformation/visitors/class/index.ts b/src/transformation/visitors/class/index.ts index c5b4d9630..e19e2ccc5 100644 --- a/src/transformation/visitors/class/index.ts +++ b/src/transformation/visitors/class/index.ts @@ -11,23 +11,16 @@ import { import { createSelfIdentifier } from "../../utils/lua-ast"; import { createSafeName, isUnsafeName } from "../../utils/safe-names"; import { transformIdentifier } from "../identifier"; -import { createDecoratingExpression, transformDecoratorExpression } from "./decorators"; +import { createClassDecoratingExpression, createConstructorDecoratingExpression } from "./decorators"; import { transformAccessorDeclarations } from "./members/accessors"; import { createConstructorName, transformConstructorDeclaration } from "./members/constructor"; -import { - createPropertyDecoratingExpression, - transformClassInstanceFields, - transformStaticPropertyDeclaration, -} from "./members/fields"; -import { - createConstructorDecoratingExpression, - createMethodDecoratingExpression, - transformMethodDeclaration, -} from "./members/method"; +import { transformClassInstanceFields, transformStaticPropertyDeclaration } from "./members/fields"; +import { transformMethodDeclaration } from "./members/method"; import { getExtendedNode, getExtendedType, isStaticNode } from "./utils"; import { createClassSetup } from "./setup"; import { LuaTarget } from "../../../CompilerOptions"; import { transformInPrecedingStatementScope } from "../../utils/preceding-statements"; +import { createClassPropertyDecoratingExpression } from "./decorators"; export const transformClassDeclaration: FunctionVisitor = (declaration, context) => { // If declaration is a default export, transform to export variable assignment instead @@ -121,6 +114,7 @@ function transformClassLikeDeclaration( if (constructorResult) result.push(constructorResult); + // Legacy constructor decorator const decoratingExpression = createConstructorDecoratingExpression(context, constructor, localClassName); if (decoratingExpression) result.push(decoratingExpression); } else if (!extendedType) { @@ -165,51 +159,39 @@ function transformClassLikeDeclaration( ); } - // Transform accessors - for (const member of classDeclaration.members) { - if (!ts.isAccessor(member)) continue; - const accessors = context.resolver.getAllAccessorDeclarations(member); - if (accessors.firstAccessor !== member) continue; - - const accessorsResult = transformAccessorDeclarations(context, accessors, localClassName); - if (accessorsResult) { - result.push(accessorsResult); - } - } - - const decorationStatements: lua.Statement[] = []; - + // Transform class members for (const member of classDeclaration.members) { if (ts.isAccessor(member)) { - const expression = createPropertyDecoratingExpression(context, member, localClassName); - if (expression) decorationStatements.push(lua.createExpressionStatement(expression)); + // Accessors + const accessors = context.resolver.getAllAccessorDeclarations(member); + if (accessors.firstAccessor !== member) continue; + + const accessorsResult = transformAccessorDeclarations(context, accessors, localClassName); + if (accessorsResult) { + result.push(accessorsResult); + } } else if (ts.isMethodDeclaration(member)) { + // Methods const statement = transformMethodDeclaration(context, member, localClassName); if (statement) result.push(statement); - if (member.body) { - const statement = createMethodDecoratingExpression(context, member, localClassName); - if (statement) decorationStatements.push(statement); - } } else if (ts.isPropertyDeclaration(member)) { + // Properties if (isStaticNode(member)) { const statement = transformStaticPropertyDeclaration(context, member, localClassName); - if (statement) decorationStatements.push(statement); + if (statement) result.push(statement); + } + + if (ts.getDecorators(member)?.length) { + result.push( + lua.createExpressionStatement(createClassPropertyDecoratingExpression(context, member, className)) + ); } - const expression = createPropertyDecoratingExpression(context, member, localClassName); - if (expression) decorationStatements.push(lua.createExpressionStatement(expression)); } } - result.push(...decorationStatements); - // Decorate the class if (ts.canHaveDecorators(classDeclaration) && ts.getDecorators(classDeclaration)) { - const decoratingExpression = createDecoratingExpression( - context, - classDeclaration.kind, - ts.getDecorators(classDeclaration)?.map(d => transformDecoratorExpression(context, d)) ?? [], - localClassName - ); + const decoratingExpression = createClassDecoratingExpression(context, classDeclaration, localClassName); const decoratingStatement = lua.createAssignmentStatement(localClassName, decoratingExpression); result.push(decoratingStatement); diff --git a/src/transformation/visitors/class/members/accessors.ts b/src/transformation/visitors/class/members/accessors.ts index fe34a3d8a..165d3fa88 100644 --- a/src/transformation/visitors/class/members/accessors.ts +++ b/src/transformation/visitors/class/members/accessors.ts @@ -7,11 +7,27 @@ import { transformFunctionBody, transformParameters } from "../../function"; import { transformPropertyName } from "../../literal"; import { isStaticNode } from "../utils"; import { createPrototypeName } from "./constructor"; +import { createClassAccessorDecoratingExpression } from "../decorators"; -function transformAccessor(context: TransformationContext, node: ts.AccessorDeclaration): lua.FunctionExpression { +function transformAccessor( + context: TransformationContext, + node: ts.AccessorDeclaration, + className: lua.Identifier +): lua.Expression { const [params, dot, restParam] = transformParameters(context, node.parameters, createSelfIdentifier()); const body = node.body ? transformFunctionBody(context, node.parameters, node.body, restParam)[0] : []; - return lua.createFunctionExpression(lua.createBlock(body), params, dot, lua.NodeFlags.Declaration); + const accessorFunction = lua.createFunctionExpression( + lua.createBlock(body), + params, + dot, + lua.NodeFlags.Declaration + ); + + if (ts.getDecorators(node)?.length) { + return createClassAccessorDecoratingExpression(context, node, accessorFunction, className); + } else { + return accessorFunction; + } } export function transformAccessorDeclarations( @@ -23,12 +39,12 @@ export function transformAccessorDeclarations( const descriptor = lua.createTableExpression([]); if (getAccessor) { - const getterFunction = transformAccessor(context, getAccessor); + const getterFunction = transformAccessor(context, getAccessor, className); descriptor.fields.push(lua.createTableFieldExpression(getterFunction, lua.createStringLiteral("get"))); } if (setAccessor) { - const setterFunction = transformAccessor(context, setAccessor); + const setterFunction = transformAccessor(context, setAccessor, className); descriptor.fields.push(lua.createTableFieldExpression(setterFunction, lua.createStringLiteral("set"))); } diff --git a/src/transformation/visitors/class/members/fields.ts b/src/transformation/visitors/class/members/fields.ts index 0e7c3e51f..2308eaac8 100644 --- a/src/transformation/visitors/class/members/fields.ts +++ b/src/transformation/visitors/class/members/fields.ts @@ -4,27 +4,6 @@ import { TransformationContext } from "../../../context"; import { createSelfIdentifier } from "../../../utils/lua-ast"; import { transformInPrecedingStatementScope } from "../../../utils/preceding-statements"; import { transformPropertyName } from "../../literal"; -import { createDecoratingExpression, transformDecoratorExpression } from "../decorators"; -import { transformMemberExpressionOwnerName } from "./method"; - -export function createPropertyDecoratingExpression( - context: TransformationContext, - node: ts.PropertyDeclaration | ts.AccessorDeclaration, - className: lua.Identifier -): lua.Expression | undefined { - if (!ts.canHaveDecorators(node)) return; - const decorators = ts.getDecorators(node); - if (!decorators) return; - const propertyName = transformPropertyName(context, node.name); - const propertyOwnerTable = transformMemberExpressionOwnerName(node, className); - return createDecoratingExpression( - context, - node.kind, - decorators.map(d => transformDecoratorExpression(context, d)), - propertyOwnerTable, - propertyName - ); -} export function transformClassInstanceFields( context: TransformationContext, @@ -63,5 +42,6 @@ export function transformStaticPropertyDeclaration( const fieldName = transformPropertyName(context, field.name); const value = context.transformExpression(field.initializer); const classField = lua.createTableIndexExpression(lua.cloneIdentifier(className), fieldName); + return lua.createAssignmentStatement(classField, value); } diff --git a/src/transformation/visitors/class/members/method.ts b/src/transformation/visitors/class/members/method.ts index 0c3c10672..d7dcf6d9e 100644 --- a/src/transformation/visitors/class/members/method.ts +++ b/src/transformation/visitors/class/members/method.ts @@ -4,10 +4,8 @@ import { TransformationContext } from "../../../context"; import { transformFunctionToExpression } from "../../function"; import { transformPropertyName } from "../../literal"; import { isStaticNode } from "../utils"; -import { createDecoratingExpression, transformDecoratorExpression } from "../decorators"; import { createPrototypeName } from "./constructor"; -import { transformLuaLibFunction, LuaLibFeature } from "../../../utils/lualib"; -import { isNonNull } from "../../../../utils"; +import { createClassMethodDecoratingExpression } from "../decorators"; export function transformMemberExpressionOwnerName( node: ts.PropertyDeclaration | ts.MethodDeclaration | ts.AccessorDeclaration, @@ -36,66 +34,27 @@ export function transformMethodDeclaration( const methodName = transformMethodName(context, node); const [functionExpression] = transformFunctionToExpression(context, node); - return lua.createAssignmentStatement( - lua.createTableIndexExpression(methodTable, methodName), - functionExpression, - node - ); -} - -export function getParameterDecorators( - context: TransformationContext, - node: ts.FunctionLikeDeclarationBase -): lua.CallExpression[] { - return node.parameters - .flatMap((parameter, index) => - ts - .getDecorators(parameter) - ?.map(decorator => - transformLuaLibFunction( - context, - LuaLibFeature.DecorateParam, - node, - lua.createNumericLiteral(index), - transformDecoratorExpression(context, decorator) - ) - ) - ) - .filter(isNonNull); -} - -export function createConstructorDecoratingExpression( - context: TransformationContext, - node: ts.ConstructorDeclaration, - className: lua.Identifier -): lua.Statement | undefined { - const parameterDecorators = getParameterDecorators(context, node); - - if (parameterDecorators.length > 0) { - const decorateMethod = createDecoratingExpression(context, node.kind, parameterDecorators, className); - return lua.createExpressionStatement(decorateMethod); - } -} - -export function createMethodDecoratingExpression( - context: TransformationContext, - node: ts.MethodDeclaration, - className: lua.Identifier -): lua.Statement | undefined { - const methodTable = transformMemberExpressionOwnerName(node, className); - const methodName = transformMethodName(context, node); - - const parameterDecorators = getParameterDecorators(context, node); - const methodDecorators = ts.getDecorators(node)?.map(d => transformDecoratorExpression(context, d)) ?? []; - - if (methodDecorators.length > 0 || parameterDecorators.length > 0) { - const decorateMethod = createDecoratingExpression( - context, - node.kind, - [...methodDecorators, ...parameterDecorators], - methodTable, - methodName + const methodHasDecorators = (ts.getDecorators(node)?.length ?? 0) > 0; + const methodHasParameterDecorators = node.parameters.some(p => (ts.getDecorators(p)?.length ?? 0) > 0); // Legacy decorators + + if (methodHasDecorators || methodHasParameterDecorators) { + if (context.options.experimentalDecorators) { + // Legacy decorator statement + return lua.createExpressionStatement( + createClassMethodDecoratingExpression(context, node, functionExpression, className) + ); + } else { + return lua.createAssignmentStatement( + lua.createTableIndexExpression(methodTable, methodName), + createClassMethodDecoratingExpression(context, node, functionExpression, className), + node + ); + } + } else { + return lua.createAssignmentStatement( + lua.createTableIndexExpression(methodTable, methodName), + functionExpression, + node ); - return lua.createExpressionStatement(decorateMethod); } } diff --git a/src/transformation/visitors/class/utils.ts b/src/transformation/visitors/class/utils.ts index d5b960662..0cd4384bb 100644 --- a/src/transformation/visitors/class/utils.ts +++ b/src/transformation/visitors/class/utils.ts @@ -1,6 +1,10 @@ import * as ts from "typescript"; import { TransformationContext } from "../../context"; +export function isPrivateNode(node: ts.HasModifiers): boolean { + return node.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) === true; +} + export function isStaticNode(node: ts.HasModifiers): boolean { return node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) === true; } diff --git a/test/unit/classes/__snapshots__/decorators.spec.ts.snap b/test/unit/classes/__snapshots__/decorators.spec.ts.snap index e2f95d920..681e4fa3d 100644 --- a/test/unit/classes/__snapshots__/decorators.spec.ts.snap +++ b/test/unit/classes/__snapshots__/decorators.spec.ts.snap @@ -6,15 +6,57 @@ local __TS__Class = ____lualib.__TS__Class local __TS__Decorate = ____lualib.__TS__Decorate local ____exports = {} function ____exports.__main(self) - local function decorator(constructor) + local function decorator(constructor, context) end local TestClass = __TS__Class() TestClass.name = "TestClass" function TestClass.prototype.____constructor(self) end - TestClass = __TS__Decorate({decorator}, TestClass) + TestClass = __TS__Decorate(TestClass, TestClass, {decorator}, {kind = "class", name = "TestClass"}) end return ____exports" `; exports[`Throws error if decorator function has void context: diagnostics 1`] = `"main.ts(4,9): error TSTL: Decorator function cannot have 'this: void'."`; + +exports[`class field decorator warns the return value is ignored: code 1`] = ` +"local ____lualib = require("lualib_bundle") +local __TS__Class = ____lualib.__TS__Class +local __TS__Decorate = ____lualib.__TS__Decorate +local ____exports = {} +function ____exports.__main(self) + local fieldDecoratorContext + local function fieldDecorator(self, _, context) + fieldDecoratorContext = context + return function(____, initialValue) return initialValue * 12 end + end + local TestClass = __TS__Class() + TestClass.name = "TestClass" + function TestClass.prototype.____constructor(self) + self.value = 22 + end + __TS__Decorate(TestClass, nil, {fieldDecorator}, {kind = "field", name = "value", private = false, static = false}) +end +return ____exports" +`; + +exports[`class field decorator warns the return value is ignored: diagnostics 1`] = `"main.ts(11,13): warning TSTL: You are using a class field decorator, note that tstl ignores returned value initializers!"`; + +exports[`legacy experimentalDecorators Throws error if decorator function has void context: code 1`] = ` +"local ____lualib = require("lualib_bundle") +local __TS__Class = ____lualib.__TS__Class +local __TS__DecorateLegacy = ____lualib.__TS__DecorateLegacy +local ____exports = {} +function ____exports.__main(self) + local function decorator(constructor) + end + local TestClass = __TS__Class() + TestClass.name = "TestClass" + function TestClass.prototype.____constructor(self) + end + TestClass = __TS__DecorateLegacy({decorator}, TestClass) +end +return ____exports" +`; + +exports[`legacy experimentalDecorators Throws error if decorator function has void context: diagnostics 1`] = `"main.ts(4,13): error TSTL: Decorator function cannot have 'this: void'."`; diff --git a/test/unit/classes/classes.spec.ts b/test/unit/classes/classes.spec.ts index f9ab4fe61..2e447ab54 100644 --- a/test/unit/classes/classes.spec.ts +++ b/test/unit/classes/classes.spec.ts @@ -749,9 +749,9 @@ test("default exported anonymous class has 'default' name property", () => { }); // https://github.com/TypeScriptToLua/TypeScriptToLua/issues/584 -test("constructor class name available with constructor", () => { +test("constructor class name available with decorator", () => { util.testModule` - const decorator = any>(constructor: T) => class extends constructor {}; + const decorator = any>(constructor: T, context: ClassDecoratorContext) => class extends constructor {}; @decorator class MyClass {} diff --git a/test/unit/classes/decorators.spec.ts b/test/unit/classes/decorators.spec.ts index abc4b948b..d7399f058 100644 --- a/test/unit/classes/decorators.spec.ts +++ b/test/unit/classes/decorators.spec.ts @@ -1,27 +1,37 @@ -import { decoratorInvalidContext } from "../../../src/transformation/utils/diagnostics"; +import { + decoratorInvalidContext, + incompleteFieldDecoratorWarning, +} from "../../../src/transformation/utils/diagnostics"; import * as util from "../../util"; test("Class decorator with no parameters", () => { util.testFunction` - function setBool {}>(constructor: T) { + let classDecoratorContext; + + function classDecorator {}>(constructor: T, context: ClassDecoratorContext) { + classDecoratorContext = context; + return class extends constructor { decoratorBool = true; } } - @setBool + @classDecorator class TestClass { public decoratorBool = false; } - return new TestClass(); + return { decoratedClass: new TestClass(), context: { + kind: classDecoratorContext.kind, + name: classDecoratorContext.name, + } }; `.expectToMatchJsResult(); }); test("Class decorator with parameters", () => { util.testFunction` function setNum(numArg: number) { - return {}>(constructor: T) => { + return {}>(constructor: T, context: ClassDecoratorContext) => { return class extends constructor { decoratorNum = numArg; }; @@ -39,13 +49,13 @@ test("Class decorator with parameters", () => { test("Multiple class decorators", () => { util.testFunction` - function setTen {}>(constructor: T) { + function setTen {}>(constructor: T, context: ClassDecoratorContext) { return class extends constructor { decoratorTen = 10; } } - function setNum {}>(constructor: T) { + function setNum {}>(constructor: T, context: ClassDecoratorContext) { return class extends constructor { decoratorNum = 410; } @@ -64,7 +74,7 @@ test("Multiple class decorators", () => { test("Class decorator with inheritance", () => { util.testFunction` - function setTen {}>(constructor: T) { + function setTen {}>(constructor: T, context: ClassDecoratorContext) { return class extends constructor { decoratorTen = 10; } @@ -89,7 +99,7 @@ test("Class decorators are applied in order and executed in reverse order", () = function pushOrder(index: number) { order.push("eval " + index); - return (constructor: new (...args: any[]) => {}) => { + return (constructor: new (...args: any[]) => {}, context: ClassDecoratorContext) => { order.push("execute " + index); }; } @@ -105,7 +115,7 @@ test("Class decorators are applied in order and executed in reverse order", () = test("Throws error if decorator function has void context", () => { util.testFunction` - function decorator(this: void, constructor: new (...args: any[]) => {}) {} + function decorator(this: void, constructor: new (...args: any[]) => {}, context: ClassDecoratorContext) {} @decorator class TestClass {} @@ -114,7 +124,7 @@ test("Throws error if decorator function has void context", () => { test("Exported class decorator", () => { util.testModule` - function decorator any>(Class: T): T { + function decorator any>(Class: T, context: ClassDecoratorContext): T { return class extends Class { public bar = "foobar"; }; @@ -127,65 +137,6 @@ test("Exported class decorator", () => { .expectToMatchJsResult(); }); -test.each([ - ["@decorator method() {}"], - ["@decorator property;"], - ["@decorator propertyWithInitializer = () => {};"], - ["@decorator ['evaluated property'];"], - ["@decorator get getter() { return 5 }"], - ["@decorator set setter(value) {}"], - ["@decorator static method() {}"], - ["@decorator static property;"], - ["@decorator static propertyWithInitializer = () => {}"], - ["@decorator static get getter() { return 5 }"], - ["@decorator static set setter(value) {}"], - ["@decorator static ['evaluated property'];"], - ["method(@decorator a) {}"], - ["static method(@decorator a) {}"], - ["constructor(@decorator a) {}"], -])("Decorate class member (%p)", classMember => { - util.testFunction` - let decoratorParameters: any; - - const decorator = (target, key, index?) => { - const targetKind = target === Foo ? "Foo" : target === Foo.prototype ? "Foo.prototype" : "unknown"; - decoratorParameters = { targetKind, key, index: typeof index }; - }; - - class Foo { - ${classMember} - } - - return decoratorParameters; - `.expectToMatchJsResult(); -}); - -describe("Decorators /w descriptors", () => { - test.each([ - ["return { writable: true }", "return { configurable: true }"], - ["desc.writable = true", "return { configurable: true }"], - ])("Combine decorators (%p + %p)", (decorateA, decorateB) => { - util.testFunction` - const A = (target, key, desc): any => { ${decorateA} }; - const B = (target, key, desc): any => { ${decorateB} }; - class Foo { @A @B static method() {} } - const { value, ...rest } = Object.getOwnPropertyDescriptor(Foo, "method"); - return rest; - `.expectToMatchJsResult(); - }); - - test.each(["return { value: true }", "desc.value = true"])( - "Use decorator to override method value", - overrideStatement => { - util.testFunction` - const decorator = (target, key, desc): any => { ${overrideStatement} }; - class Foo { @decorator static method() {} } - return Foo.method; - `.expectToMatchJsResult(); - } - ); -}); - // https://github.com/TypeScriptToLua/TypeScriptToLua/issues/1149 test("exported class with decorator", () => { util.testModule` @@ -195,7 +146,7 @@ test("exported class with decorator", () => { ` .addExtraFile( "other.ts", - `function myDecorator(target: {new(): any}) { + `function myDecorator(target: {new(): any}, context: ClassDecoratorContext) { return class extends target { foo() { return "overridden"; @@ -221,7 +172,7 @@ test("default exported class with decorator", () => { ` .addExtraFile( "other.ts", - `function myDecorator(target: {new(): any}) { + `function myDecorator(target: {new(): any}, context: ClassDecoratorContext) { return class extends target { foo() { return "overridden"; @@ -238,3 +189,419 @@ test("default exported class with decorator", () => { ) .expectToEqual({ result: "overridden" }); }); + +test("class method decorator", () => { + util.testFunction` + let methodDecoratorContext; + + function methodDecorator(method: (v: number) => number, context: ClassMethodDecoratorContext) { + methodDecoratorContext = context; + + return (v: number) => { + return method(v) + 10; + }; + } + + class TestClass { + @methodDecorator + public myMethod(x: number) { + return x * 23; + } + } + + return { result: new TestClass().myMethod(4), context: { + kind: methodDecoratorContext.kind, + name: methodDecoratorContext.name, + private: methodDecoratorContext.private, + static: methodDecoratorContext.static + } }; + `.expectToMatchJsResult(); +}); + +test("this in decorator points to class being decorated", () => { + util.testFunction` + function methodDecorator(method: (v: number) => number, context: ClassMethodDecoratorContext) { + return function() { + const thisCallTime = this.myInstanceVariable; + return thisCallTime; + }; + } + + class TestClass { + constructor(protected myInstanceVariable: number) { } + + @methodDecorator + public myMethod() { + return 0; + } + } + + return new TestClass(5).myMethod(); + `.expectToMatchJsResult(); +}); + +test("class getter decorator", () => { + util.testFunction` + let getterDecoratorContext; + + function getterDecorator(getter: () => number, context: ClassGetterDecoratorContext) { + getterDecoratorContext = context; + + return () => { + return getter() + 10; + }; + } + + class TestClass { + @getterDecorator + get getterValue() { return 10; } + } + + return { result: new TestClass().getterValue, context: { + kind: getterDecoratorContext.kind, + name: getterDecoratorContext.name, + private: getterDecoratorContext.private, + static: getterDecoratorContext.static + } }; + `.expectToMatchJsResult(); +}); + +test("class setter decorator", () => { + util.testFunction` + let setterDecoratorContext; + + function setterDecorator(setter: (v: number) => void, context: ClassSetterDecoratorContext) { + setterDecoratorContext = context; + + return function(v: number) { + setter.call(this, v + 15); + }; + } + + class TestClass { + public value: number; + + @setterDecorator + set valueSetter(v: number) { this.value = v; } + } + + const instance = new TestClass(); + instance.valueSetter = 23; + return { result: instance.value, context: { + kind: setterDecoratorContext.kind, + name: setterDecoratorContext.name, + private: setterDecoratorContext.private, + static: setterDecoratorContext.static + } }; + `.expectToMatchJsResult(); +}); + +test("class field decorator", () => { + util.testFunction` + let fieldDecoratorContext; + + function fieldDecorator(_: undefined, context: ClassFieldDecoratorContext) { + fieldDecoratorContext = context; + } + + class TestClass { + @fieldDecorator + public value: number = 22; + } + + return { result: new TestClass(), context: { + kind: fieldDecoratorContext.kind, + name: fieldDecoratorContext.name, + private: fieldDecoratorContext.private, + static: fieldDecoratorContext.static, + } }; + `.expectToEqual({ + result: { + value: 22, // Different from JS because we ignore the value initializer + }, + context: { + kind: "field", + name: "value", + private: false, + static: false, + }, + }); +}); + +test("class field decorator warns the return value is ignored", () => { + util.testFunction` + let fieldDecoratorContext; + + function fieldDecorator(_: undefined, context: ClassFieldDecoratorContext) { + fieldDecoratorContext = context; + + return (initialValue: number) => initialValue * 12; + } + + class TestClass { + @fieldDecorator + public value: number = 22; + } + `.expectDiagnosticsToMatchSnapshot([incompleteFieldDecoratorWarning.code]); +}); + +describe("legacy experimentalDecorators", () => { + test("Class decorator with no parameters", () => { + util.testFunction` + function setBool {}>(constructor: T) { + return class extends constructor { + decoratorBool = true; + } + } + + @setBool + class TestClass { + public decoratorBool = false; + } + + return new TestClass(); + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + test("Class decorator with parameters", () => { + util.testFunction` + function setNum(numArg: number) { + return {}>(constructor: T) => { + return class extends constructor { + decoratorNum = numArg; + }; + }; + } + + @setNum(420) + class TestClass { + public decoratorNum; + } + + return new TestClass(); + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + test("Multiple class decorators", () => { + util.testFunction` + function setTen {}>(constructor: T) { + return class extends constructor { + decoratorTen = 10; + } + } + + function setNum {}>(constructor: T) { + return class extends constructor { + decoratorNum = 410; + } + } + + @setTen + @setNum + class TestClass { + public decoratorTen; + public decoratorNum; + } + + return new TestClass(); + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + test("Class decorator with inheritance", () => { + util.testFunction` + function setTen {}>(constructor: T) { + return class extends constructor { + decoratorTen = 10; + } + } + + class TestClass { + public decoratorTen = 0; + } + + @setTen + class SubTestClass extends TestClass { + public decoratorTen = 5; + } + + return new SubTestClass(); + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + test("Class decorators are applied in order and executed in reverse order", () => { + util.testFunction` + const order = []; + + function pushOrder(index: number) { + order.push("eval " + index); + return (constructor: new (...args: any[]) => {}) => { + order.push("execute " + index); + }; + } + + @pushOrder(1) + @pushOrder(2) + @pushOrder(3) + class TestClass {} + + return order; + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + test("Throws error if decorator function has void context", () => { + util.testFunction` + function decorator(this: void, constructor: new (...args: any[]) => {}) {} + + @decorator + class TestClass {} + ` + .setOptions({ experimentalDecorators: true }) + .expectDiagnosticsToMatchSnapshot([decoratorInvalidContext.code]); + }); + + test("Exported class decorator", () => { + util.testModule` + function decorator any>(Class: T): T { + return class extends Class { + public bar = "foobar"; + }; + } + + @decorator + export class Foo {} + ` + .setReturnExport("Foo", "bar") + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + test.each([ + ["@decorator method() {}"], + ["@decorator property;"], + ["@decorator propertyWithInitializer = () => {};"], + ["@decorator ['evaluated property'];"], + ["@decorator get getter() { return 5 }"], + ["@decorator set setter(value) {}"], + ["@decorator static method() {}"], + ["@decorator static property;"], + ["@decorator static propertyWithInitializer = () => {}"], + ["@decorator static get getter() { return 5 }"], + ["@decorator static set setter(value) {}"], + ["@decorator static ['evaluated property'];"], + ["method(@decorator a) {}"], + ["static method(@decorator a) {}"], + ["constructor(@decorator a) {}"], + ])("Decorate class member (%p)", classMember => { + util.testFunction` + let decoratorParameters: any; + + const decorator = (target, key, index?) => { + const targetKind = target === Foo ? "Foo" : target === Foo.prototype ? "Foo.prototype" : "unknown"; + decoratorParameters = { targetKind, key, index: typeof index }; + }; + + class Foo { + ${classMember} + } + + return decoratorParameters; + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + describe("Decorators /w descriptors", () => { + test.each([ + ["return { writable: true }", "return { configurable: true }"], + ["desc.writable = true", "return { configurable: true }"], + ])("Combine decorators (%p + %p)", (decorateA, decorateB) => { + util.testFunction` + const A = (target, key, desc): any => { ${decorateA} }; + const B = (target, key, desc): any => { ${decorateB} }; + class Foo { @A @B static method() {} } + const { value, ...rest } = Object.getOwnPropertyDescriptor(Foo, "method"); + return rest; + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + }); + + test.each(["return { value: true }", "desc.value = true"])( + "Use decorator to override method value %s", + overrideStatement => { + util.testFunction` + const decorator = (target, key, desc): any => { ${overrideStatement} }; + class Foo { @decorator static method() {} } + return Foo.method; + ` + .setOptions({ experimentalDecorators: true }) + .expectToMatchJsResult(); + } + ); + }); + + // https://github.com/TypeScriptToLua/TypeScriptToLua/issues/1149 + test("exported class with decorator", () => { + util.testModule` + import { MyClass } from "./other"; + const inst = new MyClass(); + export const result = inst.foo(); + ` + .addExtraFile( + "other.ts", + `function myDecorator(target: {new(): any}) { + return class extends target { + foo() { + return "overridden"; + } + } + } + + @myDecorator + export class MyClass { + foo() { + return "foo"; + } + }` + ) + .setOptions({ experimentalDecorators: true }) + .expectToEqual({ result: "overridden" }); + }); + + test("default exported class with decorator", () => { + util.testModule` + import MyClass from "./other"; + const inst = new MyClass(); + export const result = inst.foo(); + ` + .addExtraFile( + "other.ts", + `function myDecorator(target: {new(): any}) { + return class extends target { + foo() { + return "overridden"; + } + } + } + + @myDecorator + export default class { + foo() { + return "foo"; + } + }` + ) + .setOptions({ experimentalDecorators: true }) + .expectToEqual({ result: "overridden" }); + }); +}); diff --git a/test/unit/identifiers.spec.ts b/test/unit/identifiers.spec.ts index e3acf8530..6e13a5e12 100644 --- a/test/unit/identifiers.spec.ts +++ b/test/unit/identifiers.spec.ts @@ -196,7 +196,7 @@ test.each(validTsInvalidLuaNames)("class with invalid lua name has correct name test.each(validTsInvalidLuaNames)("decorated class with invalid lua name", name => { util.testFunction` - function decorator any>(Class: T): T { + function decorator any>(Class: T, context: ClassDecoratorContext): T { return class extends Class { public bar = "foobar"; }; @@ -210,7 +210,7 @@ test.each(validTsInvalidLuaNames)("decorated class with invalid lua name", name test.each(validTsInvalidLuaNames)("exported decorated class with invalid lua name", name => { util.testModule` - function decorator any>(Class: T): T { + function decorator any>(Class: T, context: ClassDecoratorContext): T { return class extends Class { public bar = "foobar"; }; diff --git a/test/util.ts b/test/util.ts index f2c73e1e2..75b477849 100644 --- a/test/util.ts +++ b/test/util.ts @@ -122,35 +122,35 @@ export abstract class TestBuilder { // TODO: Use testModule in these cases? protected tsHeader = ""; public setTsHeader(tsHeader: string): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("setTsHeader"); this.tsHeader = tsHeader; return this; } private luaHeader = ""; public setLuaHeader(luaHeader: string): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("setLuaHeader"); this.luaHeader += luaHeader; return this; } protected jsHeader = ""; public setJsHeader(jsHeader: string): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("setJsHeader"); this.jsHeader += jsHeader; return this; } protected abstract getLuaCodeWithWrapper(code: string): string; public setLuaFactory(luaFactory: (code: string) => string): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("setLuaFactory"); this.getLuaCodeWithWrapper = luaFactory; return this; } private semanticCheck = true; public disableSemanticCheck(): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("disableSemanticCheck"); this.semanticCheck = false; return this; } @@ -163,11 +163,10 @@ export abstract class TestBuilder { lib: ["lib.esnext.d.ts"], moduleResolution: ts.ModuleResolutionKind.Node10, resolveJsonModule: true, - experimentalDecorators: true, sourceMap: true, }; public setOptions(options: tstl.CompilerOptions = {}): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("setOptions"); Object.assign(this.options, options); return this; } @@ -184,25 +183,31 @@ export abstract class TestBuilder { protected mainFileName = "main.ts"; public setMainFileName(mainFileName: string): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("setMainFileName"); this.mainFileName = mainFileName; return this; } protected extraFiles: Record = {}; public addExtraFile(fileName: string, code: string): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("addExtraFile"); this.extraFiles[fileName] = normalizeSlashes(code); return this; } private customTransformers?: ts.CustomTransformers; public setCustomTransformers(customTransformers?: ts.CustomTransformers): this { - expect(this.hasProgram).toBe(false); + this.throwIfProgramExists("setCustomTransformers"); this.customTransformers = customTransformers; return this; } + private throwIfProgramExists(name: string) { + if (this.hasProgram) { + throw new Error(`${name}() should not be called after an .expect() or .debug()`); + } + } + // Transpilation and execution public getTsCode(): string {