feat(core): add ng generate schematic to convert declarations to standalone (#48790)

Implements a new `ng generate @angular/core:standalone` schematic that allows the user to convert all the declarations in a set of NgModules to standalone.

PR Close #48790
This commit is contained in:
Kristiyan Kostadinov 2023-01-20 17:19:18 +01:00 committed by Jessica Janiuk
parent b37a624985
commit a154db8a81
10 changed files with 2117 additions and 1 deletions

View file

@ -12,11 +12,13 @@ pkg_npm(
"collection.json",
"migrations.json",
"package.json",
"//packages/core/schematics/ng-generate/standalone-migration:static_files",
],
validate = False,
visibility = ["//packages/core:__pkg__"],
deps = [
"//packages/core/schematics/migrations/relative-link-resolution:bundle",
"//packages/core/schematics/migrations/router-link-with-href:bundle",
"//packages/core/schematics/ng-generate/standalone-migration:bundle",
],
)

View file

@ -1,3 +1,10 @@
{
"schematics": {}
"schematics": {
"standalone-migration": {
"description": "Converts the entire application or a part of it to standalone",
"factory": "./ng-generate/standalone-migration/bundle",
"schema": "./ng-generate/standalone-migration/schema.json",
"aliases": ["standalone"]
}
}
}

View file

@ -0,0 +1,40 @@
load("//tools:defaults.bzl", "esbuild", "ts_library")
package(
default_visibility = [
"//packages/core/schematics:__pkg__",
"//packages/core/schematics/migrations/google3:__pkg__",
"//packages/core/schematics/test:__pkg__",
],
)
filegroup(
name = "static_files",
srcs = ["schema.json"],
)
ts_library(
name = "standalone-migration",
srcs = glob(["**/*.ts"]),
tsconfig = "//packages/core/schematics:tsconfig.json",
deps = [
"//packages/compiler-cli",
"//packages/compiler-cli/private",
"//packages/core/schematics/utils",
"@npm//@angular-devkit/schematics",
"@npm//@types/node",
"@npm//typescript",
],
)
esbuild(
name = "bundle",
entry_point = ":index.ts",
external = [
"@angular-devkit/*",
"typescript",
],
format = "cjs",
platform = "node",
deps = [":standalone-migration"],
)

View file

@ -0,0 +1,2 @@
# Standalone migration
TODO

View file

