diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index 34ef576da1..e3f95e2514 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -10,6 +10,7 @@ npm_package( srcs = ["migrations.json"], visibility = ["//packages/core:__pkg__"], deps = [ + "//packages/core/schematics/migrations/move-document", "//packages/core/schematics/migrations/static-queries", "//packages/core/schematics/migrations/template-var-assignment", ], diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 7f83b87884..6fb3a71b9d 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -1,5 +1,10 @@ { "schematics": { + "migration-v8-move-document": { + "version": "8-beta", + "description": "Migrates DOCUMENT Injection token from platform-browser imports to common import", + "factory": "./migrations/move-document/index" + }, "migration-v8-static-queries": { "version": "8-beta", "description": "Migrates ViewChild and ContentChild to explicit query timing", diff --git a/packages/core/schematics/migrations/move-document/BUILD.bazel b/packages/core/schematics/migrations/move-document/BUILD.bazel new file mode 100644 index 0000000000..4cf0ffcd36 --- /dev/null +++ b/packages/core/schematics/migrations/move-document/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "move-document", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/move-document/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], + deps = [ + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/move-document/document_import_visitor.ts b/packages/core/schematics/migrations/move-document/document_import_visitor.ts new file mode 100644 index 0000000000..e9546e22ab --- /dev/null +++ b/packages/core/schematics/migrations/move-document/document_import_visitor.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google Inc. 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 * as ts from 'typescript'; + +export const COMMON_IMPORT = '@angular/common'; +export const PLATFORM_BROWSER_IMPORT = '@angular/platform-browser'; +export const DOCUMENT_TOKEN_NAME = 'DOCUMENT'; + +/** This contains the metadata necessary to move items from one import to another */ +export interface ResolvedDocumentImport { + platformBrowserImport: ts.NamedImports|null; + commonImport: ts.NamedImports|null; + documentElement: ts.ImportSpecifier|null; +} + +/** Visitor that can be used to find a set of imports in a TypeScript file. */ +export class DocumentImportVisitor { + importsMap: Map = new Map(); + + constructor(public typeChecker: ts.TypeChecker) {} + + visitNode(node: ts.Node) { + if (ts.isNamedImports(node)) { + this.visitNamedImport(node); + } + + ts.forEachChild(node, node => this.visitNode(node)); + } + + private visitNamedImport(node: ts.NamedImports) { + if (!node.elements || !node.elements.length) { + return; + } + + const importDeclaration = node.parent.parent; + // If this is not a StringLiteral it will be a grammar error + const moduleSpecifier = importDeclaration.moduleSpecifier as ts.StringLiteral; + const sourceFile = node.getSourceFile(); + let imports = this.importsMap.get(sourceFile); + if (!imports) { + imports = { + platformBrowserImport: null, + commonImport: null, + documentElement: null, + }; + } + + if (moduleSpecifier.text === PLATFORM_BROWSER_IMPORT) { + const documentElement = this.getDocumentElement(node); + if (documentElement) { + imports.platformBrowserImport = node; + imports.documentElement = documentElement; + } + } else if (moduleSpecifier.text === COMMON_IMPORT) { + imports.commonImport = node; + } else { + return; + } + this.importsMap.set(sourceFile, imports); + } + + private getDocumentElement(node: ts.NamedImports): ts.ImportSpecifier|undefined { + const elements = node.elements; + return elements.find(el => (el.propertyName || el.name).escapedText === DOCUMENT_TOKEN_NAME); + } +} diff --git a/packages/core/schematics/migrations/move-document/index.ts b/packages/core/schematics/migrations/move-document/index.ts new file mode 100644 index 0000000000..534af46b20 --- /dev/null +++ b/packages/core/schematics/migrations/move-document/index.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google Inc. 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 {dirname, relative} from 'path'; +import * as ts from 'typescript'; + +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {parseTsconfigFile} from '../../utils/typescript/parse_tsconfig'; +import {COMMON_IMPORT, DOCUMENT_TOKEN_NAME, DocumentImportVisitor, ResolvedDocumentImport} from './document_import_visitor'; +import {addToImport, createImport, removeFromImport} from './move-import'; + + +/** Entry point for the V8 move-document migration. */ +export default function(): Rule { + return (tree: Tree) => { + const projectTsConfigPaths = getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + + if (!projectTsConfigPaths.length) { + throw new SchematicsException(`Could not find any tsconfig file. Cannot migrate DOCUMENT + to new import source.`); + } + + for (const tsconfigPath of projectTsConfigPaths) { + runMoveDocumentMigration(tree, tsconfigPath, basePath); + } + }; +} + +/** + * Runs the DOCUMENT InjectionToken import migration for the given TypeScript project. The + * schematic analyzes the imports within the project and moves the deprecated symbol to the + * new import source. + */ +function runMoveDocumentMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const parsed = parseTsconfigFile(tsconfigPath, dirname(tsconfigPath)); + const host = ts.createCompilerHost(parsed.options, true); + + // We need to overwrite the host "readFile" method, as we want the TypeScript + // program to be based on the file contents in the virtual file tree. Otherwise + // if we run the migration for multiple tsconfig files which have intersecting + // source files, it can end up updating query definitions multiple times. + host.readFile = fileName => { + const buffer = tree.read(relative(basePath, fileName)); + return buffer ? buffer.toString() : undefined; + }; + + const program = ts.createProgram(parsed.fileNames, parsed.options, host); + const typeChecker = program.getTypeChecker(); + const visitor = new DocumentImportVisitor(typeChecker); + const rootSourceFiles = program.getRootFileNames().map(f => program.getSourceFile(f) !); + + // Analyze source files by finding imports. + rootSourceFiles.forEach(sourceFile => visitor.visitNode(sourceFile)); + + const {importsMap} = visitor; + + // Walk through all source files that contain resolved queries and update + // the source files if needed. Note that we need to update multiple queries + // within a source file within the same recorder in order to not throw off + // the TypeScript node offsets. + importsMap.forEach((resolvedImport: ResolvedDocumentImport, sourceFile: ts.SourceFile) => { + const {platformBrowserImport, commonImport, documentElement} = resolvedImport; + if (!documentElement || !platformBrowserImport) { + return; + } + const update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + + const platformBrowserDeclaration = platformBrowserImport.parent.parent; + const newPlatformBrowserText = + removeFromImport(platformBrowserImport, sourceFile, DOCUMENT_TOKEN_NAME); + const newCommonText = commonImport ? + addToImport(commonImport, sourceFile, documentElement.name, documentElement.propertyName) : + createImport(COMMON_IMPORT, sourceFile, documentElement.name, documentElement.propertyName); + + // Replace the existing query decorator call expression with the updated + // call expression node. + update.remove(platformBrowserDeclaration.getStart(), platformBrowserDeclaration.getWidth()); + update.insertRight(platformBrowserDeclaration.getStart(), newPlatformBrowserText); + + if (commonImport) { + const commonDeclaration = commonImport.parent.parent; + update.remove(commonDeclaration.getStart(), commonDeclaration.getWidth()); + update.insertRight(commonDeclaration.getStart(), newCommonText); + } else { + update.insertRight(platformBrowserDeclaration.getStart(), newCommonText); + } + + tree.commitUpdate(update); + }); +} diff --git a/packages/core/schematics/migrations/move-document/move-import.ts b/packages/core/schematics/migrations/move-document/move-import.ts new file mode 100644 index 0000000000..0a9abcc0f5 --- /dev/null +++ b/packages/core/schematics/migrations/move-document/move-import.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google Inc. 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 * as ts from 'typescript'; + +export function removeFromImport( + importNode: ts.NamedImports, sourceFile: ts.SourceFile, importName: string): string { + const printer = ts.createPrinter(); + const elements = importNode.elements.filter( + el => String((el.propertyName || el.name).escapedText) !== importName); + + if (!elements.length) { + return ''; + } + + const oldDeclaration = importNode.parent.parent; + const newImport = ts.createNamedImports(elements); + const importClause = ts.createImportClause(undefined, newImport); + const newDeclaration = ts.createImportDeclaration( + undefined, undefined, importClause, oldDeclaration.moduleSpecifier); + + return printer.printNode(ts.EmitHint.Unspecified, newDeclaration, sourceFile); +} + +export function addToImport( + importNode: ts.NamedImports, sourceFile: ts.SourceFile, name: ts.Identifier, + propertyName?: ts.Identifier): string { + const printer = ts.createPrinter(); + const propertyNameIdentifier = + propertyName ? ts.createIdentifier(String(propertyName.escapedText)) : undefined; + const nameIdentifier = ts.createIdentifier(String(name.escapedText)); + const newSpecfier = ts.createImportSpecifier(propertyNameIdentifier, nameIdentifier); + const elements = [...importNode.elements]; + + elements.push(newSpecfier); + + const oldDeclaration = importNode.parent.parent; + const newImport = ts.createNamedImports(elements); + const importClause = ts.createImportClause(undefined, newImport); + const newDeclaration = ts.createImportDeclaration( + undefined, undefined, importClause, oldDeclaration.moduleSpecifier); + + return printer.printNode(ts.EmitHint.Unspecified, newDeclaration, sourceFile); +} + +export function createImport( + importSource: string, sourceFile: ts.SourceFile, name: ts.Identifier, + propertyName?: ts.Identifier) { + const printer = ts.createPrinter(); + const propertyNameIdentifier = + propertyName ? ts.createIdentifier(String(propertyName.escapedText)) : undefined; + const nameIdentifier = ts.createIdentifier(String(name.escapedText)); + const newSpecfier = ts.createImportSpecifier(propertyNameIdentifier, nameIdentifier); + const newNamedImports = ts.createNamedImports([newSpecfier]); + const importClause = ts.createImportClause(undefined, newNamedImports); + const moduleSpecifier = ts.createStringLiteral(importSource); + const newImport = ts.createImportDeclaration(undefined, undefined, importClause, moduleSpecifier); + + return printer.printNode(ts.EmitHint.Unspecified, newImport, sourceFile); +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index ccd5c67379..9397cdb324 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -8,6 +8,7 @@ ts_library( "//packages/core/schematics:migrations.json", ], deps = [ + "//packages/core/schematics/migrations/move-document", "//packages/core/schematics/migrations/static-queries", "//packages/core/schematics/migrations/static-queries/google3", "//packages/core/schematics/migrations/template-var-assignment", diff --git a/packages/core/schematics/test/move_document_migration_spec.ts b/packages/core/schematics/test/move_document_migration_spec.ts new file mode 100644 index 0000000000..64d181876d --- /dev/null +++ b/packages/core/schematics/test/move_document_migration_spec.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright Google Inc. 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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import * as shx from 'shelljs'; + +describe('move-document migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + beforeEach(() => { + runner = new SchematicTestRunner('test', require.resolve('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile('/tsconfig.json', JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + } + })); + + previousWorkingDir = shx.pwd(); + tmpDirPath = getSystemPath(host.root); + + // Switch into the temporary directory path. This allows us to run + // the schematic against our custom unit test tree. + shx.cd(tmpDirPath); + }); + + afterEach(() => { + shx.cd(previousWorkingDir); + shx.rm('-r', tmpDirPath); + }); + + describe('move-document', () => { + it('should properly apply import replacement', () => { + writeFile('/index.ts', ` + import {DOCUMENT} from '@angular/platform-browser'; + `); + + runMigration(); + + const content = tree.readContent('/index.ts'); + + expect(content).toContain(`import { DOCUMENT } from "@angular/common";`); + expect(content).not.toContain(`import {DOCUMENT} from '@angular/platform-browser';`); + }); + + it('should properly apply import replacement with existing import', () => { + writeFile('/index.ts', ` + import {DOCUMENT} from '@angular/platform-browser'; + import {someImport} from '@angular/common'; + `); + + writeFile('/reverse.ts', ` + import {someImport} from '@angular/common'; + import {DOCUMENT} from '@angular/platform-browser'; + `); + + runMigration(); + + const content = tree.readContent('/index.ts'); + const contentReverse = tree.readContent('/reverse.ts'); + + expect(content).toContain(`import { someImport, DOCUMENT } from '@angular/common';`); + expect(content).not.toContain(`import {DOCUMENT} from '@angular/platform-browser';`); + + expect(contentReverse).toContain(`import { someImport, DOCUMENT } from '@angular/common';`); + expect(contentReverse).not.toContain(`import {DOCUMENT} from '@angular/platform-browser';`); + }); + + it('should properly apply import replacement with existing import w/ comments', () => { + writeFile('/index.ts', ` + /** + * this is a comment + */ + import {someImport} from '@angular/common'; + import {DOCUMENT} from '@angular/platform-browser'; + `); + + runMigration(); + + const content = tree.readContent('/index.ts'); + + expect(content).toContain(`import { someImport, DOCUMENT } from '@angular/common';`); + expect(content).not.toContain(`import {DOCUMENT} from '@angular/platform-browser';`); + + expect(content).toMatch(/.*this is a comment.*/); + }); + + it('should properly apply import replacement with existing and redundant imports', () => { + writeFile('/index.ts', ` + import {DOCUMENT} from '@angular/platform-browser'; + import {anotherImport} from '@angular/platform-browser-dynamic'; + import {someImport} from '@angular/common'; + `); + + runMigration(); + + const content = tree.readContent('/index.ts'); + + expect(content).toContain(`import { someImport, DOCUMENT } from '@angular/common';`); + expect(content).not.toContain(`import {DOCUMENT} from '@angular/platform-browser';`); + }); + + it('should properly apply import replacement with existing import and leave original import', + () => { + writeFile('/index.ts', ` + import {DOCUMENT, anotherImport} from '@angular/platform-browser'; + import {someImport} from '@angular/common'; + `); + + runMigration(); + + const content = tree.readContent('/index.ts'); + + expect(content).toContain(`import { someImport, DOCUMENT } from '@angular/common';`); + expect(content).toContain(`import { anotherImport } from '@angular/platform-browser';`); + }); + + it('should properly apply import replacement with existing import and alias', () => { + writeFile('/index.ts', ` + import {DOCUMENT as doc, anotherImport} from '@angular/platform-browser'; + import {someImport} from '@angular/common'; + `); + + runMigration(); + + const content = tree.readContent('/index.ts'); + + expect(content).toContain(`import { someImport, DOCUMENT as doc } from '@angular/common';`); + expect(content).toContain(`import { anotherImport } from '@angular/platform-browser';`); + }); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { runner.runSchematic('migration-v8-move-document', {}, tree); } +});