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'); + } +}