@ -0,0 +1,98 @@
/**
* @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 {Rule, SchematicsException, Tree} from '@angular-devkit/schematics';
import {createProgram, NgtscProgram} from '@angular/compiler-cli';
import {existsSync, statSync} from 'fs';
import {join, relative} from 'path';
import ts from 'typescript';
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
import {canMigrateFile, createProgramOptions} from '../../utils/typescript/compiler_host';
import {toStandalone} from './to-standalone';
import {ChangesByFile} from './util';
enum MigrationMode {
toStandalone = 'convert-to-standalone',
}
interface Options {
path: string;
mode: MigrationMode;
}
export default function(options: Options): Rule {
return async (tree) => {
const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree);
const basePath = process.cwd();
const allPaths = [...buildPaths, ...testPaths];
if (!allPaths.length) {
throw new SchematicsException(
'Could not find any tsconfig file. Cannot run the standalone migration.');
}
for (const tsconfigPath of allPaths) {
standaloneMigration(tree, tsconfigPath, basePath, options);
}
};
}
function standaloneMigration(tree: Tree, tsconfigPath: string, basePath: string, options: Options) {
if (options.path.startsWith('..')) {
throw new SchematicsException(
'Cannot run standalone migration outside of the current project.');
}
const {host, rootNames} = createProgramOptions(tree, tsconfigPath, basePath);
const program = createProgram({
rootNames,
host,
options: {_enableTemplateTypeChecker: true, compileNonExportedClasses: true}
}) as NgtscProgram;
const printer = ts.createPrinter();
const pathToMigrate = join(basePath, options.path);
if (existsSync(pathToMigrate) && !statSync(pathToMigrate).isDirectory()) {
throw new SchematicsException(`Migration path ${
pathToMigrate} has to be a directory. Cannot run the standalone migration.`);
}
const sourceFiles = program.getTsProgram().getSourceFiles().filter(sourceFile => {
return sourceFile.fileName.startsWith(pathToMigrate) &&
canMigrateFile(basePath, sourceFile, program.getTsProgram());
});
if (sourceFiles.length === 0) {
throw new SchematicsException(`Could not find any files to migrate under the path ${
pathToMigrate}. Cannot run the standalone migration.`);
}
let pendingChanges: ChangesByFile;
if (options.mode === MigrationMode.toStandalone) {
pendingChanges = toStandalone(sourceFiles, program, printer);
} else {
throw new SchematicsException(
`Unknown schematic mode ${options.mode}. Cannot run the standalone migration.`);
}
for (const [file, changes] of pendingChanges.entries()) {
const update = tree.beginUpdate(relative(basePath, file.fileName));
changes.forEach(change => {
if (change.removeLength != null) {
update.remove(change.start, change.removeLength);
}
update.insertRight(change.start, change.text);
});
tree.commitUpdate(update);
}
}

View file

@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "AngularStandaloneMigration",
"title": "Angular Standalone Migration Schema",
"type": "object",
"properties": {
"mode": {
"description": "Operation that should be performed by the migrator",
"type": "string",
"default": "convert-to-standalone",
"x-prompt": {
"message": "Choose the type of migration:",
"type": "list",
"items": [
{
"value": "convert-to-standalone",
"label": "Convert all components, directives and pipes to standalone"
}
]
}
},
"path": {
"type": "string",
"$default": {
"$source": "workingDirectory"
},
"description": "Path relative to the project root in which to look for declarations to migrate to standalone",
"x-prompt": "Which path in your project should be migrated to standalone?",
"default": "./"
}
},
"required": ["mode", "path"]
}

View file

@ -0,0 +1,500 @@
/*!
* @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 {NgtscProgram} from '@angular/compiler-cli';
import {PotentialImport, PotentialImportKind, PotentialImportMode, Reference, TemplateTypeChecker} from '@angular/compiler-cli/private/migrations';
import ts from 'typescript';
import {getAngularDecorators, NgDecorator} from '../../utils/ng_decorators';
import {getImportSpecifier} from '../../utils/typescript/imports';
import {isReferenceToImport} from '../../utils/typescript/symbol';
import {ChangesByFile, ChangeTracker, findClassDeclaration, findLiteralProperty, NamedClassDeclaration} from './util';
/**
* Converts all declarations in the specified files to standalone.
* @param sourceFiles Files that should be migrated.
* @param program
* @param printer
*/
export function toStandalone(
sourceFiles: ts.SourceFile[], program: NgtscProgram, printer: ts.Printer): ChangesByFile {
const templateTypeChecker = program.compiler.getTemplateTypeChecker();
const typeChecker = program.getTsProgram().getTypeChecker();
const modulesToMigrate: ts.ClassDeclaration[] = [];
const testObjectsToMigrate: ts.ObjectLiteralExpression[] = [];
const declarations: Reference<ts.ClassDeclaration>[] = [];
const tracker = new ChangeTracker(printer);
for (const sourceFile of sourceFiles) {
const {modules, testObjects} = findModulesToMigrate(sourceFile, typeChecker);
modules.forEach(
module => declarations.push(...extractDeclarationsFromModule(module, templateTypeChecker)));
modulesToMigrate.push(...modules);
testObjectsToMigrate.push(...testObjects);
}
for (const declaration of declarations) {
convertNgModuleDeclarationToStandalone(declaration, declarations, tracker, templateTypeChecker);
}
for (const node of modulesToMigrate) {
migrateNgModuleClass(node, tracker, templateTypeChecker);
}
migrateTestDeclarations(testObjectsToMigrate, tracker, typeChecker);
return tracker.recordChanges();
}
/**
* Converts a single declaration defined through an NgModule to standalone.
* @param ref References to the declaration being converted.
* @param tracker Tracker used to track the file changes.
* @param allDeclarations All the declarations that are being converted as a part of this migration.
* @param typeChecker
*/
export function convertNgModuleDeclarationToStandalone(
ref: Reference<ts.ClassDeclaration>, allDeclarations: Reference<ts.ClassDeclaration>[],
tracker: ChangeTracker, typeChecker: TemplateTypeChecker): void {
const directiveMeta = typeChecker.getDirectiveMetadata(ref.node);
if (directiveMeta && directiveMeta.decorator && !directiveMeta.isStandalone) {
let decorator = addStandaloneToDecorator(directiveMeta.decorator);
if (directiveMeta.isComponent) {
const importsToAdd =
getComponentImportExpressions(ref, allDeclarations, tracker, typeChecker);
if (importsToAdd.length > 0) {
decorator = addPropertyToAngularDecorator(
decorator,
ts.factory.createPropertyAssignment(
'imports', ts.factory.createArrayLiteralExpression(importsToAdd)));
}
}
tracker.replaceNode(directiveMeta.decorator, decorator);
} else {
const pipeMeta = typeChecker.getPipeMetadata(ref.node);
if (pipeMeta && pipeMeta.decorator && !pipeMeta.isStandalone) {
tracker.replaceNode(pipeMeta.decorator, addStandaloneToDecorator(pipeMeta.decorator));
}
}
}
/**
* Gets the expressions that should be added to a component's
* `imports` array based on its template dependencies.
* @param ref Reference to the component class.
* @param allDeclarations All the declarations that are being converted as a part of this migration.
* @param tracker
* @param typeChecker
*/
function getComponentImportExpressions(
ref: Reference<ts.ClassDeclaration>, allDeclarations: Reference<ts.ClassDeclaration>[],
tracker: ChangeTracker, typeChecker: TemplateTypeChecker): ts.Expression[] {
const templateDependencies = findTemplateDependencies(ref, typeChecker);
const usedDependenciesInMigration = new Set(templateDependencies.filter(
dep => allDeclarations.find(current => current.node === dep.node)));
const imports: ts.Expression[] = [];
const seenImports = new Set<string>();
for (const dep of templateDependencies) {
const importLocation = findImportLocation(
dep as Reference<NamedClassDeclaration>, ref,
usedDependenciesInMigration.has(dep) ? PotentialImportMode.ForceDirect :
PotentialImportMode.Normal,
typeChecker);
if (importLocation && !seenImports.has(importLocation.symbolName)) {
if (importLocation.moduleSpecifier) {
const identifier = tracker.addImport(
ref.node.getSourceFile(), importLocation.symbolName, importLocation.moduleSpecifier);
imports.push(identifier);
} else {
imports.push(ts.factory.createIdentifier(importLocation.symbolName));
}
seenImports.add(importLocation.symbolName);
}
}
return imports;
}
/**
* Moves all of the declarations of a class decorated with `@NgModule` to its imports.
* @param node Class being migrated.
* @param tracker
* @param typeChecker
*/
function migrateNgModuleClass(
node: ts.ClassDeclaration, tracker: ChangeTracker, typeChecker: TemplateTypeChecker) {
const decorator = typeChecker.getNgModuleMetadata(node)?.decorator;
if (decorator && ts.isCallExpression(decorator.expression) &&
decorator.expression.arguments.length === 1 &&
ts.isObjectLiteralExpression(decorator.expression.arguments[0])) {
moveDeclarationsToImports(decorator.expression.arguments[0], tracker);
}
}
/**
* Moves all the symbol references from the `declarations` array to the `imports`
* array of an `NgModule` class and removes the `declarations`.
* @param literal Object literal used to configure the module that should be migrated.
* @param tracker
*/
function moveDeclarationsToImports(
literal: ts.ObjectLiteralExpression, tracker: ChangeTracker): void {
const properties =
literal.properties
.map(prop => {
if (!isNamedPropertyAssignment(prop)) {
return prop;
}
// If there's no `imports`, copy the initializer from the `declarations`.
if (prop.name.text === 'declarations' && !findLiteralProperty(literal, 'imports')) {
return ts.factory.createPropertyAssignment('imports', prop.initializer);
}
// Migrate the `imports`.
if (prop.name.text === 'imports') {
const declarations = findLiteralProperty(literal, 'declarations');
return declarations && ts.isPropertyAssignment(declarations) ?
mergeDeclarationsIntoImports(declarations, prop) :
prop;
}
// Retain any remaining properties.
return prop;
})
// Drop the `declarations` property.
.filter(prop => isNamedPropertyAssignment(prop) && prop.name.text !== 'declarations');
tracker.replaceNode(
literal, ts.factory.createObjectLiteralExpression(properties, true), ts.EmitHint.Expression);
}
/**
* Merges the `declarations` and `imports` arrays of an NgModule.
* @param declarations Node that declares the `declarations` property.
* @param imports Node that declares the `imports` property.
*/
function mergeDeclarationsIntoImports(
declarations: ts.PropertyAssignment, imports: ts.PropertyAssignment) {
const importsIsArray = ts.isArrayLiteralExpression(imports.initializer);
const declarationsIsArray = ts.isArrayLiteralExpression(declarations.initializer);
let arrayElements: ts.Expression[];
if (importsIsArray && declarationsIsArray) {
// Both values are arrays so they can be merged statically.
// E.g. `imports: [Import1, Import2, Declaration1]`.
arrayElements = [...imports.initializer.elements, ...declarations.initializer.elements];
} else if (importsIsArray) {
// Only the imports is an array so we need to use a spread to merge the
// declarations. E.g. `imports: [Import1, Import2, ...DECLARATIONS]`.
arrayElements =
[...imports.initializer.elements, ts.factory.createSpreadElement(declarations.initializer)];
} else if (declarationsIsArray) {
// Declarations are an array, but imports aren't so we have to generate a spread.
// E.g. `imports: [...IMPORTS, Declaration1, Declaration2]`.
arrayElements =
[ts.factory.createSpreadElement(imports.initializer), ...declarations.initializer.elements];
} else {
// Neither the declarations nor the imports are arrays so we have to use spread for
// both. E.g. `imports: [...IMPORTS, ...DECLARATIONS]`.
arrayElements = [
ts.factory.createSpreadElement(imports.initializer),
ts.factory.createSpreadElement(declarations.initializer)
];
}
return ts.factory.createPropertyAssignment(
imports.name, ts.factory.createArrayLiteralExpression(arrayElements));
}
/** Adds `standalone: true` to a decorator node. */
function addStandaloneToDecorator(node: ts.Decorator): ts.Decorator {
return addPropertyToAngularDecorator(
node,
ts.factory.createPropertyAssignment(
'standalone', ts.factory.createToken(ts.SyntaxKind.TrueKeyword)));
}
/**
* Adds a property to an Angular decorator node.
* @param node Decorator to which to add the property.
* @param property Property to add.
*/
function addPropertyToAngularDecorator(
node: ts.Decorator, property: ts.PropertyAssignment): ts.Decorator {
// Invalid decorator.
if (!ts.isCallExpression(node.expression) || node.expression.arguments.length > 1) {
return node;
}
let literalProperties: ts.ObjectLiteralElementLike[];
if (node.expression.arguments.length === 0) {
literalProperties = [property];
} else if (ts.isObjectLiteralExpression(node.expression.arguments[0])) {
literalProperties = [...node.expression.arguments[0].properties, property];
} else {
// Unsupported case (e.g. `@Component(SOME_CONST)`). Return the original node.
return node;
}
return ts.factory.updateDecorator(
node,
ts.factory.createCallExpression(node.expression.expression, node.expression.typeArguments, [
ts.factory.createObjectLiteralExpression(literalProperties, literalProperties.length > 1)
]));
}
/** Checks if a node is a `PropertyAssignment` with a name. */
function isNamedPropertyAssignment(node: ts.Node): node is ts.PropertyAssignment&
{name: ts.Identifier} {
return ts.isPropertyAssignment(node) && node.name && ts.isIdentifier(node.name);
}
/**
* Finds the import from which to bring in a template dependency of a component.
* @param target Dependency that we're searching for.
* @param inComponent Component in which the dependency is used.
* @param importMode Mode in which to resolve the import target.
* @param typeChecker
*/
function findImportLocation(
target: Reference<NamedClassDeclaration>, inComponent: Reference<ts.ClassDeclaration>,
importMode: PotentialImportMode, typeChecker: TemplateTypeChecker): PotentialImport|null {
const importLocations = typeChecker.getPotentialImportsFor(target, inComponent.node, importMode);
let firstModuleImport: PotentialImport|null = null;
for (const location of importLocations) {
// Prefer a standalone import, if we can find one.
// Otherwise fall back to the first module-based import.
if (location.kind === PotentialImportKind.Standalone) {
return location;
}
if (location.kind === PotentialImportKind.NgModule && !firstModuleImport) {
firstModuleImport = location;
}
}
return firstModuleImport;
}
/**
* Checks whether a node is an `NgModule` metadata element with at least one element.
* E.g. `declarations: [Foo]` or `declarations: SOME_VAR` would match this description,
* but not `declarations: []`.
*/
function hasNgModuleMetadataElements(node: ts.Node): node is ts.PropertyAssignment&
{initializer: ts.ArrayLiteralExpression} {
return ts.isPropertyAssignment(node) &&
(!ts.isArrayLiteralExpression(node.initializer) || node.initializer.elements.length > 0);
}
/** Finds all modules whose declarations can be migrated. */
function findModulesToMigrate(sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) {
const modules: ts.ClassDeclaration[] = [];
const testObjects: ts.ObjectLiteralExpression[] = [];
const testBedImport = getImportSpecifier(sourceFile, '@angular/core/testing', 'TestBed');
const catalystImport = getImportSpecifier(sourceFile, /testing\/catalyst$/, 'setupModule');
sourceFile.forEachChild(function walk(node) {
if (ts.isClassDeclaration(node)) {
const decorator = getAngularDecorators(typeChecker, ts.getDecorators(node) || [])
.find(current => current.name === 'NgModule');
const metadata = decorator?.node.expression.arguments[0];
if (metadata && ts.isObjectLiteralExpression(metadata)) {
const declarations = findLiteralProperty(metadata, 'declarations');
const bootstrap = findLiteralProperty(metadata, 'bootstrap');
const hasDeclarations = declarations != null && hasNgModuleMetadataElements(declarations);
const hasBootstrap = bootstrap != null && hasNgModuleMetadataElements(bootstrap);
// Skip modules that bootstrap components since changing them would also involve
// converting `bootstrapModule` calls to `bootstrapApplication`. These declarations
// will be converted to standalone in the `standalone-bootstrap` step.
if (hasDeclarations && !hasBootstrap) {
modules.push(node);
}
}
} else if (
ts.isCallExpression(node) && node.arguments.length > 0 &&
ts.isObjectLiteralExpression(node.arguments[0])) {
if ((testBedImport && ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'configureTestingModule' &&
isReferenceToImport(typeChecker, node.expression.expression, testBedImport)) ||
(catalystImport && ts.isIdentifier(node.expression) &&
isReferenceToImport(typeChecker, node.expression, catalystImport))) {
testObjects.push(node.arguments[0]);
}
}
node.forEachChild(walk);
});
return {modules, testObjects};
}
/**
* Finds the classes corresponding to dependencies used in a component's template.
* @param ref Component in whose template we're looking for dependencies.
* @param typeChecker
*/
function findTemplateDependencies(
ref: Reference<ts.ClassDeclaration>,
typeChecker: TemplateTypeChecker): Reference<NamedClassDeclaration>[] {
const results: Reference<NamedClassDeclaration>[] = [];
const usedDirectives = typeChecker.getUsedDirectives(ref.node);
const usedPipes = typeChecker.getUsedPipes(ref.node);
if (usedDirectives !== null) {
for (const dir of usedDirectives) {
if (ts.isClassDeclaration(dir.ref.node)) {
results.push(dir.ref as Reference<NamedClassDeclaration>);
}
}
}
if (usedPipes !== null) {
const potentialPipes = typeChecker.getPotentialPipes(ref.node);
for (const pipe of potentialPipes) {
if (ts.isClassDeclaration(pipe.ref.node) &&
usedPipes.some(current => pipe.name === current)) {
results.push(pipe.ref as Reference<NamedClassDeclaration>);
}
}
}
return results;
}
/** Extracts classes that are referred to in a module's `declarations` array. */
function extractDeclarationsFromModule(
ngModule: ts.ClassDeclaration,
typeChecker: TemplateTypeChecker): Reference<ts.ClassDeclaration>[] {
return typeChecker.getNgModuleMetadata(ngModule)?.declarations.filter(
decl => ts.isClassDeclaration(decl.node)) as Reference<ts.ClassDeclaration>[] ||
[];
}
/**
* Migrates the `declarations` from a unit test file to standalone.
* @param testObjects Object literals used to configure the testing modules.
* @param tracker
* @param typeChecker
*/
function migrateTestDeclarations(
testObjects: ts.ObjectLiteralExpression[], tracker: ChangeTracker,
typeChecker: ts.TypeChecker) {
const {decorators, componentImports} = analyzeTestingModules(testObjects, tracker, typeChecker);
for (const decorator of decorators) {
if (decorator.name === 'Pipe' || decorator.name === 'Directive') {
tracker.replaceNode(decorator.node, addStandaloneToDecorator(decorator.node));
} else if (decorator.name === 'Component') {
const newDecorator = addStandaloneToDecorator(decorator.node);
const importsToAdd = componentImports.get(decorator.node);
if (importsToAdd && importsToAdd.size > 0) {
tracker.replaceNode(
decorator.node,
addPropertyToAngularDecorator(
newDecorator,
ts.factory.createPropertyAssignment(
'imports', ts.factory.createArrayLiteralExpression(Array.from(importsToAdd)))));
} else {
tracker.replaceNode(decorator.node, newDecorator);
}
}
}
}
/**
* Analyzes a set of objects used to configure testing modules and returns the AST
* nodes that need to be migrated and the imports that should be added to the imports
* of any declared components.
* @param testObjects Object literals that should be analyzed.
*/
function analyzeTestingModules(
testObjects: ts.ObjectLiteralExpression[], tracker: ChangeTracker,
typeChecker: ts.TypeChecker) {
const seenDeclarations = new Set<ts.Declaration>();
const decorators: NgDecorator[] = [];
const componentImports = new Map<ts.Decorator, Set<ts.Expression>>();
for (const obj of testObjects) {
const declarations = extractDeclarationsFromTestObject(obj, typeChecker);
const importsProp = findLiteralProperty(obj, 'imports');
const importElements = importsProp && hasNgModuleMetadataElements(importsProp) ?
importsProp.initializer.elements :
null;
moveDeclarationsToImports(obj, tracker);
for (const decl of declarations) {
if (seenDeclarations.has(decl)) {
continue;
}
const [decorator] = getAngularDecorators(typeChecker, ts.getDecorators(decl) || []);
if (decorator) {
seenDeclarations.add(decl);
decorators.push(decorator);
if (decorator.name === 'Component' && importElements) {
// We try to de-duplicate the imports being added to a component, because it may be
// declared in different testing modules with a different set of imports.
let imports = componentImports.get(decorator.node);
if (!imports) {
imports = new Set();
componentImports.set(decorator.node, imports);
}
importElements.forEach(imp => imports!.add(imp));
}
}
}
}
return {decorators, componentImports};
}
/**
* Finds the class declarations that are being referred
* to in the `declarations` of an object literal.
* @param obj Object literal that may contain the declarations.
* @param typeChecker
*/
function extractDeclarationsFromTestObject(
obj: ts.ObjectLiteralExpression, typeChecker: ts.TypeChecker): ts.ClassDeclaration[] {
const results: ts.ClassDeclaration[] = [];
const declarations = findLiteralProperty(obj, 'declarations');
if (declarations && hasNgModuleMetadataElements(declarations)) {
for (const element of declarations.initializer.elements) {
const declaration = findClassDeclaration(element, typeChecker);
// Note that we only migrate classes that are in the same file as the testing module,
// because external fixture components are somewhat rare and handling them is going
// to involve a lot of assumptions that are likely to be incorrect.
if (declaration && declaration.getSourceFile().fileName === obj.getSourceFile().fileName) {
results.push(declaration);
}
}
}
return results;
}

