diff --git a/package.json b/package.json index f854b87a..5535035c 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "@vitest/coverage-v8": "^2.1.1", "acorn": "^8.12.1", "babel-plugin-parameter-decorator": "^1.0.16", - "babel-plugin-transform-typescript-metadata": "^0.3.2", "changelogen": "^0.5.7", "config": "^3.3.12", "destr": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cced6a60..c108bd99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: babel-plugin-parameter-decorator: specifier: ^1.0.16 version: 1.0.16 - babel-plugin-transform-typescript-metadata: - specifier: ^0.3.2 - version: 0.3.2(@babel/core@7.25.2)(@babel/traverse@7.25.6) changelogen: specifier: ^0.5.7 version: 0.5.7(magicast@0.3.5) @@ -980,15 +977,6 @@ packages: babel-plugin-parameter-decorator@1.0.16: resolution: {integrity: sha512-yUT2WPTUg1JaPmRGRSF557m1HJ9vdFQInRWOkiOyO5a9HhqlXffJu+fQ2xd5+qU/35ICMrrk9eWKsHCairKA9w==} - babel-plugin-transform-typescript-metadata@0.3.2: - resolution: {integrity: sha512-mWEvCQTgXQf48yDqgN7CH50waTyYBeP2Lpqx4nNWab9sxEpdXVeKgfj1qYI2/TgUPQtNFZ85i3PemRtnXVYYJg==} - peerDependencies: - '@babel/core': ^7 - '@babel/traverse': ^7 - peerDependenciesMeta: - '@babel/traverse': - optional: true - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3346,13 +3334,6 @@ snapshots: babel-plugin-parameter-decorator@1.0.16: {} - babel-plugin-transform-typescript-metadata@0.3.2(@babel/core@7.25.2)(@babel/traverse@7.25.6): - dependencies: - '@babel/core': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 - optionalDependencies: - '@babel/traverse': 7.25.6 - balanced-match@1.0.2: {} binary-extensions@2.3.0: {} diff --git a/src/babel.ts b/src/babel.ts index d1937714..c202921c 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -9,7 +9,7 @@ import syntaxImportAssertionsPlugin from "@babel/plugin-syntax-import-assertions import transformExportNamespaceFromPlugin from "@babel/plugin-transform-export-namespace-from"; import transformTypeScriptPlugin from "@babel/plugin-transform-typescript"; import parameterDecoratorPlugin from "babel-plugin-parameter-decorator"; -import transformTypeScriptMetaPlugin from "babel-plugin-transform-typescript-metadata"; +import transformTypeScriptMetaPlugin from "./plugins/babel-plugin-transform-typescript-metadata"; import syntaxJSXPlugin from "@babel/plugin-syntax-jsx"; import transformReactJSX from "@babel/plugin-transform-react-jsx"; import { TransformImportMetaPlugin } from "./plugins/babel-plugin-transform-import-meta"; diff --git a/src/plugins/babel-plugin-transform-typescript-metadata/index.ts b/src/plugins/babel-plugin-transform-typescript-metadata/index.ts new file mode 100644 index 00000000..9bf9c988 --- /dev/null +++ b/src/plugins/babel-plugin-transform-typescript-metadata/index.ts @@ -0,0 +1,50 @@ +/** + * Based on https://github.com/leonardfactory/babel-plugin-transform-typescript-metadata + * Copyright (c) 2019 Leonardo Ascione [MIT] + */ + +import type { PluginObj } from "@babel/core"; +import { declare } from "@babel/helper-plugin-utils"; +import { parameterVisitor } from "./parameter-visitor"; +import { metadataVisitor } from "./metadata-visitor"; + +export default declare((api: any): PluginObj => { + api.assertVersion(7); + + return { + visitor: { + Program(programPath) { + /** + * We need to traverse the program right here since + * `@babel/preset-typescript` removes imports at this level. + * + * Since we need to convert some typings into **bindings**, used in + * `Reflect.metadata` calls, we need to process them **before** + * the typescript preset. + */ + programPath.traverse({ + ClassDeclaration(path) { + for (const field of path.get("body").get("body")) { + if ( + field.type !== "ClassMethod" && + field.type !== "ClassProperty" + ) { + continue; + } + + parameterVisitor(path, field as any); + metadataVisitor(path, field as any); + } + + /** + * We need to keep binding in order to let babel know where imports + * are used as a Value (and not just as a type), so that + * `babel-transform-typescript` do not strip the import. + */ + (path.parentPath.scope as any).crawl(); + }, + }); + }, + }, + }; +}); diff --git a/src/plugins/babel-plugin-transform-typescript-metadata/metadata-visitor.ts b/src/plugins/babel-plugin-transform-typescript-metadata/metadata-visitor.ts new file mode 100644 index 00000000..6815a978 --- /dev/null +++ b/src/plugins/babel-plugin-transform-typescript-metadata/metadata-visitor.ts @@ -0,0 +1,84 @@ +/** + * Based on https://github.com/leonardfactory/babel-plugin-transform-typescript-metadata + * Copyright (c) 2019 Leonardo Ascione [MIT] + */ + +import { NodePath, types as t } from "@babel/core"; +import { serializeType } from "./serialize-type"; + +function createMetadataDesignDecorator( + design: + | "design:type" + | "design:paramtypes" + | "design:returntype" + | "design:typeinfo", + typeArg: t.Expression | t.SpreadElement | t.JSXNamespacedName, +): t.Decorator { + return t.decorator( + t.logicalExpression( + "||", + t.optionalCallExpression( + t.memberExpression(t.identifier("Reflect"), t.identifier("metadata")), + [t.stringLiteral(design), typeArg as unknown as t.Expression], + true, + ), + t.arrowFunctionExpression([t.identifier("t")], t.identifier("t")), + ), + ); +} + +export function metadataVisitor( + classPath: NodePath, + path: NodePath, +) { + const field = path.node; + const classNode = classPath.node; + + switch (field.type) { + case "ClassMethod": { + const decorators = + field.kind === "constructor" ? classNode.decorators : field.decorators; + + if (!decorators || decorators.length === 0) { + return; + } + + decorators!.push( + createMetadataDesignDecorator("design:type", t.identifier("Function")), + ); + decorators!.push( + createMetadataDesignDecorator( + "design:paramtypes", + t.arrayExpression( + field.params.map((param) => serializeType(classPath, param)), + ), + ), + ); + // Hint: `design:returntype` could also be implemented here, although this seems + // quite complicated to achieve without the TypeScript compiler. + // See https://github.com/microsoft/TypeScript/blob/f807b57356a8c7e476fedc11ad98c9b02a9a0e81/src/compiler/transformers/ts.ts#L1315 + break; + } + + case "ClassProperty": { + if (!field.decorators || field.decorators.length === 0) { + return; + } + + if ( + !field.typeAnnotation || + field.typeAnnotation.type !== "TSTypeAnnotation" + ) { + return; + } + + field.decorators!.push( + createMetadataDesignDecorator( + "design:type", + serializeType(classPath, field), + ), + ); + break; + } + } +} diff --git a/src/plugins/babel-plugin-transform-typescript-metadata/parameter-visitor.ts b/src/plugins/babel-plugin-transform-typescript-metadata/parameter-visitor.ts new file mode 100644 index 00000000..81dd56d0 --- /dev/null +++ b/src/plugins/babel-plugin-transform-typescript-metadata/parameter-visitor.ts @@ -0,0 +1,108 @@ +/** + * Based on https://github.com/leonardfactory/babel-plugin-transform-typescript-metadata + * Copyright (c) 2019 Leonardo Ascione [MIT] + */ + +import { NodePath, types as t } from "@babel/core"; + +/** + * Helper function to create a field/class decorator from a parameter decorator. + * Field/class decorators get three arguments: the class, the name of the method + * (or 'undefined' in the case of the constructor) and the position index of the + * parameter in the argument list. + * Some of this information, the index, is only available at transform time, and + * has to be stored. The other arguments are part of the decorator signature and + * will be passed to the decorator anyway. But the decorator has to be called + * with all three arguments at runtime, so this creates a function wrapper, which + * takes the target and the key, and adds the index to it. + * + * Inject() becomes function(target, key) { return Inject()(target, key, 0) } + * + * @param paramIndex the index of the parameter inside the function call + * @param decoratorExpression the decorator expression, the return object of SomeParameterDecorator() + * @param isConstructor indicates if the key should be set to 'undefined' + */ +function createParamDecorator( + paramIndex: number, + decoratorExpression: t.Expression, + isConstructor = false, +) { + return t.decorator( + t.functionExpression( + null, // anonymous function + [t.identifier("target"), t.identifier("key")], + t.blockStatement([ + t.returnStatement( + t.callExpression(decoratorExpression, [ + t.identifier("target"), + t.identifier(isConstructor ? "undefined" : "key"), + t.numericLiteral(paramIndex), + ]), + ), + ]), + ), + ); +} + +export function parameterVisitor( + classPath: NodePath, + path: NodePath | NodePath, +) { + if (path.type !== "ClassMethod") { + return; + } + if (path.node.type !== "ClassMethod") { + return; + } + if (path.node.key.type !== "Identifier") { + return; + } + + const methodPath = path as NodePath; + const params = methodPath.get("params") || []; + + for (const param of params) { + const identifier = + param.node.type === "Identifier" || param.node.type === "ObjectPattern" + ? param.node + : // eslint-disable-next-line unicorn/no-nested-ternary + param.node.type === "TSParameterProperty" && + param.node.parameter.type === "Identifier" + ? param.node.parameter + : null; + + if (identifier == null) { + continue; + } + + let resultantDecorator: t.Decorator | undefined; + + for (const decorator of (param.node as t.Identifier).decorators || []) { + if (methodPath.node.kind === "constructor") { + resultantDecorator = createParamDecorator( + param.key as number, + decorator.expression, + true, + ); + if (!classPath.node.decorators) { + classPath.node.decorators = []; + } + classPath.node.decorators.push(resultantDecorator); + } else { + resultantDecorator = createParamDecorator( + param.key as number, + decorator.expression, + false, + ); + if (!methodPath.node.decorators) { + methodPath.node.decorators = []; + } + methodPath.node.decorators.push(resultantDecorator); + } + } + + if (resultantDecorator) { + (param.node as t.Identifier).decorators = null; + } + } +} diff --git a/src/plugins/babel-plugin-transform-typescript-metadata/serialize-type.ts b/src/plugins/babel-plugin-transform-typescript-metadata/serialize-type.ts new file mode 100644 index 00000000..01022e59 --- /dev/null +++ b/src/plugins/babel-plugin-transform-typescript-metadata/serialize-type.ts @@ -0,0 +1,309 @@ +/** + * Based on https://github.com/leonardfactory/babel-plugin-transform-typescript-metadata + * Copyright (c) 2019 Leonardo Ascione [MIT] + */ + +import { NodePath, types as t } from "@babel/core"; + +type InferArray = T extends Array ? A : never; + +type Parameter = InferArray | t.ClassProperty; + +function createVoidZero() { + return t.unaryExpression("void", t.numericLiteral(0)); +} + +/** + * Given a parameter (or class property) node it returns the first identifier + * containing the TS Type Annotation. + * + * @todo Array and Objects spread are not supported. + * @todo Rest parameters are not supported. + */ +function getTypedNode( + param: Parameter, +): t.Identifier | t.ClassProperty | t.ObjectPattern | null { + if (param == null) { + return null; + } + + if (param.type === "ClassProperty") { + return param; + } + if (param.type === "Identifier") { + return param; + } + if (param.type === "ObjectPattern") { + return param; + } + + if (param.type === "AssignmentPattern" && param.left.type === "Identifier") { + return param.left; + } + + if (param.type === "TSParameterProperty") { + return getTypedNode(param.parameter); + } + + return null; +} + +export function serializeType( + classPath: NodePath, + param: Parameter, +) { + const node = getTypedNode(param); + if (node == null) { + return createVoidZero(); + } + + if (!node.typeAnnotation || node.typeAnnotation.type !== "TSTypeAnnotation") { + return createVoidZero(); + } + + const annotation = node.typeAnnotation.typeAnnotation; + const className = classPath.node.id ? classPath.node.id.name : ""; + return serializeTypeNode(className, annotation); +} + +function serializeTypeReferenceNode( + className: string, + node: t.TSTypeReference, +) { + /** + * We need to save references to this type since it is going + * to be used as a Value (and not just as a Type) here. + * + * This is resolved in main plugin method, calling + * `path.scope.crawl()` which updates the bindings. + */ + const reference = serializeReference(node.typeName); + + /** + * We should omit references to self (class) since it will throw a + * ReferenceError at runtime due to babel transpile output. + */ + if (isClassType(className, reference)) { + return t.identifier("Object"); + } + + /** + * We don't know if type is just a type (interface, etc.) or a concrete + * value (class, etc.). + * `typeof` operator allows us to use the expression even if it is not + * defined, fallback is just `Object`. + */ + return t.conditionalExpression( + t.binaryExpression( + "===", + t.unaryExpression("typeof", reference), + t.stringLiteral("undefined"), + ), + t.identifier("Object"), + t.cloneDeep(reference), + ); +} + +/** + * Checks if node (this should be the result of `serializeReference`) member + * expression or identifier is a reference to self (class name). + * In this case, we just emit `Object` in order to avoid ReferenceError. + */ +export function isClassType(className: string, node: t.Expression): boolean { + switch (node.type) { + case "Identifier": { + return node.name === className; + } + case "MemberExpression": { + return isClassType(className, node.object); + } + default: { + throw new Error( + `The property expression at ${node.start} is not valid as a Type to be used in Reflect.metadata`, + ); + } + } +} + +function serializeReference( + typeName: t.Identifier | t.TSQualifiedName, +): t.Identifier | t.MemberExpression { + if (typeName.type === "Identifier") { + return t.identifier(typeName.name); + } + return t.memberExpression(serializeReference(typeName.left), typeName.right); +} + +type SerializedType = + | t.Identifier + | t.UnaryExpression + | t.ConditionalExpression; + +/** + * Actual serialization given the TS Type annotation. + * Result tries to get the best match given the information available. + * + * Implementation is adapted from original TSC compiler source as + * available here: + * https://github.com/Microsoft/TypeScript/blob/2932421370df720f0ccfea63aaf628e32e881429/src/compiler/transformers/ts.ts + */ +function serializeTypeNode(className: string, node: t.TSType): SerializedType { + if (node === undefined) { + return t.identifier("Object"); + } + + switch (node.type) { + case "TSVoidKeyword": + case "TSUndefinedKeyword": + case "TSNullKeyword": + case "TSNeverKeyword": { + return createVoidZero(); + } + + case "TSParenthesizedType": { + return serializeTypeNode(className, node.typeAnnotation); + } + + case "TSFunctionType": + case "TSConstructorType": { + return t.identifier("Function"); + } + + case "TSArrayType": + case "TSTupleType": { + return t.identifier("Array"); + } + + case "TSTypePredicate": + case "TSBooleanKeyword": { + return t.identifier("Boolean"); + } + + case "TSStringKeyword": { + return t.identifier("String"); + } + + case "TSObjectKeyword": { + return t.identifier("Object"); + } + + case "TSLiteralType": { + switch (node.literal.type) { + case "StringLiteral": { + return t.identifier("String"); + } + + case "NumericLiteral": { + return t.identifier("Number"); + } + + case "BooleanLiteral": { + return t.identifier("Boolean"); + } + + default: { + /** + * @todo Use `path` error building method. + */ + throw new Error("Bad type for decorator" + node.literal); + } + } + } + + case "TSNumberKeyword": + case "TSBigIntKeyword" as any: { + // Still not in ``@babel/core` typings + return t.identifier("Number"); + } + + case "TSSymbolKeyword": { + return t.identifier("Symbol"); + } + + case "TSTypeReference": { + return serializeTypeReferenceNode(className, node); + } + + case "TSIntersectionType": + case "TSUnionType": { + return serializeTypeList(className, node.types); + } + + case "TSConditionalType": { + return serializeTypeList(className, [node.trueType, node.falseType]); + } + + case "TSTypeQuery": + case "TSTypeOperator": + case "TSIndexedAccessType": + case "TSMappedType": + case "TSTypeLiteral": + case "TSAnyKeyword": + case "TSUnknownKeyword": + case "TSThisType": { + // case SyntaxKind.ImportType: + break; + } + + default: { + throw new Error("Bad type for decorator"); + } + } + + return t.identifier("Object"); +} + +/** + * Type lists need some refining. Even here, implementation is slightly + * adapted from original TSC compiler: + * + * https://github.com/Microsoft/TypeScript/blob/2932421370df720f0ccfea63aaf628e32e881429/src/compiler/transformers/ts.ts + */ +function serializeTypeList( + className: string, + types: ReadonlyArray, +): SerializedType { + let serializedUnion: SerializedType | undefined; + + for (let typeNode of types) { + while (typeNode.type === "TSParenthesizedType") { + typeNode = typeNode.typeAnnotation; // Skip parens if need be + } + if (typeNode.type === "TSNeverKeyword") { + continue; // Always elide `never` from the union/intersection if possible + } + if ( + typeNode.type === "TSNullKeyword" || + typeNode.type === "TSUndefinedKeyword" + ) { + continue; // Elide null and undefined from unions for metadata, just like what we did prior to the implementation of strict null checks + } + const serializedIndividual = serializeTypeNode(className, typeNode); + + if ( + t.isIdentifier(serializedIndividual) && + serializedIndividual.name === "Object" + ) { + // One of the individual is global object, return immediately + return serializedIndividual; + } + // If there exists union that is not void 0 expression, check if the the common type is identifier. + // anything more complex and we will just default to Object + else if (serializedUnion) { + // Different types + if ( + !t.isIdentifier(serializedUnion) || + !t.isIdentifier(serializedIndividual) || + serializedUnion.name !== serializedIndividual.name + ) { + return t.identifier("Object"); + } + } else { + // Initialize the union type + serializedUnion = serializedIndividual; + } + } + + // If we were able to find common type, use it + return serializedUnion || createVoidZero(); // Fallback is only hit if all union constituients are null/undefined/never +} diff --git a/test/fixtures/typescript/decorators.ts b/test/fixtures/typescript/decorators.ts index b9770d64..08166a56 100644 --- a/test/fixtures/typescript/decorators.ts +++ b/test/fixtures/typescript/decorators.ts @@ -1,4 +1,5 @@ import "reflect-metadata"; + function decorator(...args: any) { console.log("Decorator called with " + args.length + " arguments."); } @@ -7,7 +8,7 @@ function anotherDecorator() { return function (object: any, propertyName: any) { console.log( "Decorator metadata keys: " + - Reflect.getMetadataKeys(object, propertyName), + Reflect.getMetadataKeys?.(object, propertyName), ); }; }