From 9bda2c3fa328e6da552dcbf159b8952d388adcfc Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 22 Jul 2024 15:13:59 +0000 Subject: [PATCH] refactor(migrations): initial logic for converting `@Input()` to `input` (#57082) This commit includes the initial logic for converting `@Input()` to `input`. The logic is not fully polished in terms of what use-cases and patterns we want to generate, but it's working pretty stable with testing in Angular Material and some g3 targets. We may improve this and e.g. generate the `input()` shorthand in a couple of cases. The commit also includes some related helpers/parts that are needed for the migration phases. Notably the conversion phase is split up into preparation and migration. That is necessary because as part of analysing we already try to prepare to see if it's "possible". This is necessary for the global metadata analysis, so that we can know that certain references are incompatible across compilation units (e.g. when running as a batch). PR Close #57082 --- .../src/convert-input/convert_to_signal.ts | 158 ++++++++++++++++++ .../src/convert-input/prepare_and_check.ts | 114 +++++++++++++ .../signal-migration/src/migration_host.ts | 53 ++++++ .../signal-migration/src/replacement.ts | 28 ++++ .../migrations/signal-migration/src/result.ts | 46 +++++ .../src/write_replacements.ts | 22 +++ 6 files changed, 421 insertions(+) create mode 100644 packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/migration_host.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/replacement.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/result.ts create mode 100644 packages/core/schematics/migrations/signal-migration/src/write_replacements.ts diff --git a/packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts b/packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts new file mode 100644 index 00000000000..e9c0c107feb --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/convert-input/convert_to_signal.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import assert from 'assert'; +import ts from 'typescript'; + +import {MigrationHost} from '../migration_host'; +import {ConvertInputPreparation} from './prepare_and_check'; +import {DecoratorInputTransform} from '../../../../../../compiler-cli/src/ngtsc/metadata'; + +const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed}); + +/** + * + * Converts an `@Input()` property declaration to a signal input. + * + * @returns The transformed property declaration, printed as a string. + */ +export function convertToSignalInput( + host: MigrationHost, + node: ts.PropertyDeclaration, + {resolvedMetadata: metadata, resolvedType, isResolvedTypeCheckable}: ConvertInputPreparation, + checker: ts.TypeChecker, +): string { + let initialValue = node.initializer; + let optionsLiteral: ts.ObjectLiteralExpression | null = null; + + // We need an options array for the input because: + // - the input is either aliased, + // - or we have a transform. + if (metadata.bindingPropertyName !== metadata.classPropertyName || metadata.transform !== null) { + const properties: ts.ObjectLiteralElementLike[] = []; + if (metadata.bindingPropertyName !== metadata.classPropertyName) { + properties.push( + ts.factory.createPropertyAssignment( + 'alias', + ts.factory.createStringLiteral(metadata.bindingPropertyName), + ), + ); + } + if (metadata.transform !== null) { + properties.push( + extractTransformOfInput(metadata.transform, resolvedType, isResolvedTypeCheckable, checker), + ); + } + + optionsLiteral = ts.factory.createObjectLiteralExpression(properties); + } + + const strictPropertyInitialization = + !!host.tsOptions.strict || !!host.tsOptions.strictPropertyInitialization; + const inputArgs: ts.Expression[] = []; + const typeArguments: ts.TypeNode[] = []; + + if (resolvedType !== undefined) { + typeArguments.push(resolvedType); + + if (metadata.transform !== null) { + typeArguments.push(metadata.transform.type.node); + } + } + + // If we have no initial value but strict property initialization is enabled, we + // need to add an explicit value. Alternatively, if we have an explicit type, we + // need to add an explicit initial value as per the API signature of `input()`. + if (initialValue === undefined && (strictPropertyInitialization || resolvedType !== undefined)) { + // TODO: Consider initializations inside the constructor. Those are not migrated right now + // though, as they are writes. + + // TODO: We can use the `input()` shorthand if there is a question mark? + // We can assume `undefined` is part of the type already, either already was included, or + // we added synthetically as part of the preparation. + initialValue = ts.factory.createIdentifier('undefined'); + } + + // Always add an initial value when the input is optional, and we have one, or we need one + // to be able to pass options as the second argument. + if (!metadata.required && (initialValue !== undefined || optionsLiteral !== null)) { + // TODO: undefined `input()` shorthand support! + inputArgs.push(initialValue ?? ts.factory.createIdentifier('undefined')); + } + + if (optionsLiteral !== null) { + inputArgs.push(optionsLiteral); + } + + const inputFnRef = + metadata.inputDecoratorRef !== null && ts.isPropertyAccessExpression(metadata.inputDecoratorRef) + ? ts.factory.createPropertyAccessExpression(metadata.inputDecoratorRef.expression, 'input') + : ts.factory.createIdentifier('input'); + + const inputInitializerFn = metadata.required + ? ts.factory.createPropertyAccessExpression(inputFnRef, 'required') + : inputFnRef; + + const inputInitializer = ts.factory.createCallExpression( + inputInitializerFn, + typeArguments, + inputArgs, + ); + + // TODO: + // - modifiers (but private does not work) + // - preserve custom decorators etc. + + const result = ts.factory.createPropertyDeclaration( + undefined, + node.name, + undefined, + undefined, + inputInitializer, + ); + + return printer.printNode(ts.EmitHint.Unspecified, result, node.getSourceFile()); +} + +/** + * Extracts the transform for the given input and returns a property assignment + * that works for the new signal `input()` API. + */ +function extractTransformOfInput( + transform: DecoratorInputTransform, + resolvedType: ts.TypeNode | undefined, + isResolvedTypeCheckable: boolean, + checker: ts.TypeChecker, +): ts.PropertyAssignment { + assert(ts.isExpression(transform.node), `Expected transform to be an expression.`); + let transformFn: ts.Expression = transform.node; + + // If there is an explicit type, check if the transform return type actually works. + // In some cases, the transform function is not compatible because with decorator inputs, + // those were not checked. We cast the transform to `any` and add a TODO. + // TODO: Insert a TODO and capture this in the design doc. + if (resolvedType !== undefined && isResolvedTypeCheckable) { + const transformType = checker.getTypeAtLocation(transform.node); + const transformSignature = transformType.getCallSignatures()[0]; + assert(transformSignature !== undefined, 'Expected transform to be an invoke-able.'); + + if ( + !checker.isTypeAssignableTo( + checker.getReturnTypeOfSignature(transformSignature), + checker.getTypeFromTypeNode(resolvedType), + ) + ) { + transformFn = ts.factory.createAsExpression( + ts.factory.createParenthesizedExpression(transformFn), + ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + ); + } + } + + return ts.factory.createPropertyAssignment('transform', transformFn); +} diff --git a/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts b/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts new file mode 100644 index 00000000000..a2a3cbc792c --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import ts from 'typescript'; +import {ExtractedInput} from '../input_detection/input_decorator'; +import { + InputIncompatibilityReason, + InputMemberIncompatibility, +} from '../input_detection/incompatibility'; +import {InputNode} from '../input_detection/input_node'; + +/** + * Interface describing analysis performed when the input + * was verified to be convert-able. + */ +export interface ConvertInputPreparation { + resolvedType: ts.TypeNode | undefined; + isResolvedTypeCheckable: boolean; + resolvedMetadata: ExtractedInput; +} + +/** + * Prepares a potential migration of the given node by performing + * initial analysis and checking whether it an be migrated. + * + * For example, required inputs that don't have an explicit type may not + * be migrated as we don't have a good type for `input.required`. + * (Note: `typeof Bla` may be usable— but isn't necessarily a good practice + * for complex expressions) + */ +export function prepareAndCheckForConversion( + node: InputNode, + metadata: ExtractedInput, + checker: ts.TypeChecker, +): InputMemberIncompatibility | ConvertInputPreparation { + // Accessor inputs cannot be migrated right now. + if (ts.isAccessor(node)) { + return { + context: node, + reason: InputIncompatibilityReason.Accessor, + }; + } + const initialValue = node.initializer; + + // If an input can be required, due to the non-null assertion on the property, + // make it required if there is no initializer. + if (node.exclamationToken !== undefined && initialValue === undefined) { + metadata.required = true; + } + + let typeToAdd: ts.TypeNode | undefined = node.type; + let isResolvedTypeCheckable = true; + + // If the input was using `@Input() bla?: string;`, then we try to explicitly + // add `undefined` as type, if it's not part of the type already. + if ( + node.type !== undefined && + node.questionToken !== undefined && + !checker.isTypeAssignableTo(checker.getUndefinedType(), checker.getTypeFromTypeNode(node.type)) + ) { + // Synthetic types are never checkable. + isResolvedTypeCheckable = false; + typeToAdd = ts.factory.createUnionTypeNode([ + node.type, + ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), + ]); + } + + // Attempt to extract type from input initial value. No explicit type, but input is required. + // Hence we need an explicit type, or fall back to `typeof`. + if (typeToAdd === undefined && initialValue !== undefined && metadata.required) { + // Synthetic types are never checkable. + isResolvedTypeCheckable = false; + + const propertyType = checker.getTypeAtLocation(node); + if (propertyType.flags & ts.TypeFlags.Boolean) { + typeToAdd = ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); + } else if (propertyType.flags & ts.TypeFlags.String) { + typeToAdd = ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); + } else if (propertyType.flags & ts.TypeFlags.Number) { + typeToAdd = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); + } else if (ts.isIdentifier(initialValue)) { + // @Input({required: true}) bla = SOME_DEFAULT; + typeToAdd = ts.factory.createTypeQueryNode(initialValue); + } else if ( + ts.isPropertyAccessExpression(initialValue) && + ts.isIdentifier(initialValue.name) && + ts.isIdentifier(initialValue.expression) + ) { + // @Input({required: true}) bla = prop.SOME_DEFAULT; + typeToAdd = ts.factory.createTypeQueryNode( + ts.factory.createQualifiedName(initialValue.name, initialValue.expression), + ); + } else { + // Note that we could use `typeToTypeNode` here but it's likely breaking because + // the generated type might depend on imports that we cannot add here (nor want). + return { + context: node, + reason: InputIncompatibilityReason.RequiredInputButNoGoodExplicitTypeExtractable, + }; + } + } + + return { + resolvedMetadata: metadata, + isResolvedTypeCheckable, + resolvedType: typeToAdd, + }; +} diff --git a/packages/core/schematics/migrations/signal-migration/src/migration_host.ts b/packages/core/schematics/migrations/signal-migration/src/migration_host.ts new file mode 100644 index 00000000000..cdbcc512d5b --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/migration_host.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import path from 'path'; +import ts from 'typescript'; + +/** + * A migration host is in practice a container object that + * exposes commonly accessed contextual helpers throughout + * the whole migration. + */ +export class MigrationHost { + private _sourceFiles: WeakSet; + + constructor( + public projectDir: string, + public isMigratingCore: boolean, + public tsOptions: ts.CompilerOptions, + sourceFiles: readonly ts.SourceFile[], + ) { + this._sourceFiles = new WeakSet(sourceFiles); + } + + /** Whether the given file is a source file to be migrated. */ + isSourceFileForCurrentMigration(file: ts.SourceFile): boolean { + return this._sourceFiles.has(file); + } + + /** Retrieves a unique serializable ID for the given source file or file path. */ + fileToId(file: ts.SourceFile | string): string { + if (typeof file !== 'string') { + // Assume that declaration files may appear in different workers, + // and in practice e.g. the input is actually part of a `.ts` file. + if (file.isDeclarationFile) { + file = file.fileName.replace(/\.d\.ts$/, '.ts'); + } else { + file = file.fileName; + } + } + + return path.relative(this.projectDir, file); + } + + /** Converts a serialized file ID to an absolute file path. */ + idToFilePath(id: string): string { + return path.join(this.projectDir, id); + } +} diff --git a/packages/core/schematics/migrations/signal-migration/src/replacement.ts b/packages/core/schematics/migrations/signal-migration/src/replacement.ts new file mode 100644 index 00000000000..2422a0813fd --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/replacement.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import MagicString from 'magic-string'; + +/** Class describing a replacement to be performed. */ +export class Replacement { + constructor( + public pos: number, + public end: number, + public toInsert: string, + ) {} +} + +/** Helper that applies replacements to the given text. */ +export function applyReplacements(input: string, replacements: Replacement[]): string { + const res = new MagicString(input); + for (const replacement of replacements) { + res.remove(replacement.pos, replacement.end); + res.appendLeft(replacement.pos, replacement.toInsert); + } + return res.toString(); +} diff --git a/packages/core/schematics/migrations/signal-migration/src/result.ts b/packages/core/schematics/migrations/signal-migration/src/result.ts new file mode 100644 index 00000000000..159fe12c1bf --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/result.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import ts from 'typescript'; +import {Replacement} from './replacement'; +import {InputDescriptor} from './utils/input_id'; +import {InputReference} from './utils/input_reference'; +import {ConvertInputPreparation} from './convert-input/prepare_and_check'; + +/** + * State of the migration that is passed between + * the individual phases. + * + * The state/phase captures information like: + * - list of inputs that are defined in `.ts` and need migration. + * - list of references. + * - keeps track of computed replacements. + * - imports that may need to be updated. + */ +export class MigrationResult { + // May be `null` if the input cannot be converted. This is also + // signified by an incompatibility- but the input is tracked here as it + // still is a "source input". + sourceInputs = new Map(); + references: InputReference[] = []; + + // Execution data + replacements = new Map(); + inputDecoratorSpecifiers = new Map< + ts.SourceFile, + {node: ts.ImportSpecifier; kind: 'signal-input-import' | 'decorator-input-import'}[] + >(); + + addReplacement(file: string, replacement: Replacement) { + if (this.replacements.has(file)) { + this.replacements.get(file)!.push(replacement); + } else { + this.replacements.set(file, [replacement]); + } + } +} diff --git a/packages/core/schematics/migrations/signal-migration/src/write_replacements.ts b/packages/core/schematics/migrations/signal-migration/src/write_replacements.ts new file mode 100644 index 00000000000..dca34ec9f6a --- /dev/null +++ b/packages/core/schematics/migrations/signal-migration/src/write_replacements.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import ts from 'typescript'; +import fs from 'fs'; +import {applyReplacements} from './replacement'; +import {MigrationResult} from './result'; + +/** Applies the migration result and applies it to the file system. */ +export function writeMigrationReplacements(tsHost: ts.CompilerHost, result: MigrationResult) { + for (const filePath of result.replacements.keys()) { + const fileText = tsHost.readFile(filePath)!; + const newText = applyReplacements(fileText, result.replacements.get(filePath)!); + + fs.writeFileSync(filePath, newText, 'utf8'); + } +}