View file

@ -0,0 +1,256 @@
/*!
* @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 {NgtscProgram} from '@angular/compiler-cli';
import {dirname, relative} from 'path';
import ts from 'typescript';
import {ImportManager} from '../../utils/import_manager';
/** Mapping between a source file and the changes that have to be applied to it. */
export type ChangesByFile = ReadonlyMap<ts.SourceFile, PendingChange[]>;
/** Map used to look up nodes based on their positions in a source file. */
export type NodeLookup = Map<number, ts.Node[]>;
/** Utility to type a class declaration with a name. */
export type NamedClassDeclaration = ts.ClassDeclaration&{name: ts.Identifier};
/** Change that needs to be applied to a file. */
interface PendingChange {
/** Index at which to start changing the file. */
start: number;
/**
* Amount of text that should be removed after the `start`.
* No text will be removed if omitted.
*/
removeLength?: number;
/** New text that should be inserted. */
text: string;
}
/** Tracks changes that have to be made for specific files. */
export class ChangeTracker {
private readonly _changes = new Map<ts.SourceFile, PendingChange[]>();
private readonly _importManager = new ImportManager(
currentFile => ({
addNewImport: (start, text) => this.insertText(currentFile, start, text),
updateExistingImport: (namedBindings, text) =>
this.replaceText(currentFile, namedBindings.getStart(), namedBindings.getWidth(), text),
}),
this._printer);
constructor(private _printer: ts.Printer) {}
/**
* Tracks the insertion of some text.
* @param sourceFile File in which the text is being inserted.
* @param start Index at which the text is insert.
* @param text Text to be inserted.
*/
insertText(sourceFile: ts.SourceFile, index: number, text: string): void {
this._trackChange(sourceFile, {start: index, text});
}
/**
* Replaces text within a file.
* @param sourceFile File in which to replace the text.
* @param start Index from which to replace the text.
* @param removeLength Length of the text being replaced.
* @param text Text to be inserted instead of the old one.
*/
replaceText(sourceFile: ts.SourceFile, start: number, removeLength: number, text: string): void {
this._trackChange(sourceFile, {start, removeLength, text});
}
/**
* Replaces the text of an AST node with a new one.
* @param oldNode Node to be replaced.
* @param newNode New node to be inserted.
* @param emitHint Hint when formatting the text of the new node.
* @param sourceFileWhenPrinting File to use when printing out the new node. This is important
* when copying nodes from one file to another, because TypeScript might not output literal nodes
* without it.
*/
replaceNode(
oldNode: ts.Node, newNode: ts.Node, emitHint = ts.EmitHint.Unspecified,
sourceFileWhenPrinting?: ts.SourceFile): void {
const sourceFile = oldNode.getSourceFile();
this.replaceText(
sourceFile, oldNode.getStart(), oldNode.getWidth(),
this._printer.printNode(emitHint, newNode, sourceFileWhenPrinting || sourceFile));
}
/**
* Removes the text of an AST node from a file.
* @param node Node whose text should be removed.
*/
removeNode(node: ts.Node): void {
this._trackChange(
node.getSourceFile(), {start: node.getStart(), removeLength: node.getWidth(), text: ''});
}
/**
* Adds an import to a file.
* @param sourceFile File to which to add the import.
* @param symbolName Symbol being imported.
* @param moduleName Module from which the symbol is imported.
*/
addImport(
sourceFile: ts.SourceFile, symbolName: string, moduleName: string,
alias: string|null = null): ts.Expression {
return this._importManager.addImportToSourceFile(sourceFile, symbolName, moduleName, alias);
}
/**
* Gets the changes that should be applied to all the files in the migration.
* The changes are sorted in the order in which they should be applied.
*/
recordChanges(): ChangesByFile {
this._importManager.recordChanges();
return this._changes;
}
/**
* Adds a change to a `ChangesByFile` map.
* @param file File that the change is associated with.
* @param change Change to be added.
*/
private _trackChange(file: ts.SourceFile, change: PendingChange): void {
const changes = this._changes.get(file);
if (changes) {
// Insert the changes in reverse so that they're applied in reverse order.
// This ensures that the offsets of subsequent changes aren't affected by
// previous changes changing the file's text.
const insertIndex = changes.findIndex(current => current.start <= change.start);
if (insertIndex === -1) {
changes.push(change);
} else {
changes.splice(insertIndex, 0, change);
}
} else {
this._changes.set(file, [change]);
}
}
}
/** Utility class used to track a one-to-many relationship where all the items are unique. */
export class UniqueItemTracker<K, V> {
private _nodes = new Map<K, Set<V>>();
track(key: K, item: V) {
const set = this._nodes.get(key);
if (set) {
set.add(item);
} else {
this._nodes.set(key, new Set([item]));
}
}
getEntries(): IterableIterator<[K, Set<V>]> {
return this._nodes.entries();
}
}
/**
* Creates a TypeScript language service.
* @param program Program used to analyze the project.
* @param host Compiler host used to interact with the file system.
* @param rootFileNames Root files of the project.
* @param basePath Root path of the project.
*/
export function createLanguageService(
program: NgtscProgram, host: ts.CompilerHost, rootFileNames: string[],
basePath: string): ts.LanguageService {
return ts.createLanguageService({
getCompilationSettings: () => program.getTsProgram().getCompilerOptions(),
getScriptFileNames: () => rootFileNames,
getScriptVersion: () => '0', // The files won't change so we can return the same version.
getScriptSnapshot: (fileName: string) => {
const content = host.readFile(fileName);
return content ? ts.ScriptSnapshot.fromString(content) : undefined;
},
getCurrentDirectory: () => basePath,
getDefaultLibFileName: options => ts.getDefaultLibFilePath(options),
readFile: (path: string) => host.readFile(path),
fileExists: (path: string) => host.fileExists(path)
});
}
/** Creates a NodeLookup object from a source file. */
export function getNodeLookup(sourceFile: ts.SourceFile): NodeLookup {
const lookup: NodeLookup = new Map();
sourceFile.forEachChild(function walk(node) {
const nodesAtStart = lookup.get(node.getStart());
if (nodesAtStart) {
nodesAtStart.push(node);
} else {
lookup.set(node.getStart(), [node]);
}
node.forEachChild(walk);
});
return lookup;
}
/**
* Converts node offsets to the nodes they correspond to.
* @param lookup Data structure used to look up nodes at particular positions.
* @param offsets Offsets of the nodes.
* @param results Set in which to store the results.
*/
export function offsetsToNodes(
lookup: NodeLookup, offsets: [start: number, end: number][],
results: Set<ts.Node>): Set<ts.Node> {
for (const [start, end] of offsets) {
const match = lookup.get(start)?.find(node => node.getEnd() === end);
if (match) {
results.add(match);
}
}
return results;
}
/**
* Finds the class declaration that is being referred to by a node.
* @param reference Node referring to a class declaration.
* @param typeChecker
*/
export function findClassDeclaration(
reference: ts.Node, typeChecker: ts.TypeChecker): ts.ClassDeclaration|null {
return typeChecker.getTypeAtLocation(reference).getSymbol()?.declarations?.find(
ts.isClassDeclaration) ||
null;
}
/** Finds a property with a specific name in an object literal expression. */
export function findLiteralProperty(literal: ts.ObjectLiteralExpression, name: string) {
return literal.properties.find(
prop => prop.name && ts.isIdentifier(prop.name) && prop.name.text === name);
}
/** Gets a relative path between two files that can be used inside a TypeScript import. */
export function getRelativeImportPath(fromFile: string, toFile: string): string {
let path = relative(dirname(fromFile), toFile).replace(/\.ts$/, '');
// `relative` returns paths inside the same directory without `./`
if (!path.startsWith('.')) {
path = './' + path;
}
return path;
}

View file

@ -23,6 +23,9 @@ jasmine_node_test(
"//packages/core/schematics/migrations/relative-link-resolution:bundle",
"//packages/core/schematics/migrations/router-link-with-href",
"//packages/core/schematics/migrations/router-link-with-href:bundle",
"//packages/core/schematics/ng-generate/standalone-migration",
"//packages/core/schematics/ng-generate/standalone-migration:bundle",
"//packages/core/schematics/ng-generate/standalone-migration:static_files",
],
templated_args = ["--nobazel_run_linker"],
deps = [

File diff suppressed because it is too large Load diff