From a461ce1ee9703d53914e054d9c7bb25193dc20c7 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 14 Oct 2019 13:59:32 +0200 Subject: [PATCH] feat(material/ng-update): add migration for hammerjs in version 9 --- src/cdk/private/testing/public-api.ts | 1 + src/cdk/private/testing/text-dedent.ts | 28 + src/cdk/schematics/index.ts | 7 + src/cdk/schematics/ng-update/index.ts | 4 +- .../ng-update/upgrade-rules/index.ts | 32 +- src/cdk/schematics/testing/test-case-setup.ts | 17 +- src/cdk/schematics/update-tool/index.ts | 23 +- .../schematics/update-tool/migration-rule.ts | 49 +- .../schematics/update-tool/target-version.ts | 1 + src/cdk/schematics/utils/index.ts | 1 + .../schematics/utils/project-index-file.ts | 21 + src/cdk/schematics/utils/project-targets.ts | 27 +- src/material/schematics/BUILD.bazel | 3 +- .../schematics/ng-add/fonts/material-fonts.ts | 19 +- .../ng-add/fonts/project-index-html.ts | 23 - src/material/schematics/ng-add/index.spec.ts | 23 +- .../schematics/ng-add/theming/theming.ts | 7 +- src/material/schematics/ng-update/index.ts | 2 + .../test-cases/v9/hammer-migration-v9.spec.ts | 691 ++++++++++++++++ .../hammer-gestures-v9/cli-workspace.ts | 59 ++ .../find-hammer-script-tags.ts | 44 + .../gesture-config.template | 105 +++ .../hammer-gestures-rule.ts | 759 ++++++++++++++++++ .../hammer-template-check.ts | 48 ++ .../hammer-gestures-v9/identifier-imports.ts | 113 +++ .../hammer-gestures-v9/import-manager.ts | 463 +++++++++++ .../remove-array-element.ts | 67 ++ .../remove-element-from-html.ts | 33 + 28 files changed, 2589 insertions(+), 81 deletions(-) create mode 100644 src/cdk/private/testing/text-dedent.ts create mode 100644 src/cdk/schematics/utils/project-index-file.ts delete mode 100644 src/material/schematics/ng-add/fonts/project-index-html.ts create mode 100644 src/material/schematics/ng-update/test-cases/v9/hammer-migration-v9.spec.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/cli-workspace.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/find-hammer-script-tags.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/gesture-config.template create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-gestures-rule.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-template-check.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/identifier-imports.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/import-manager.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-array-element.ts create mode 100644 src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-element-from-html.ts diff --git a/src/cdk/private/testing/public-api.ts b/src/cdk/private/testing/public-api.ts index b41c0604195a..43642063b8b8 100644 --- a/src/cdk/private/testing/public-api.ts +++ b/src/cdk/private/testing/public-api.ts @@ -9,3 +9,4 @@ export * from './expect-async-error'; export * from './wrapped-error-message'; export * from './mock-ng-zone'; +export * from './text-dedent'; diff --git a/src/cdk/private/testing/text-dedent.ts b/src/cdk/private/testing/text-dedent.ts new file mode 100644 index 000000000000..e3ccadcccd42 --- /dev/null +++ b/src/cdk/private/testing/text-dedent.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 + */ + +/** + * Template string function that can be used to dedent a given string + * literal. The smallest common indentation will be omitted. + */ +export function dedent(strings: TemplateStringsArray, ...values: any[]) { + let joinedString = ''; + for (let i = 0; i < values.length; i++) { + joinedString += `${strings[i]}${values[i]}`; + } + joinedString += strings[strings.length - 1]; + + const matches = joinedString.match(/^[ \t]*(?=\S)/gm); + if (matches === null) { + return joinedString; + } + + const minLineIndent = Math.min(...matches.map(el => el.length)); + const omitMinIndentRegex = new RegExp(`^[ \\t]{${minLineIndent}}`, 'gm'); + return minLineIndent > 0 ? joinedString.replace(omitMinIndentRegex, '') : joinedString; +} diff --git a/src/cdk/schematics/index.ts b/src/cdk/schematics/index.ts index 60d3eeabde01..1bdf47dadb19 100644 --- a/src/cdk/schematics/index.ts +++ b/src/cdk/schematics/index.ts @@ -9,3 +9,10 @@ export * from './utils'; export * from './ng-update/public-api'; export * from './update-tool/public-api'; + +// Re-export parse5 from the CDK. Material schematics code cannot simply import +// "parse5" because it could result in a different version. As long as we import +// it from within the CDK, it will always be the correct version that is specified +// in the CDK "package.json" as optional dependency. +import * as parse5 from 'parse5'; +export {parse5}; diff --git a/src/cdk/schematics/ng-update/index.ts b/src/cdk/schematics/ng-update/index.ts index deb68fb53523..e0966aff1ea6 100644 --- a/src/cdk/schematics/ng-update/index.ts +++ b/src/cdk/schematics/ng-update/index.ts @@ -40,7 +40,7 @@ function onMigrationComplete(targetVersion: TargetVersion, hasFailures: boolean) if (hasFailures) { console.log(chalk.yellow( - ' ⚠ Some issues were detected but could not be fixed automatically. Please check the ' + - 'output above and fix these issues manually.')); + ' ⚠ Some issues were detected but could not be fixed automatically. Please check the ' + + 'output above and fix these issues manually.')); } } diff --git a/src/cdk/schematics/ng-update/upgrade-rules/index.ts b/src/cdk/schematics/ng-update/upgrade-rules/index.ts index 8cd49ae1034e..db5d1e25461a 100644 --- a/src/cdk/schematics/ng-update/upgrade-rules/index.ts +++ b/src/cdk/schematics/ng-update/upgrade-rules/index.ts @@ -8,8 +8,7 @@ import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics'; -import {Constructor, runMigrationRules} from '../../update-tool'; -import {MigrationRule} from '../../update-tool/migration-rule'; +import {MigrationRuleType, runMigrationRules} from '../../update-tool'; import {TargetVersion} from '../../update-tool/target-version'; import {getProjectTsConfigPaths} from '../../utils/project-tsconfig-paths'; import {RuleUpgradeData} from '../upgrade-data'; @@ -28,7 +27,7 @@ import {PropertyNamesRule} from './property-names-rule'; /** List of migration rules which run for the CDK update. */ -export const cdkMigrationRules: Constructor>[] = [ +export const cdkMigrationRules: MigrationRuleType[] = [ AttributeSelectorsRule, ClassInheritanceRule, ClassNamesRule, @@ -42,7 +41,7 @@ export const cdkMigrationRules: Constructor>[] = PropertyNamesRule, ]; -type NullableMigrationRule = Constructor>; +type NullableMigrationRule = MigrationRuleType; /** * Creates a Angular schematic rule that runs the upgrade for the @@ -51,7 +50,7 @@ type NullableMigrationRule = Constructor>; export function createUpgradeRule( targetVersion: TargetVersion, extraRules: NullableMigrationRule[], upgradeData: RuleUpgradeData, onMigrationCompleteFn?: (targetVersion: TargetVersion, hasFailures: boolean) => void): Rule { - return (tree: Tree, context: SchematicContext) => { + return async (tree: Tree, context: SchematicContext) => { const logger = context.logger; const {buildPaths, testPaths} = getProjectTsConfigPaths(tree); @@ -66,13 +65,26 @@ export function createUpgradeRule( // necessary because multiple TypeScript projects can contain the same source file and // we don't want to check these again, as this would result in duplicated failure messages. const analyzedFiles = new Set(); + const rules = [...cdkMigrationRules, ...extraRules]; let hasRuleFailures = false; - for (const tsconfigPath of [...buildPaths, ...testPaths]) { - hasRuleFailures = hasRuleFailures || runMigrationRules( - tree, context.logger, tsconfigPath, targetVersion, [...cdkMigrationRules, ...extraRules], - upgradeData, analyzedFiles); - } + const runMigration = (tsconfigPath: string, isTestTarget: boolean) => { + const result = runMigrationRules( + tree, context.logger, tsconfigPath, isTestTarget, targetVersion, + rules, upgradeData, analyzedFiles); + + hasRuleFailures = hasRuleFailures || result.hasFailures; + }; + + buildPaths.forEach(p => runMigration(p, false)); + testPaths.forEach(p => runMigration(p, true)); + + // Run the global post migration static members for all migration rules. + rules.forEach(r => r.globalPostMigration(tree, context)); + + // Execute all asynchronous tasks and await them here. We want to run + // the "onMigrationCompleteFn" after all work is done. + await context.engine.executePostTasks().toPromise(); if (onMigrationCompleteFn) { onMigrationCompleteFn(targetVersion, hasRuleFailures); diff --git a/src/cdk/schematics/testing/test-case-setup.ts b/src/cdk/schematics/testing/test-case-setup.ts index e4940e08c7c1..6ff1d0c640e9 100644 --- a/src/cdk/schematics/testing/test-case-setup.ts +++ b/src/cdk/schematics/testing/test-case-setup.ts @@ -14,6 +14,7 @@ import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/test import {readFileSync, removeSync} from 'fs-extra'; import {sync as globSync} from 'glob'; import {basename, extname, join, relative, sep} from 'path'; +import {EMPTY} from 'rxjs'; import {createTestApp} from '../testing'; /** Suffix that indicates whether a given file is a test case input. */ @@ -53,7 +54,15 @@ export async function createFileSystemTestApp(runner: SchematicTestRunner) { }; function writeFile(filePath: string, content: string) { + // Update the temp file system host to reflect the changes in the real file system. + // This is still necessary since we depend on the real file system for parsing the + // TypeScript project. tempFileSystemHost.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(content)); + if (hostTree.exists(filePath)) { + hostTree.overwrite(filePath, content); + } else { + hostTree.create(filePath, content); + } } } @@ -95,6 +104,12 @@ export async function createTestCaseSetup(migrationName: string, collectionPath: // from within the project. process.chdir(tempPath); + // Patch "executePostTasks" to do nothing. This is necessary since + // we cannot run the node install task in unit tests. Rather we just + // assert that certain async post tasks are scheduled. + // TODO(devversion): RxJS version conflicts between angular-devkit and our dev deps. + runner.engine.executePostTasks = () => EMPTY as any; + await runner.runSchematicAsync(migrationName, {}, appTree).toPromise(); // Switch back to the initial working directory. @@ -103,7 +118,7 @@ export async function createTestCaseSetup(migrationName: string, collectionPath: return {logOutput}; }; - return {appTree, writeFile, tempPath, removeTempDir, runFixers}; + return {runner, appTree, writeFile, tempPath, removeTempDir, runFixers}; } /** diff --git a/src/cdk/schematics/update-tool/index.ts b/src/cdk/schematics/update-tool/index.ts index 4c693eb87dbf..79783ce929c0 100644 --- a/src/cdk/schematics/update-tool/index.ts +++ b/src/cdk/schematics/update-tool/index.ts @@ -17,12 +17,15 @@ import {MigrationFailure, MigrationRule} from './migration-rule'; import {TargetVersion} from './target-version'; import {parseTsconfigFile} from './utils/parse-tsconfig'; -export type Constructor = new (...args: any[]) => T; +export type Constructor = (new (...args: any[]) => T); +export type MigrationRuleType = Constructor> + & {[m in keyof typeof MigrationRule]: (typeof MigrationRule)[m]}; + export function runMigrationRules( - tree: Tree, logger: logging.LoggerApi, tsconfigPath: string, targetVersion: TargetVersion, - ruleTypes: Constructor>[], upgradeData: T, - analyzedFiles: Set): boolean { + tree: Tree, logger: logging.LoggerApi, tsconfigPath: string, isTestTarget: boolean, + targetVersion: TargetVersion, ruleTypes: MigrationRuleType[], upgradeData: T, + analyzedFiles: Set): {hasFailures: boolean} { // The CLI uses the working directory as the base directory for the // virtual file system tree. const basePath = process.cwd(); @@ -44,8 +47,9 @@ export function runMigrationRules( // Create instances of all specified migration rules. for (const ruleCtor of ruleTypes) { - const rule = new ruleCtor(program, typeChecker, targetVersion, upgradeData); - rule.getUpdateRecorder = getUpdateRecorder; + const rule = new ruleCtor( + program, typeChecker, targetVersion, upgradeData, tree, getUpdateRecorder, basePath, logger, + isTestTarget, tsconfigPath); rule.init(); if (rule.ruleEnabled) { rules.push(rule); @@ -102,6 +106,9 @@ export function runMigrationRules( } }); + // Call the "postAnalysis" method for each migration rule. + rules.forEach(r => r.postAnalysis()); + // Commit all recorded updates in the update recorder. We need to perform the // replacements per source file in order to ensure that offsets in the TypeScript // program are not incorrectly shifted. @@ -120,7 +127,9 @@ export function runMigrationRules( }); } - return !!ruleFailures.length; + return { + hasFailures: !!ruleFailures.length, + }; function getUpdateRecorder(filePath: string): UpdateRecorder { const treeFilePath = getProjectRelativePath(filePath); diff --git a/src/cdk/schematics/update-tool/migration-rule.ts b/src/cdk/schematics/update-tool/migration-rule.ts index 099be93a682f..301b70ba0355 100644 --- a/src/cdk/schematics/update-tool/migration-rule.ts +++ b/src/cdk/schematics/update-tool/migration-rule.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {UpdateRecorder} from '@angular-devkit/schematics'; +import {logging} from '@angular-devkit/core'; +import {SchematicContext, Tree, UpdateRecorder} from '@angular-devkit/schematics'; import * as ts from 'typescript'; import {ResolvedResource} from './component-resource-collector'; import {TargetVersion} from './target-version'; @@ -26,12 +27,36 @@ export class MigrationRule { ruleEnabled = true; constructor( - public program: ts.Program, public typeChecker: ts.TypeChecker, - public targetVersion: TargetVersion, public upgradeData: T) {} + /** TypeScript program for the migration. */ + public program: ts.Program, + /** TypeChecker instance for the analysis program. */ + public typeChecker: ts.TypeChecker, + /** Version for which the migration rule should run. */ + public targetVersion: TargetVersion, + /** Upgrade data passed to the migration. */ + public upgradeData: T, + /** Devkit tree for the current migration. Can be used to insert/remove files. */ + public tree: Tree, + /** Gets the update recorder for a given source file or resolved template. */ + public getUpdateRecorder: (filePath: string) => UpdateRecorder, + /** Base directory of the virtual file system tree. */ + public basePath: string, + /** Logger that can be used to print messages as part of the migration. */ + public logger: logging.LoggerApi, + /** Whether the migration runs for a test target. */ + public isTestTarget: boolean, + /** Path to the tsconfig that is migrated. */ + public tsconfigPath: string) {} /** Method can be used to perform global analysis of the program. */ init(): void {} + /** + * Method that will be called once all nodes, templates and stylesheets + * have been visited. + */ + postAnalysis(): void {} + /** * Method that will be called for each node in a given source file. Unlike tslint, this * function will only retrieve TypeScript nodes that need to be casted manually. This @@ -46,11 +71,6 @@ export class MigrationRule { /** Method that will be called for each stylesheet in the program. */ visitStylesheet(stylesheet: ResolvedResource): void {} - /** Gets the update recorder for a given source file or resolved template. */ - getUpdateRecorder(filePath: string): UpdateRecorder { - throw new Error('MigrationRule#getUpdateRecorder is not implemented.'); - } - /** Creates a failure with a specified message at the given node location. */ createFailureAtNode(node: ts.Node, message: string) { const sourceFile = node.getSourceFile(); @@ -60,4 +80,17 @@ export class MigrationRule { message: message, }); } + + /** Prints the specified message with "info" loglevel. */ + printInfo(text: string) { + this.logger.info(`- ${this.tsconfigPath}: ${text}`); + } + + /** + * Static method that will be called once the migration of all project targets + * has been performed. This method can be used to make changes respecting the + * migration result of all individual targets. e.g. removing HammerJS if it + * is not needed in any project target. + */ + static globalPostMigration(tree: Tree, context: SchematicContext) {} } diff --git a/src/cdk/schematics/update-tool/target-version.ts b/src/cdk/schematics/update-tool/target-version.ts index 0ef26504aaec..3566bee4cbaf 100644 --- a/src/cdk/schematics/update-tool/target-version.ts +++ b/src/cdk/schematics/update-tool/target-version.ts @@ -12,6 +12,7 @@ export enum TargetVersion { V7 = 'version 7', V8 = 'version 8', V9 = 'version 9', + V10 = 'version 10', } /** diff --git a/src/cdk/schematics/utils/index.ts b/src/cdk/schematics/utils/index.ts index 8534f8214862..df5a6140aa98 100644 --- a/src/cdk/schematics/utils/index.ts +++ b/src/cdk/schematics/utils/index.ts @@ -12,6 +12,7 @@ export * from './build-component'; export * from './get-project'; export * from './html-head-element'; export * from './parse5-element'; +export * from './project-index-file'; export * from './project-main-file'; export * from './project-style-file'; export * from './project-targets'; diff --git a/src/cdk/schematics/utils/project-index-file.ts b/src/cdk/schematics/utils/project-index-file.ts new file mode 100644 index 000000000000..e15a9aaeb78b --- /dev/null +++ b/src/cdk/schematics/utils/project-index-file.ts @@ -0,0 +1,21 @@ +/** + * @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 {WorkspaceProject} from '@angular-devkit/core/src/experimental/workspace'; +import {BrowserBuilderTarget} from '@schematics/angular/utility/workspace-models'; +import {defaultTargetBuilders, getTargetsByBuilderName} from './project-targets'; + +/** Gets the path of the index file in the given project. */ +export function getProjectIndexFiles(project: WorkspaceProject): string[] { + // Use a set to remove duplicate index files referenced in multiple build targets + // of a project. + return [...new Set( + (getTargetsByBuilderName(project, defaultTargetBuilders.build) as BrowserBuilderTarget[]) + .filter(t => t.options.index) + .map(t => t.options.index!))]; +} diff --git a/src/cdk/schematics/utils/project-targets.ts b/src/cdk/schematics/utils/project-targets.ts index 099e3b38fa75..dbed3d4951ab 100644 --- a/src/cdk/schematics/utils/project-targets.ts +++ b/src/cdk/schematics/utils/project-targets.ts @@ -8,26 +8,37 @@ import {WorkspaceProject} from '@angular-devkit/core/src/experimental/workspace'; import {SchematicsException} from '@angular-devkit/schematics'; +import {BuilderTarget} from '@schematics/angular/utility/workspace-models'; + +/** Object that maps a CLI target to its default builder name. */ +export const defaultTargetBuilders = { + build: '@angular-devkit/build-angular:browser', + test: '@angular-devkit/build-angular:karma', +}; /** Resolves the architect options for the build target of the given project. */ export function getProjectTargetOptions(project: WorkspaceProject, buildTarget: string) { - if (project.targets && - project.targets[buildTarget] && - project.targets[buildTarget].options) { - + if (project.targets && project.targets[buildTarget] && project.targets[buildTarget].options) { return project.targets[buildTarget].options; } // TODO(devversion): consider removing this architect check if the CLI completely switched // over to `targets`, and the `architect` support has been removed. // See: https://github.com/angular/angular-cli/commit/307160806cb48c95ecb8982854f452303801ac9f - if (project.architect && - project.architect[buildTarget] && + if (project.architect && project.architect[buildTarget] && project.architect[buildTarget].options) { - return project.architect[buildTarget].options; } throw new SchematicsException( - `Cannot determine project target configuration for: ${buildTarget}.`); + `Cannot determine project target configuration for: ${buildTarget}.`); +} + +/** Gets all targets from the given project that match the specified builder name. */ +export function getTargetsByBuilderName( + project: WorkspaceProject, builderName: string): BuilderTarget[] { + const targets = project.targets || project.architect || {}; + return Object.keys(targets) + .filter(name => targets[name].builder === builderName) + .map(name => targets[name]); } diff --git a/src/material/schematics/BUILD.bazel b/src/material/schematics/BUILD.bazel index 61e4f17cc38c..91f07f71eeb4 100644 --- a/src/material/schematics/BUILD.bazel +++ b/src/material/schematics/BUILD.bazel @@ -6,7 +6,7 @@ load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") filegroup( name = "schematics_assets", - srcs = glob([ + srcs = ["ng-update/upgrade-rules/hammer-gestures-v9/gesture-config.template"] + glob([ "ng-generate/*/files/**/*", "**/*.json", ]), @@ -70,6 +70,7 @@ ts_library( tsconfig = ":tsconfig.json", deps = [ ":schematics", + "//src/cdk/private/testing", "//src/cdk/schematics", "//src/cdk/schematics/testing", "@npm//@angular-devkit/core", diff --git a/src/material/schematics/ng-add/fonts/material-fonts.ts b/src/material/schematics/ng-add/fonts/material-fonts.ts index df15fb24d41b..58df95f9a4a0 100644 --- a/src/material/schematics/ng-add/fonts/material-fonts.ts +++ b/src/material/schematics/ng-add/fonts/material-fonts.ts @@ -6,18 +6,25 @@ * found in the LICENSE file at https://angular.io/license */ -import {Tree} from '@angular-devkit/schematics'; -import {appendHtmlElementToHead, getProjectFromWorkspace} from '@angular/cdk/schematics'; +import {SchematicsException, Tree} from '@angular-devkit/schematics'; +import { + appendHtmlElementToHead, + getProjectFromWorkspace, + getProjectIndexFiles, +} from '@angular/cdk/schematics'; import {getWorkspace} from '@schematics/angular/utility/config'; import {Schema} from '../schema'; -import {getIndexHtmlPath} from './project-index-html'; /** Adds the Material Design fonts to the index HTML file. */ export function addFontsToIndex(options: Schema): (host: Tree) => Tree { return (host: Tree) => { const workspace = getWorkspace(host); const project = getProjectFromWorkspace(workspace, options.project); - const projectIndexHtmlPath = getIndexHtmlPath(project); + const projectIndexFiles = getProjectIndexFiles(project); + + if (!projectIndexFiles.length) { + throw new SchematicsException('No project index HTML file could be found.'); + } const fonts = [ 'https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap', @@ -25,7 +32,9 @@ export function addFontsToIndex(options: Schema): (host: Tree) => Tree { ]; fonts.forEach(f => { - appendHtmlElementToHead(host, projectIndexHtmlPath, ``); + projectIndexFiles.forEach(indexFilePath => { + appendHtmlElementToHead(host, indexFilePath, ``); + }); }); return host; diff --git a/src/material/schematics/ng-add/fonts/project-index-html.ts b/src/material/schematics/ng-add/fonts/project-index-html.ts deleted file mode 100644 index 102ae89b3ed1..000000000000 --- a/src/material/schematics/ng-add/fonts/project-index-html.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @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 {WorkspaceProject} from '@angular-devkit/core/src/experimental/workspace'; -import {SchematicsException} from '@angular-devkit/schematics'; -import {getProjectTargetOptions} from '@angular/cdk/schematics'; - - -/** Looks for the index HTML file in the given project and returns its path. */ -export function getIndexHtmlPath(project: WorkspaceProject): string { - const buildOptions = getProjectTargetOptions(project, 'build'); - - if (!buildOptions.index) { - throw new SchematicsException('No project "index.html" file could be found.'); - } - - return buildOptions.index; -} diff --git a/src/material/schematics/ng-add/index.spec.ts b/src/material/schematics/ng-add/index.spec.ts index 042908c2faef..e963311751ee 100644 --- a/src/material/schematics/ng-add/index.spec.ts +++ b/src/material/schematics/ng-add/index.spec.ts @@ -4,13 +4,12 @@ import {Tree} from '@angular-devkit/schematics'; import {SchematicTestRunner} from '@angular-devkit/schematics/testing'; import { addModuleImportToRootModule, - getProjectFromWorkspace, + getProjectFromWorkspace, getProjectIndexFiles, getProjectStyleFile, getProjectTargetOptions, } from '@angular/cdk/schematics'; import {createTestApp, getFileContent} from '@angular/cdk/schematics/testing'; import {getWorkspace} from '@schematics/angular/utility/config'; -import {getIndexHtmlPath} from './fonts/project-index-html'; describe('ng-add schematic', () => { let runner: SchematicTestRunner; @@ -112,18 +111,22 @@ describe('ng-add schematic', () => { const workspace = getWorkspace(tree); const project = getProjectFromWorkspace(workspace); - const indexPath = getIndexHtmlPath(project); - const buffer = tree.read(indexPath)!; - const htmlContent = buffer.toString(); + const indexFiles = getProjectIndexFiles(project); + expect(indexFiles.length).toBe(1); - // Ensure that the indentation has been determined properly. We want to make sure that - // the created links properly align with the existing HTML. Default CLI projects use an - // indentation of two columns. - expect(htmlContent) + indexFiles.forEach(indexPath => { + const buffer = tree.read(indexPath)!; + const htmlContent = buffer.toString(); + + // Ensure that the indentation has been determined properly. We want to make sure that + // the created links properly align with the existing HTML. Default CLI projects use an + // indentation of two columns. + expect(htmlContent) .toContain(' { diff --git a/src/material/schematics/ng-add/theming/theming.ts b/src/material/schematics/ng-add/theming/theming.ts index c6dd223c2ca7..f19257aeeb50 100644 --- a/src/material/schematics/ng-add/theming/theming.ts +++ b/src/material/schematics/ng-add/theming/theming.ts @@ -10,6 +10,7 @@ import {normalize} from '@angular-devkit/core'; import {WorkspaceProject, WorkspaceSchema} from '@angular-devkit/core/src/experimental/workspace'; import {SchematicsException, Tree} from '@angular-devkit/schematics'; import { + defaultTargetBuilders, getProjectFromWorkspace, getProjectStyleFile, getProjectTargetOptions, @@ -27,12 +28,6 @@ const prebuiltThemePathSegment = '@angular/material/prebuilt-themes'; /** Default file name of the custom theme that can be generated. */ const defaultCustomThemeFilename = 'custom-theme.scss'; -/** Object that maps a CLI target to its default builder name. */ -const defaultTargetBuilders = { - build: '@angular-devkit/build-angular:browser', - test: '@angular-devkit/build-angular:karma', -}; - /** Add pre-built styles to the main project style file. */ export function addThemeToAppStyles(options: Schema): (host: Tree) => Tree { return function(host: Tree): Tree { diff --git a/src/material/schematics/ng-update/index.ts b/src/material/schematics/ng-update/index.ts index ee300041ed45..cf937a9d5d2c 100644 --- a/src/material/schematics/ng-update/index.ts +++ b/src/material/schematics/ng-update/index.ts @@ -11,6 +11,7 @@ import {createUpgradeRule, TargetVersion} from '@angular/cdk/schematics'; import chalk from 'chalk'; import {materialUpgradeData} from './upgrade-data'; +import {HammerGesturesRule} from './upgrade-rules/hammer-gestures-v9/hammer-gestures-rule'; import {MiscClassInheritanceRule} from './upgrade-rules/misc-checks/misc-class-inheritance-rule'; import {MiscClassNamesRule} from './upgrade-rules/misc-checks/misc-class-names-rule'; import {MiscImportsRule} from './upgrade-rules/misc-checks/misc-imports-rule'; @@ -29,6 +30,7 @@ const materialMigrationRules = [ MiscTemplateRule, RippleSpeedFactorRule, SecondaryEntryPointsRule, + HammerGesturesRule, ]; /** Entry point for the migration schematics with target of Angular Material v6 */ diff --git a/src/material/schematics/ng-update/test-cases/v9/hammer-migration-v9.spec.ts b/src/material/schematics/ng-update/test-cases/v9/hammer-migration-v9.spec.ts new file mode 100644 index 000000000000..15411105356c --- /dev/null +++ b/src/material/schematics/ng-update/test-cases/v9/hammer-migration-v9.spec.ts @@ -0,0 +1,691 @@ +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import {dedent} from '@angular/cdk/private/testing'; +import {addPackageToPackageJson} from '@angular/cdk/schematics/ng-add/package-config'; +import {createTestCaseSetup} from '@angular/cdk/schematics/testing'; +import {readFileSync} from 'fs'; + +import {migrationCollection} from '../index.spec'; + +describe('v9 HammerJS removal', () => { + const GESTURE_CONFIG_TEMPLATE_PATH = + require.resolve('../../upgrade-rules/hammer-gestures-v9/gesture-config.template'); + + let runner: SchematicTestRunner; + let tree: UnitTestTree; + let writeFile: (filePath: string, text: string) => void; + let runMigration: () => Promise<{logOutput: string}>; + let cleanupTest: () => void; + + beforeEach(async () => { + const testSetup = await createTestCaseSetup('migration-v9', migrationCollection, []); + + runner = testSetup.runner; + tree = testSetup.appTree; + runMigration = testSetup.runFixers; + writeFile = testSetup.writeFile; + cleanupTest = testSetup.removeTempDir; + }); + + afterEach(() => cleanupTest()); + + function appendContent(filePath: string, text: string) { + writeFile(filePath, text + tree.readContent(filePath)) + } + + function writeHammerTypes() { + writeFile('/node_modules/@types/hammerjs/index.d.ts', ` + declare var Hammer: any; + `); + } + + describe('hammerjs not used', () => { + it('should remove hammerjs from "package.json" file', async () => { + addPackageToPackageJson(tree, 'hammerjs', '0.0.0'); + + expect(JSON.parse(tree.readContent('/package.json')).dependencies['hammerjs']).toBe('0.0.0'); + + await runMigration(); + + expect(JSON.parse(tree.readContent('/package.json')).dependencies['hammerjs']) + .toBe(undefined); + + // expect that there is a "node-package" install task. The task is + // needed to update the lock file. + expect(runner.tasks.some(t => t.name === 'node-package')).toBe(true); + }); + + it('should remove import to load hammerjs', async () => { + appendContent('/projects/cdk-testing/src/main.ts', ` + import 'hammerjs'; + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).not.toContain('hammerjs'); + }); + + it('should remove empty named import to load hammerjs', async () => { + appendContent('/projects/cdk-testing/src/main.ts', ` + import {} 'hammerjs'; + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).not.toContain('hammerjs'); + }); + + it('should remove references to gesture config', async () => { + writeFile('/projects/cdk-testing/src/test.module.ts', dedent` + import {NgModule} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; // some comment + import {GestureConfig} from '@angular/material/core'; + + @NgModule({ + providers: [ + {provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig}, + OtherProvider, + ] + }) + export class TestModule {} + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/test.module.ts')).toContain(dedent` + import {NgModule} from '@angular/core'; + + @NgModule({ + providers: [ + OtherProvider, + ] + }) + export class TestModule {}`); + }); + + it('should remove references to gesture config if imports are aliased', async () => { + writeFile('/projects/cdk-testing/src/test.module.ts', dedent` + import {NgModule} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG as configToken} from '@angular/platform-browser'; // some comment + import {GestureConfig as gestureConfig} from '@angular/material/core'; + + @NgModule({ + providers: [ + {provide: configToken, useClass: gestureConfig}, + OtherProvider, + ] + }) + export class TestModule {} + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/test.module.ts')).toContain(dedent` + import {NgModule} from '@angular/core'; + + @NgModule({ + providers: [ + OtherProvider, + ] + }) + export class TestModule {}`); + }); + + it('should report error if unable to remove reference to gesture config', async () => { + writeFile('/projects/cdk-testing/src/test.module.ts', dedent` + import {NgModule} from '@angular/core'; + import {NOT_KNOWN_TOKEN, HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {GestureConfig} from '@angular/material/core'; + + const myProvider = {provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig} + + @NgModule({ + providers: [ + {provide: NOT_KNOWN_TOKEN, useClass: GestureConfig}, + {provide: HAMMER_GESTURE_CONFIG, useFactory: () => GestureConfig}, + OtherProvider, + ] + }) + export class TestModule { + constructor() { + doSomethingWith(GestureConfig); + } + } + `); + + const {logOutput} = await runMigration(); + + expect(logOutput).toContain( + `projects/cdk-testing/src/test.module.ts@5:20 - ` + + `Unable to delete provider definition for "GestureConfig" completely. ` + + `Please clean up the provider.`); + expect(logOutput).toContain( + `projects/cdk-testing/src/test.module.ts@9:42 - ` + + `Cannot remove reference to "GestureConfig". Please remove manually.`); + expect(logOutput).toContain( + `projects/cdk-testing/src/test.module.ts@10:56 - ` + + `Cannot remove reference to "GestureConfig". Please remove manually.`); + expect(logOutput).toContain( + `projects/cdk-testing/src/test.module.ts@16:21 - ` + + `Cannot remove reference to "GestureConfig". Please remove manually.`); + expect(tree.readContent('/projects/cdk-testing/src/test.module.ts')).toContain(dedent` + import {NgModule} from '@angular/core'; + import {NOT_KNOWN_TOKEN, HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + + const myProvider = /* TODO: remove */ {} + + @NgModule({ + providers: [ + {provide: NOT_KNOWN_TOKEN, useClass: GestureConfig}, + {provide: HAMMER_GESTURE_CONFIG, useFactory: () => GestureConfig}, + OtherProvider, + ] + }) + export class TestModule { + constructor() { + doSomethingWith(GestureConfig); + } + }`); + }); + + it('should preserve import for hammer gesture token if used elsewhere', async () => { + writeFile('/projects/cdk-testing/src/test.module.ts', dedent` + import {NgModule, Inject} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {GestureConfig} from '@angular/material/core'; + + @NgModule({ + providers: [ + {provide: ProviderAbove}, + {provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig}, + OtherProvider, + ] + }) + export class TestModule { + constructor(@Inject(HAMMER_GESTURE_CONFIG) config?: any) { + console.log(config); + } + } + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/test.module.ts')).toContain(dedent` + import {NgModule, Inject} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + + @NgModule({ + providers: [ + {provide: ProviderAbove}, + OtherProvider, + ] + }) + export class TestModule {`); + }); + + it('should remove import scripts in project index files if found', async () => { + writeFile('/projects/cdk-testing/src/index.html', dedent` + + + + Hello + + + + + + + + + + + + + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/index.html')).toContain(dedent` + + + + Hello + + + + + + + `); + }); + }); + + describe('hammerjs used', () => { + beforeEach(() => { + appendContent('/projects/cdk-testing/src/main.ts', ` + import 'hammerjs'; + `); + }); + + it('should detect global reference to Hammer through types', async () => { + writeHammerTypes(); + writeFile('/projects/cdk-testing/src/app/hammer.ts', ` + export function createHammerInstance(el: HTMLElement) { + // this works since there are types for HammerJS installed. + return new Hammer(el); + } + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + }); + + it('should ignore global reference to Hammer if not resolved to known types', async () => { + writeHammerTypes(); + writeFile('/projects/cdk-testing/src/app/hammer.ts', ` + import {Hammer} from 'workbench'; + + export function createWorkbenchHammer() { + return new Hammer(); + } + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')) + .not.toContain(`import 'hammerjs';`); + }); + + it('should not create gesture config if hammer is only used programmatically', async () => { + writeFile('/projects/cdk-testing/src/app/hammer.ts', ` + export function createHammerInstance(el: HTMLElement) { + return new (window as any).Hammer(el); + } + `); + + await runMigration(); + + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(false); + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + }); + + it('should remove gesture config setup if hammer is only used programmatically', async () => { + writeFile('/projects/cdk-testing/src/app/hammer.ts', ` + export function createHammerInstance(el: HTMLElement) { + return new (window as any).Hammer(el); + } + `); + + writeFile('/projects/cdk-testing/src/test.module.ts', dedent` + import {NgModule} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {GestureConfig} from '@angular/material/core'; + + @NgModule({ + providers: [ + {provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig}, + OtherProvider, + ] + }) + export class TestModule {} + `); + + await runMigration(); + + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(false); + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.readContent('/projects/cdk-testing/src/test.module.ts')).toContain(dedent` + import {NgModule} from '@angular/core'; + + @NgModule({ + providers: [ + OtherProvider, + ] + }) + export class TestModule {}`); + }); + + it('should create gesture config file if used in template', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/gesture-config.ts')) + .toBe(readFileSync(GESTURE_CONFIG_TEMPLATE_PATH, 'utf8')); + }); + + it('should create gesture config file if used in inline template', async () => { + writeFile('/projects/cdk-testing/src/app/test.component.ts', ` + import {Component} from '@angular/core'; + + @Component({ + template: \`\` + }) + export class TestComponent {} + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/gesture-config.ts')) + .toBe(readFileSync(GESTURE_CONFIG_TEMPLATE_PATH, 'utf8')); + }); + + it('should print a notice message if hammer is only used in template', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + + `); + + const {logOutput} = await runMigration(); + + expect(logOutput).toContain( + 'The HammerJS v9 migration for Angular components migrated the project to ' + + 'keep HammerJS installed, but detected ambiguous usage of HammerJS. Please manually ' + + 'check if you can remove HammerJS from your application.'); + }); + + it('should create gesture config file if used in template and programmatically', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + `); + + writeFile('/projects/cdk-testing/src/app/hammer.ts', ` + export function createHammerInstance(el: HTMLElement) { + return new (window as any).Hammer(el); + } + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/gesture-config.ts')) + .toBe(readFileSync(GESTURE_CONFIG_TEMPLATE_PATH, 'utf8')); + }); + + it('should create gesture config file with different name if it would conflict', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + `); + + // unlikely case that someone has a file named "gesture-config" in the + // project sources root. Though we want to perform the migration + // successfully so we just generate a unique file name. + writeFile('/projects/cdk-testing/src/gesture-config.ts', ''); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config-1.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/gesture-config-1.ts')) + .toBe(readFileSync(GESTURE_CONFIG_TEMPLATE_PATH, 'utf8')); + }); + + it('should rewrite references to gesture config', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + `); + + writeFile('/projects/cdk-testing/src/nested/test.module.ts', dedent` + import {NgModule} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {GestureConfig} from '@angular/material/core'; // some-comment + + @NgModule({ + providers: [ + {provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig}, + OtherProvider, + ] + }) + export class TestModule {} + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/nested/test.module.ts')).toContain(dedent` + import {NgModule} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import { GestureConfig } from "../gesture-config"; // some-comment + + @NgModule({ + providers: [ + {provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig}, + OtherProvider, + ] + }) + export class TestModule {}`); + }); + + it('should rewrite references to gesture config without causing conflicts', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + `); + + writeFile('/projects/cdk-testing/src/test.module.ts', dedent` + import {NgModule} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {GestureConfig} from 'ngx-hammer-events'; + import * as core from '@angular/material/core'; + + @NgModule({ + providers: [ + {provide: HAMMER_GESTURE_CONFIG, useClass: core.GestureConfig}, + ] + }) + export class TestModule {} + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/test.module.ts')).toContain(dedent` + import {NgModule} from '@angular/core'; + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {GestureConfig} from 'ngx-hammer-events'; + import * as core from '@angular/material/core'; + import { GestureConfig as GestureConfig_1 } from "./gesture-config"; + + @NgModule({ + providers: [ + {provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig_1}, + ] + }) + export class TestModule {}`); + }); + + it('should add gesture config provider to app module', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/app/app.module.ts')).toContain(dedent`\ + import { BrowserModule, HAMMER_GESTURE_CONFIG } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppComponent } from './app.component'; + import { GestureConfig } from "../gesture-config"; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [{ provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig }], + bootstrap: [AppComponent] + }) + export class AppModule { }`); + }); + + it('should not add gesture config provider multiple times if already provided', async () => { + writeFile('/projects/cdk-testing/src/app/app.component.html', ` + + `); + + writeFile('/projects/cdk-testing/src/app/app.module.ts', dedent` + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {NgModule} from '@angular/core'; + import {GestureConfig} from '@angular/material/core'; + + @NgModule({ + providers: [ + { + provide: HAMMER_GESTURE_CONFIG, + useClass: GestureConfig + }, + ], + }) + export class AppModule {} + `); + + await runMigration(); + + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain(`import 'hammerjs';`); + expect(tree.exists('/projects/cdk-testing/src/gesture-config.ts')).toBe(true); + expect(tree.readContent('/projects/cdk-testing/src/app/app.module.ts')).toContain(dedent` + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {NgModule} from '@angular/core'; + import { GestureConfig } from "../gesture-config"; + + @NgModule({ + providers: [ + { + provide: HAMMER_GESTURE_CONFIG, + useClass: GestureConfig + }, + ], + }) + export class AppModule {}`); + }); + }); + + it('should not remove hammerjs if test target compilation scope does not contain hammerjs usage', + async () => { + addPackageToPackageJson(tree, 'hammerjs', '0.0.0'); + expect(JSON.parse(tree.readContent('/package.json')).dependencies['hammerjs']).toBe('0.0.0'); + + // we simulate a case where a component does not have any tests for. In that case, + // the test target compilation scope does not include "test.component.ts" and the + // migration would detect **no** usage of HammerJS, hence removing it. This is + // something we avoid by just ignoring test target compilation scopes. + writeFile('/projects/cdk-testing/src/app/test.component.ts', ` + import {Component} from '@angular/core'; + + @Component({ + template: \`\` + }) + export class TestComponent {} + `); + + await runMigration(); + + expect(JSON.parse(tree.readContent('/package.json')).dependencies['hammerjs']).toBe('0.0.0'); + }); + + it('should not remove hammerjs from "package.json" file if used in one project while ' + + 'unused in other project', async () => { + addPackageToPackageJson(tree, 'hammerjs', '0.0.0'); + + expect(JSON.parse(tree.readContent('/package.json')).dependencies['hammerjs']).toBe('0.0.0'); + + await runner.runExternalSchematicAsync('@schematics/angular', 'application', + {name: 'second-project'}, tree).toPromise(); + + // Ensure the "second-project" will be detected with using HammerJS. + writeFile('/projects/second-project/src/main.ts', ` + new (window as any).Hammer(document.body); + `); + + await runMigration(); + + expect(runner.tasks.some(t => t.name === 'node-package')).toBe(false); + expect(JSON.parse(tree.readContent('/package.json')).dependencies['hammerjs']) + .toBe('0.0.0'); + }); + + it('should not remove hammerjs if no usage could be detected but custom gesture config is set up', + async () => { + appendContent('/projects/cdk-testing/src/main.ts', ` + import 'hammerjs'; + `); + + writeFile('/projects/cdk-testing/src/test.component.ts', dedent` + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {NgModule} from '@angular/core'; + import {CustomGestureConfig} from "../gesture-config"; + + @NgModule({ + providers: [ + { + provide: HAMMER_GESTURE_CONFIG, + useClass: CustomGestureConfig + }, + ], + }) + export class TestModule {} + `); + + writeFile('/projects/cdk-testing/src/sub.component.ts', dedent` + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {NgModule} from '@angular/core'; + import {GestureConfig} from '@angular/material/core'; + + @NgModule({ + providers: [ + { + provide: HAMMER_GESTURE_CONFIG, + useClass: GestureConfig + }, + ], + }) + export class SubModule {} + `); + + const {logOutput} = await runMigration(); + + expect(logOutput).toContain( + `Material gesture config is used while a custom gesture config is set up`); + expect(tree.readContent('/projects/cdk-testing/src/main.ts')).toContain('hammerjs'); + expect(tree.readContent('/projects/cdk-testing/src/test.component.ts')).toContain(dedent` + import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; + import {NgModule} from '@angular/core'; + import {CustomGestureConfig} from "../gesture-config"; + + @NgModule({ + providers: [ + { + provide: HAMMER_GESTURE_CONFIG, + useClass: CustomGestureConfig + }, + ], + }) + export class TestModule {}`); + expect(tree.readContent('/projects/cdk-testing/src/sub.component.ts')).toContain(dedent` + import {NgModule} from '@angular/core'; + + @NgModule({ + providers: [ + ], + }) + export class SubModule {}`); + }); +}); diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/cli-workspace.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/cli-workspace.ts new file mode 100644 index 000000000000..b65d2e4cb9f4 --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/cli-workspace.ts @@ -0,0 +1,59 @@ +/** + * @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 {WorkspaceProject, WorkspaceSchema} from '@schematics/angular/utility/workspace-models'; +import {isAbsolute, relative} from 'path'; +import * as ts from 'typescript'; + +/** Finds all projects which contain the given path. */ +export function getMatchingProjectsByPath( + workspace: WorkspaceSchema, searchPath: string): WorkspaceProject[] { + const projectNames = Object.keys(workspace.projects); + const isProjectMatching = (relativeProjectPath: string): boolean => { + // Build the relative path from the real project path to the + // possible project path based on the specified search path. + const relativePath = relative(relativeProjectPath, searchPath); + // If the relative path does not start with two dots and is not absolute, we + // know that the search path is inside the given project path. + return !relativePath.startsWith('..') && !isAbsolute(relativePath); + }; + + return projectNames.map(name => workspace.projects[name]) + .filter(p => isProjectMatching(p.root)) + .sort((a, b) => b.root.length - a.root.length); +} + +/** + * Gets the matching Angular CLI workspace project from the given program. Project + * is determined by checking root file names of the program against project paths. + * + * If there is only one project set up, the project will be returned regardless of + * whether it matches any of the specified program files. + */ +export function getProjectFromProgram( + workspace: WorkspaceSchema, program: ts.Program): WorkspaceProject|null { + const projectNames = Object.keys(workspace.projects); + + // If there is only one project, we just return it without looking + // for other matching projects. + if (projectNames.length === 1) { + return workspace.projects[projectNames[0]]; + } + + const basePath = program.getCurrentDirectory(); + // Go through the root file names of the program and return the first project + // that matches a given root file. We can't just take any arbitrary file in the + // list since sometimes there can be root files which do not belong to any project. + for (let filePath of program.getRootFileNames()) { + const matchingProjects = getMatchingProjectsByPath(workspace, relative(basePath, filePath)); + if (matchingProjects.length) { + return matchingProjects[0]; + } + } + return null; +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/find-hammer-script-tags.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/find-hammer-script-tags.ts new file mode 100644 index 000000000000..c67db8f6d415 --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/find-hammer-script-tags.ts @@ -0,0 +1,44 @@ +/** + * @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 {parse5} from '@angular/cdk/schematics'; + +/** + * Parses the specified HTML content and looks for "script" elements which + * potentially import HammerJS. These elements will be returned. + */ +export function findHammerScriptImportElements(htmlContent: string): parse5.DefaultTreeElement[] { + const document = + parse5.parse(htmlContent, {sourceCodeLocationInfo: true}) as parse5.DefaultTreeDocument; + const nodeQueue = [...document.childNodes]; + const result: parse5.DefaultTreeElement[] = []; + + while (nodeQueue.length) { + const node = nodeQueue.shift() as parse5.DefaultTreeElement; + + if (node.childNodes) { + nodeQueue.push(...node.childNodes); + } + + if (node.nodeName.toLowerCase() === 'script' && node.attrs.length !== 0) { + const srcAttribute = node.attrs.find(a => a.name === 'src'); + if (srcAttribute && isPotentialHammerScriptReference(srcAttribute.value)) { + result.push(node); + } + } + } + return result; +} + +/** + * Checks whether the specified source path is potentially referring to the + * HammerJS script output. + */ +function isPotentialHammerScriptReference(srcPath: string): boolean { + return /\/hammer(\.min)?\.js($|\?)/.test(srcPath); +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/gesture-config.template b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/gesture-config.template new file mode 100644 index 000000000000..87c68a0eaf0d --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/gesture-config.template @@ -0,0 +1,105 @@ +/** + * Custom HammerJS configuration forked from Angular Material. With Angular v9, + * Angular Material dropped HammerJS as a dependency. This configuration was added + * automatically to this application because `ng update` detected that this application + * directly used HammerJS. + * + * If this application does not depend on the custom gestures originally defined by + * Angular Material, this file can be deleted. + */ + +import {Injectable, Inject, Optional, Type} from '@angular/core'; +import {HammerGestureConfig} from '@angular/platform-browser'; +import {MAT_HAMMER_OPTIONS} from '@angular/material/core'; + +const SUPPORTED_CUSTOM_GESTURES = [ + 'longpress', + 'slide', + 'slidestart', + 'slideend', + 'slideright', + 'slideleft' +]; + +/** + * Fake HammerInstance that is used when a Hammer instance is requested when + * HammerJS has not been loaded on the page. + */ +const noopHammerInstance = { + on: () => {}, + off: () => {}, +}; + +/** + * Adjusts configuration of our gesture library, Hammer. + * @deprecated No longer being used. To be removed. + * @breaking-change 10.0.0 + */ +@Injectable() +export class GestureConfig extends HammerGestureConfig { + /** List of new event names to add to the gesture support list */ + events = SUPPORTED_CUSTOM_GESTURES; + + constructor( + @Optional() @Inject(MAT_HAMMER_OPTIONS) private _hammerOptions?: any) { + super(); + } + + /** + * Builds Hammer instance manually to add custom recognizers that match the + * Material Design spec. + * + * Our gesture names come from the Material Design gestures spec: + * https://material.io/design/#gestures-touch-mechanics + * + * More information on default recognizers can be found in Hammer docs: + * http://hammerjs.github.io/recognizer-pan/ + * http://hammerjs.github.io/recognizer-press/ + * + * @param element Element to which to assign the new HammerJS gestures. + * @returns Newly-created HammerJS instance. + */ + buildHammer(element: HTMLElement): any { + const hammer: any = typeof window !== 'undefined' ? (window as any).Hammer : null; + + if (!hammer) { + return noopHammerInstance; + } + + const mc = new hammer(element, this._hammerOptions || undefined); + + // Default Hammer Recognizers. + const pan = new hammer.Pan(); + const swipe = new hammer.Swipe(); + const press = new hammer.Press(); + + // Notice that a HammerJS recognizer can only depend on one other recognizer once. + // Otherwise the previous `recognizeWith` will be dropped. + const slide = this._createRecognizer(pan, {event: 'slide', threshold: 0}, swipe); + const longpress = this._createRecognizer(press, {event: 'longpress', time: 500}); + + // Overwrite the default `pan` event to use the swipe event. + pan.recognizeWith(swipe); + + // Since the slide event threshold is set to zero, the slide recognizer can fire and + // accidentally reset the longpress recognizer. In order to make sure that the two + // recognizers can run simultaneously but don't affect each other, we allow the slide + // recognizer to recognize while a longpress is being processed. + // See: https://github.com/hammerjs/hammer.js/blob/master/src/manager.js#L123-L124 + longpress.recognizeWith(slide); + + // Add customized gestures to Hammer manager + mc.add([swipe, press, pan, slide, longpress]); + + return mc; + } + + /** Creates a new recognizer, without affecting the default recognizers of HammerJS */ + private _createRecognizer(base: Object, options: any, ...inheritances: Object[]) { + const recognizer = new (base.constructor as Type)(options); + inheritances.push(base); + inheritances.forEach(item => recognizer.recognizeWith(item)); + return recognizer; + } + +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-gestures-rule.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-gestures-rule.ts new file mode 100644 index 000000000000..1301c255ac90 --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-gestures-rule.ts @@ -0,0 +1,759 @@ +/** + * @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 { + join as devkitJoin, + normalize as devkitNormalize, + Path as DevkitPath +} from '@angular-devkit/core'; +import {SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks'; +import { + getProjectIndexFiles, + getProjectMainFile, + MigrationFailure, + MigrationRule, + ResolvedResource, + TargetVersion +} from '@angular/cdk/schematics'; +import { + addSymbolToNgModuleMetadata, + getDecoratorMetadata, + getMetadataField +} from '@schematics/angular/utility/ast-utils'; +import {InsertChange} from '@schematics/angular/utility/change'; +import {getWorkspace} from '@schematics/angular/utility/config'; +import {getAppModulePath} from '@schematics/angular/utility/ng-ast-utils'; +import {WorkspaceProject} from '@schematics/angular/utility/workspace-models'; +import chalk from 'chalk'; +import {readFileSync} from 'fs'; +import {dirname, join, relative} from 'path'; +import * as ts from 'typescript'; + +import {getProjectFromProgram} from './cli-workspace'; +import {findHammerScriptImportElements} from './find-hammer-script-tags'; +import {isHammerJsUsedInTemplate} from './hammer-template-check'; +import {getImportOfIdentifier, Import} from './identifier-imports'; +import {ImportManager} from './import-manager'; +import {removeElementFromArrayExpression} from './remove-array-element'; +import {removeElementFromHtml} from './remove-element-from-html'; + +const GESTURE_CONFIG_CLASS_NAME = 'GestureConfig'; +const GESTURE_CONFIG_FILE_NAME = 'gesture-config'; +const GESTURE_CONFIG_TEMPLATE_PATH = './gesture-config.template'; +const HAMMER_CONFIG_TOKEN_NAME = 'HAMMER_GESTURE_CONFIG'; +const HAMMER_CONFIG_TOKEN_MODULE = '@angular/platform-browser'; +const HAMMER_MODULE_SPECIFIER = 'hammerjs'; + +const CANNOT_REMOVE_REFERENCE_ERROR = + `Cannot remove reference to "GestureConfig". Please remove manually.`; + +interface IdentifierReference { + node: ts.Identifier; + importData: Import; + isImport: boolean; +} + +export class HammerGesturesRule extends MigrationRule { + // Only enable this rule if the migration targets v9 or v10 and is running for a non-test + // target. We cannot migrate test targets since they have a limited scope + // (in regards to source files) and therefore the HammerJS usage detection can be incorrect. + ruleEnabled = + (this.targetVersion === TargetVersion.V9 || this.targetVersion === TargetVersion.V10) && + !this.isTestTarget; + + private _printer = ts.createPrinter(); + private _importManager = new ImportManager(this.getUpdateRecorder, this._printer); + private _nodeFailures: {node: ts.Node, message: string}[] = []; + + /** Whether HammerJS is explicitly used in any component template. */ + private _usedInTemplate = false; + + /** Whether HammerJS is accessed at runtime. */ + private _usedInRuntime = false; + + /** + * List of imports that make "hammerjs" available globally. We keep track of these + * since we might need to remove them if Hammer is not used. + */ + private _installImports: ts.ImportDeclaration[] = []; + + /** + * List of identifiers which resolve to the gesture config from Angular Material. + */ + private _gestureConfigReferences: IdentifierReference[] = []; + + /** + * List of identifiers which resolve to "HAMMER_GESTURE_CONFIG" token from + * "@angular/platform-browser". + */ + private _hammerConfigTokenReferences: IdentifierReference[] = []; + + /** + * List of identifiers that have been deleted from source files. This can be + * used to determine if certain imports are still used or not. + */ + private _deletedIdentifiers: ts.Identifier[] = []; + + visitTemplate(template: ResolvedResource): void { + if (!this._usedInTemplate && isHammerJsUsedInTemplate(template.content)) { + this._usedInTemplate = true; + } + } + + visitNode(node: ts.Node): void { + this._checkHammerImports(node); + this._checkForRuntimeHammerUsage(node); + this._checkForMaterialGestureConfig(node); + this._checkForHammerGestureConfigToken(node); + } + + postAnalysis(): void { + // Walk through all hammer config token references and check if there + // is a potential custom gesture config setup. + const hasCustomGestureConfigSetup = + this._hammerConfigTokenReferences.some(r => this._checkForCustomGestureConfigSetup(r)); + + if (this._usedInRuntime || this._usedInTemplate) { + // We keep track of whether Hammer is used globally. This is necessary because we + // want to only remove Hammer from the "package.json" if it is not used in any project + // target. Just because it isn't used in one target doesn't mean that we can safely + // remove the dependency. + HammerGesturesRule.globalUsesHammer = true; + + // If hammer is only used at runtime, we don't need the gesture config + // and can remove it (along with the hammer config token if possible) + if (!this._usedInTemplate) { + this._removeGestureConfigSetup(); + } else { + this._setupHammerGestureConfig(); + } + } else { + // If HammerJS could not be detected, but we detected a custom gesture + // config setup, we just remove all references to the Angular Material + // gesture config. Otherwise we completely remove HammerJS from the app. + if (hasCustomGestureConfigSetup) { + this._removeGestureConfigSetup(); + // Print a message if we found a custom gesture config setup in combination with + // references to the Angular Material gesture config. This is ambiguous and the + // migration just removes the Material gesture config setup, but we still want + // to create an information message. + if (this._gestureConfigReferences.length) { + this.printInfo(chalk.yellow( + 'The HammerJS v9 migration for Angular components detected that the Angular ' + + 'Material gesture config is used while a custom gesture config is set up. The ' + + 'migration removed all references to the Angular Material gesture config.')); + } + } else { + this._removeHammerSetup(); + } + } + + // Record the changes collected in the import manager. Changes need to be applied + // once the import manager registered all import modifications. This avoids collisions. + this._importManager.recordChanges(); + + // Create migration failures that will be printed by the update-tool on migration + // completion. We need special logic for updating failure positions to reflect + // the new source file after modifications from the import manager. + this.failures.push(...this._createMigrationFailures()); + + // The template check for HammerJS events is not completely reliable as the event + // output could also be from a component having an output named similarly to a known + // hammerjs event (e.g. "@Output() slide"). The usage is therefore somewhat ambiguous + // and we want to print a message that developers might be able to remove Hammer manually. + if (!this._usedInRuntime && this._usedInTemplate) { + this.printInfo(chalk.yellow( + 'The HammerJS v9 migration for Angular components migrated the ' + + 'project to keep HammerJS installed, but detected ambiguous usage of HammerJS. Please ' + + 'manually check if you can remove HammerJS from your application.')); + } + } + + /** + * Sets up the hammer gesture config in the current project. To achieve this, the + * following steps are performed: + * 1) Create copy of Angular Material gesture config. + * 2) Rewrite all references to the Angular Material gesture config to the + * newly copied gesture config. + * 3) Setup the HAMMER_GESTURE_CONFIG provider in the root app module + * (if not done already). + */ + private _setupHammerGestureConfig() { + const project = this._getProjectOrThrow(); + const sourceRoot = devkitNormalize(project.sourceRoot || project.root); + const gestureConfigPath = + devkitJoin(sourceRoot, this._getAvailableGestureConfigFileName(sourceRoot)); + + // Copy gesture config template into the CLI project. + this.tree.create( + gestureConfigPath, readFileSync(require.resolve(GESTURE_CONFIG_TEMPLATE_PATH), 'utf8')); + + // Replace all references to the gesture config of Material. + this._gestureConfigReferences.forEach( + i => this._replaceGestureConfigReference(i, gestureConfigPath)); + + const appModulePath = getAppModulePath(this.tree, getProjectMainFile(project)); + const sourceFile = this.program.getSourceFile(join(this.basePath, appModulePath)); + + if (!sourceFile) { + this.failures.push({ + filePath: appModulePath, + message: `Could not setup HammerJS gesture in module. Please manually ensure that ` + + `the Hammer gesture config is set up.`, + position: {character: 0, line: 0} + }); + return; + } + + // Setup the gesture config provider in the project app module if not done. + this._setupGestureConfigProviderIfNeeded(sourceFile, appModulePath, gestureConfigPath); + } + + /** + * Removes Hammer from the current project. The following steps are performed: + * 1) Delete all TypeScript imports to "hammerjs". + * 2) Remove references to the Angular Material gesture config. + * 3) Remove "hammerjs" from all index HTML files of the current project. + */ + private _removeHammerSetup() { + const project = this._getProjectOrThrow(); + + this._installImports.forEach(i => this._importManager.deleteImportByDeclaration(i)); + + this._removeGestureConfigSetup(); + this._removeHammerFromIndexFile(project); + } + + /** + * Removes the gesture config setup by deleting all found references + * to a gesture config. Additionally, unused imports to the hammer gesture + * config token from platform-browser are removed as well. + */ + private _removeGestureConfigSetup() { + this._gestureConfigReferences.forEach(r => this._removeGestureConfigReference(r)); + + this._hammerConfigTokenReferences.forEach(r => { + if (r.isImport) { + this._removeHammerConfigTokenImportIfUnused(r); + } + }); + } + + /** + * Checks if the given node is a reference to the hammer gesture config + * token from platform-browser. If so, keeps track of the reference. + */ + private _checkForHammerGestureConfigToken(node: ts.Node) { + if (ts.isIdentifier(node)) { + const importData = getImportOfIdentifier(node, this.typeChecker); + if (importData && importData.symbolName === HAMMER_CONFIG_TOKEN_NAME && + importData.moduleName === HAMMER_CONFIG_TOKEN_MODULE) { + this._hammerConfigTokenReferences.push( + {node, importData, isImport: ts.isImportSpecifier(node.parent)}); + } + } + } + + /** + * Checks if the given node is an import to the HammerJS package. Imports to + * HammerJS which load specific symbols from the package are considered as + * runtime usage of Hammer. e.g. `import {Symbol} from "hammerjs";`. + */ + private _checkHammerImports(node: ts.Node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === HAMMER_MODULE_SPECIFIER) { + // If there is an import to HammerJS that imports symbols, or is namespaced + // (e.g. "import {A, B} from ..." or "import * as hammer from ..."), then we + // assume that some exports are used at runtime. + if (node.importClause && + !(node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings) && + node.importClause.namedBindings.elements.length === 0)) { + this._usedInRuntime = true; + } else { + this._installImports.push(node); + } + } + } + + /** + * Checks if the given node accesses the global "Hammer" symbol at runtime. If so, + * the migration rule state will be updated to reflect that Hammer is used at runtime. + */ + private _checkForRuntimeHammerUsage(node: ts.Node) { + if (this._usedInRuntime) { + return; + } + + // Detects usages of "window.Hammer". + if (ts.isPropertyAccessExpression(node) && node.name.text === 'Hammer') { + const originExpr = unwrapExpression(node.expression); + if (ts.isIdentifier(originExpr) && originExpr.text === 'window') { + this._usedInRuntime = true; + } + return; + } + + // Detects usages of "window['Hammer']". + if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) && + node.argumentExpression.text === 'Hammer') { + const originExpr = unwrapExpression(node.expression); + if (ts.isIdentifier(originExpr) && originExpr.text === 'window') { + this._usedInRuntime = true; + } + return; + } + + // Handles usages of plain identifier with the name "Hammer". These usage + // are valid if they resolve to "@types/hammerjs". e.g. "new Hammer(myElement)". + if (ts.isIdentifier(node) && node.text === 'Hammer' && + !ts.isPropertyAccessExpression(node.parent) && !ts.isElementAccessExpression(node.parent)) { + const symbol = this._getDeclarationSymbolOfNode(node); + if (symbol && symbol.valueDeclaration && + symbol.valueDeclaration.getSourceFile().fileName.includes('@types/hammerjs')) { + this._usedInRuntime = true; + } + } + } + + /** + * Checks if the given node references the gesture config from Angular Material. + * If so, we keep track of the found symbol reference. + */ + private _checkForMaterialGestureConfig(node: ts.Node) { + if (ts.isIdentifier(node)) { + const importData = getImportOfIdentifier(node, this.typeChecker); + if (importData && importData.symbolName === GESTURE_CONFIG_CLASS_NAME && + importData.moduleName.startsWith('@angular/material/')) { + this._gestureConfigReferences.push( + {node, importData, isImport: ts.isImportSpecifier(node.parent)}); + } + } + } + + /** + * Checks if the given Hammer gesture config token reference is part of an + * Angular provider definition that sets up a custom gesture config. + */ + private _checkForCustomGestureConfigSetup(tokenRef: IdentifierReference): boolean { + // Walk up the tree to look for a parent property assignment of the + // reference to the hammer gesture config token. + let propertyAssignment: ts.Node = tokenRef.node; + while (propertyAssignment && !ts.isPropertyAssignment(propertyAssignment)) { + propertyAssignment = propertyAssignment.parent; + } + + if (!propertyAssignment || !ts.isPropertyAssignment(propertyAssignment) || + getPropertyNameText(propertyAssignment.name) !== 'provide') { + return false; + } + + const objectLiteralExpr = propertyAssignment.parent; + const matchingIdentifiers = findMatchingChildNodes(objectLiteralExpr, ts.isIdentifier); + + // We naively assume that if there is a reference to the "GestureConfig" export + // from Angular Material in the provider literal, that the provider sets up the + // Angular Material gesture config. + return !this._gestureConfigReferences.some(r => matchingIdentifiers.includes(r.node)); + } + + /** + * Determines an available file name for the gesture config which should + * be stored in the specified file path. + */ + private _getAvailableGestureConfigFileName(sourceRoot: DevkitPath) { + if (!this.tree.exists(devkitJoin(sourceRoot, `${GESTURE_CONFIG_FILE_NAME}.ts`))) { + return `${GESTURE_CONFIG_FILE_NAME}.ts`; + } + + let possibleName = `${GESTURE_CONFIG_FILE_NAME}-`; + let index = 1; + while (this.tree.exists(devkitJoin(sourceRoot, `${possibleName}-${index}.ts`))) { + index++; + } + return `${possibleName + index}.ts`; + } + + /** + * Replaces a given gesture config reference by ensuring that it is imported + * from the new specified path. + */ + private _replaceGestureConfigReference( + {node, importData, isImport}: IdentifierReference, newPath: string) { + const sourceFile = node.getSourceFile(); + const recorder = this.getUpdateRecorder(sourceFile.fileName); + // List of all identifiers referring to the gesture config in the current file. This + // allows us to add a import for the new gesture configuration without generating a + // new unique identifier for the import. i.e. "GestureConfig_1". The import manager + // checks for possible name collisions, but is able to ignore specific identifiers. + const gestureIdentifiersInFile = + this._gestureConfigReferences.filter(d => d.node.getSourceFile() === sourceFile) + .map(d => d.node); + + const newModuleSpecifier = getModuleSpecifier(newPath, sourceFile.fileName); + + // If the parent of the identifier is accessed through a namespace, we can just + // import the new gesture config without rewriting the import declaration because + // the config has been imported through a namespaced import. + if (isNamespacedIdentifierAccess(node)) { + const newExpression = this._importManager.addImportToSourceFile( + sourceFile, GESTURE_CONFIG_CLASS_NAME, newModuleSpecifier, false, + gestureIdentifiersInFile); + + recorder.remove(node.parent.getStart(), node.parent.getWidth()); + recorder.insertRight(node.parent.getStart(), this._printNode(newExpression, sourceFile)); + return; + } + + // Delete the old import to the "GestureConfig". + this._importManager.deleteNamedBindingImport( + sourceFile, GESTURE_CONFIG_CLASS_NAME, importData.moduleName); + + // If the current reference is not from inside of a import, we need to add a new + // import to the copied gesture config and replace the identifier. For references + // within an import, we do nothing but removing the actual import. This allows us + // to remove unused imports to the Material gesture config. + if (!isImport) { + const newExpression = this._importManager.addImportToSourceFile( + sourceFile, GESTURE_CONFIG_CLASS_NAME, newModuleSpecifier, false, + gestureIdentifiersInFile); + + recorder.remove(node.getStart(), node.getWidth()); + recorder.insertRight(node.getStart(), this._printNode(newExpression, sourceFile)); + } + } + + /** + * Removes a given gesture config reference and its corresponding import from + * its containing source file. Imports will be always removed, but in some cases, + * where it's not guaranteed that a removal can be performed safely, we just + * create a migration failure (and add a TODO if possible). + */ + private _removeGestureConfigReference({node, importData, isImport}: IdentifierReference) { + const sourceFile = node.getSourceFile(); + const recorder = this.getUpdateRecorder(sourceFile.fileName); + // Only remove the import for the gesture config if the gesture config has + // been accessed through a non-namespaced identifier access. + if (!isNamespacedIdentifierAccess(node)) { + this._importManager.deleteNamedBindingImport( + sourceFile, GESTURE_CONFIG_CLASS_NAME, importData.moduleName); + } + + // For references from within an import, we do not need to do anything other than + // removing the import. For other references, we remove the import and the reference + // identifier if used inside of a provider definition. + if (isImport) { + return; + } + + const providerAssignment = node.parent; + + // Only remove references to the gesture config which are part of a statically + // analyzable provider definition. We only support the common case of a gesture + // config provider definition where the config is set up through "useClass". + // Otherwise, it's not guaranteed that we can safely remove the provider definition. + if (!ts.isPropertyAssignment(providerAssignment) || + getPropertyNameText(providerAssignment.name) !== 'useClass') { + this._nodeFailures.push({node, message: CANNOT_REMOVE_REFERENCE_ERROR}); + return; + } + + const objectLiteralExpr = providerAssignment.parent; + const provideToken = objectLiteralExpr.properties.find( + (p): p is ts.PropertyAssignment => + ts.isPropertyAssignment(p) && getPropertyNameText(p.name) === 'provide'); + + // Do not remove the reference if the gesture config is not part of a provider definition, + // or if the provided toke is not referring to the known HAMMER_GESTURE_CONFIG token + // from platform-browser. + if (!provideToken || !this._isReferenceToHammerConfigToken(provideToken.initializer)) { + this._nodeFailures.push({node, message: CANNOT_REMOVE_REFERENCE_ERROR}); + return; + } + + // Collect all nested identifiers which will be deleted. This helps us + // determining if we can remove imports for the "HAMMER_GESTURE_CONFIG" token. + this._deletedIdentifiers.push(...findMatchingChildNodes(objectLiteralExpr, ts.isIdentifier)); + + // In case the found provider definition is not part of an array literal, + // we cannot safely remove the provider. This is because it could be declared + // as a variable. e.g. "const gestureProvider = {provide: .., useClass: GestureConfig}". + // In that case, we just add an empty object literal with TODO and print a failure. + if (!ts.isArrayLiteralExpression(objectLiteralExpr.parent)) { + recorder.remove(objectLiteralExpr.getStart(), objectLiteralExpr.getWidth()); + recorder.insertRight(objectLiteralExpr.getStart(), `/* TODO: remove */ {}`); + this._nodeFailures.push({ + node: objectLiteralExpr, + message: `Unable to delete provider definition for "GestureConfig" completely. ` + + `Please clean up the provider.` + }); + return; + } + + // Removes the object literal from the parent array expression. Removes + // the trailing comma token if present. + removeElementFromArrayExpression(objectLiteralExpr, recorder); + } + + /** Removes the given hammer config token import if it is not used. */ + private _removeHammerConfigTokenImportIfUnused({node, importData}: IdentifierReference) { + const sourceFile = node.getSourceFile(); + const isTokenUsed = this._hammerConfigTokenReferences.some( + r => !r.isImport && !isNamespacedIdentifierAccess(r.node) && + r.node.getSourceFile() === sourceFile && !this._deletedIdentifiers.includes(r.node)); + + // We don't want to remove the import for the token if the token is + // still used somewhere. + if (!isTokenUsed) { + this._importManager.deleteNamedBindingImport( + sourceFile, HAMMER_CONFIG_TOKEN_NAME, importData.moduleName); + } + } + + /** Removes Hammer from all index HTML files of the given project. */ + private _removeHammerFromIndexFile(project: WorkspaceProject) { + const indexFilePaths = getProjectIndexFiles(project); + indexFilePaths.forEach(filePath => { + if (!this.tree.exists(filePath)) { + return; + } + + const htmlContent = this.tree.read(filePath)!.toString('utf8'); + const recorder = this.getUpdateRecorder(filePath); + + findHammerScriptImportElements(htmlContent) + .forEach(el => removeElementFromHtml(el, recorder)); + }); + } + + /** + * Sets up the Hammer gesture config provider in the given app module + * if needed. + */ + private _setupGestureConfigProviderIfNeeded( + sourceFile: ts.SourceFile, appModulePath: string, configPath: string) { + const hammerConfigTokenExpr = this._importManager.addImportToSourceFile( + sourceFile, HAMMER_CONFIG_TOKEN_NAME, HAMMER_CONFIG_TOKEN_MODULE); + const gestureConfigExpr = this._importManager.addImportToSourceFile( + sourceFile, GESTURE_CONFIG_CLASS_NAME, getModuleSpecifier(configPath, sourceFile.fileName)); + + const recorder = this.getUpdateRecorder(sourceFile.fileName); + const newProviderNode = ts.createObjectLiteral([ + ts.createPropertyAssignment('provide', hammerConfigTokenExpr), + ts.createPropertyAssignment('useClass', gestureConfigExpr) + ]); + + // If no "NgModule" definition is found inside the source file, we just do nothing. + const metadata = getDecoratorMetadata(sourceFile, 'NgModule', '@angular/core') as + ts.ObjectLiteralExpression[]; + if (!metadata.length) { + return; + } + + const providersField = getMetadataField(metadata[0], 'providers')[0]; + const providerIdentifiers = + providersField ? findMatchingChildNodes(providersField, ts.isIdentifier) : null; + + // If the providers field exists and already contains references to the hammer + // gesture config token and the gesture config, we naively assume that the gesture + // config is already set up. This check is slightly naive because it assumes that + // references to these two tokens always mean that they are set up as a provider + // definition. This is not guaranteed because it could be just by incident that + // gesture config is somehow references in a different provider than for setting up + // the gesture config token from platform-browser. This check can never be very + // robust without actually interpreting the providers field like NGC or ngtsc would. + // (this would involve partial interpretation with metadata.json file support) + if (providerIdentifiers && + this._hammerConfigTokenReferences.some(r => providerIdentifiers.includes(r.node)) && + this._gestureConfigReferences.some(r => providerIdentifiers.includes(r.node))) { + return; + } + + const changeActions = addSymbolToNgModuleMetadata( + sourceFile, appModulePath, 'providers', this._printNode(newProviderNode, sourceFile), null); + + changeActions.forEach(change => { + if (change instanceof InsertChange) { + recorder.insertRight(change.pos, change.toAdd); + } + }); + } + + /** Prints a given node within the specified source file. */ + private _printNode(node: ts.Node, sourceFile: ts.SourceFile): string { + return this._printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); + } + + /** Gets the symbol that contains the value declaration of the specified node. */ + private _getDeclarationSymbolOfNode(node: ts.Node): ts.Symbol|undefined { + const symbol = this.typeChecker.getSymbolAtLocation(node); + + // Symbols can be aliases of the declaration symbol. e.g. in named import specifiers. + // We need to resolve the aliased symbol back to the declaration symbol. + // tslint:disable-next-line:no-bitwise + if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { + return this.typeChecker.getAliasedSymbol(symbol); + } + return symbol; + } + + /** + * Checks whether the given expression resolves to a hammer gesture config + * token reference from "@angular/platform-browser". + */ + private _isReferenceToHammerConfigToken(expr: ts.Expression) { + const unwrapped = unwrapExpression(expr); + if (ts.isIdentifier(unwrapped)) { + return this._hammerConfigTokenReferences.some(r => r.node === unwrapped); + } else if (ts.isPropertyAccessExpression(unwrapped)) { + return this._hammerConfigTokenReferences.some(r => r.node === unwrapped.name); + } + return false; + } + + /** + * Creates migration failures of the collected node failures. The returned migration + * failures are updated to reflect the post-migration state of source files. Meaning + * that failure positions are corrected if source file modifications shifted lines. + */ + private _createMigrationFailures(): MigrationFailure[] { + return this._nodeFailures.map(({node, message}) => { + const sourceFile = node.getSourceFile(); + const offset = node.getStart(); + const position = ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()); + return { + position: this._importManager.correctNodePosition(node, offset, position), + message: message, + filePath: sourceFile.fileName, + }; + }); + } + + /** + * Gets the project from the current program or throws if no project + * could be found. + */ + private _getProjectOrThrow(): WorkspaceProject { + const workspace = getWorkspace(this.tree); + const project = getProjectFromProgram(workspace, this.program); + + if (!project) { + throw new SchematicsException( + 'Could not find project to perform HammerJS v9 migration. ' + + 'Please ensure your workspace configuration defines a project.'); + } + + return project; + } + + /** Global state of whether Hammer is used in any analyzed project target. */ + static globalUsesHammer = false; + + /** + * Static migration rule method that will be called once all project targets + * have been migrated individually. This method can be used to make changes based + * on the analysis of the individual targets. For example: we only remove Hammer + * from the "package.json" if it is not used in *any* project target. + */ + static globalPostMigration(tree: Tree, context: SchematicContext) { + if (!this.globalUsesHammer && this._removeHammerFromPackageJson(tree)) { + // Since Hammer has been removed from the workspace "package.json" file, + // we schedule a node package install task to refresh the lock file. + context.addTask(new NodePackageInstallTask({quiet: false})); + } + + context.logger.info(chalk.yellow( + ' ⚠ The HammerJS v9 migration for Angular components is not able to migrate tests. ' + + 'Please manually clean up tests in your project if they rely on HammerJS.')); + + // Clean global state once the workspace has been migrated. This is technically + // not necessary in "ng update", but in tests we re-use the same rule class. + this.globalUsesHammer = false; + } + + /** + * Removes the hammer package from the workspace "package.json". + * @returns Whether Hammer was set up and has been removed from the "package.json" + */ + private static _removeHammerFromPackageJson(tree: Tree): boolean { + if (!tree.exists('/package.json')) { + return false; + } + + const packageJson = JSON.parse(tree.read('/package.json')!.toString('utf8')); + + // We do not handle the case where someone manually added "hammerjs" + // to the dev dependencies. + if (packageJson.dependencies[HAMMER_MODULE_SPECIFIER]) { + delete packageJson.dependencies[HAMMER_MODULE_SPECIFIER]; + tree.overwrite('/package.json', JSON.stringify(packageJson, null, 2)); + return true; + } + return false; + } +} + +/** + * Recursively unwraps a given expression if it is wrapped + * by parenthesis, type casts or type assertions. + */ +function unwrapExpression(node: ts.Node): ts.Node { + if (ts.isParenthesizedExpression(node)) { + return unwrapExpression(node.expression); + } else if (ts.isAsExpression(node)) { + return unwrapExpression(node.expression); + } else if (ts.isTypeAssertion(node)) { + return unwrapExpression(node.expression); + } + return node; +} + +/** + * Converts the specified path to a valid TypeScript module specifier which is + * relative to the given containing file. + */ +function getModuleSpecifier(newPath: string, containingFile: string) { + let result = relative(dirname(containingFile), newPath).replace(/\\/g, '/').replace(/\.ts$/, ''); + if (!result.startsWith('.')) { + result = `./${result}`; + } + return result; +} + +/** + * Gets the text of the given property name. + * @returns Text of the given property name. Null if not statically analyzable. + */ +function getPropertyNameText(node: ts.PropertyName): string|null { + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) { + return node.text; + } + return null; +} + +/** Checks whether the given identifier is part of a namespaced access. */ +function isNamespacedIdentifierAccess(node: ts.Identifier): boolean { + return ts.isQualifiedName(node.parent) || ts.isPropertyAccessExpression(node.parent); +} + +/** + * Walks through the specified node and returns all child nodes which match the + * given predicate. + */ +function findMatchingChildNodes( + parent: ts.Node, predicate: (node: ts.Node) => node is T): T[] { + const result: T[] = []; + const visitNode = (node: ts.Node) => { + if (predicate(node)) { + result.push(node); + } + ts.forEachChild(node, visitNode); + }; + ts.forEachChild(parent, visitNode); + return result; +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-template-check.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-template-check.ts new file mode 100644 index 000000000000..7868e3cf2a0c --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/hammer-template-check.ts @@ -0,0 +1,48 @@ +/** + * @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 {parse5} from '@angular/cdk/schematics'; + +/** + * List of known events which are supported by the "HammerGesturesPlugin" and by + * the gesture config which was provided by Angular Material. + */ +const KNOWN_HAMMERJS_EVENTS = [ + // Events supported by the "HammerGesturesPlugin". See: + // angular/angular/blob/0119f46d/packages/platform-browser/src/dom/events/hammer_gestures.ts#L19 + 'pan', 'panstart', 'panmove', 'panend', 'pancancel', 'panleft', 'panright', 'panup', 'pandown', + 'pinch', 'pinchstart', 'pinchmove', 'pinchend', 'pinchcancel', 'pinchin', 'pinchout', 'press', + 'pressup', 'rotate', 'rotatestart', 'rotatemove', 'rotateend', 'rotatecancel', 'swipe', + 'swipeleft', 'swiperight', 'swipeup', 'swipedown', 'tap', + + // Events from the Angular Material gesture config. + 'longpress', 'slide', 'slidestart', 'slideend', 'slideright', 'slideleft' +]; + +/** + * Parses the specified HTML and searches for elements with Angular outputs listening to + * one of the known HammerJS events. This check naively assumes that the bindings never + * match on a component output, but only on the Hammer plugin. + */ +export function isHammerJsUsedInTemplate(html: string): boolean { + const document = + parse5.parseFragment(html, {sourceCodeLocationInfo: true}) as parse5.DefaultTreeDocument; + let result = false; + const visitNodes = nodes => { + nodes.forEach(node => { + if (node.attrs && + node.attrs.some(attr => KNOWN_HAMMERJS_EVENTS.some(e => `(${e})` === attr.name))) { + result = true; + } else if (node.childNodes) { + visitNodes(node.childNodes); + } + }); + }; + visitNodes(document.childNodes); + return result; +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/identifier-imports.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/identifier-imports.ts new file mode 100644 index 000000000000..0a502a067c93 --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/identifier-imports.ts @@ -0,0 +1,113 @@ +/** + * @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 * as ts from 'typescript'; + +/** Interface describing a resolved import. */ +export interface Import { + /** Name of the imported symbol. */ + symbolName: string; + /** Module name from which the symbol has been imported. */ + moduleName: string; +} + + +/** Resolves the import of the specified identifier. */ +export function getImportOfIdentifier(node: ts.Identifier, typeChecker: ts.TypeChecker): Import| + null { + const directImport = getSpecificImportOfIdentifier(node, typeChecker); + if (directImport !== null) { + return directImport; + } else if (ts.isQualifiedName(node.parent) && node.parent.right === node) { + const qualifierRoot = getQualifiedNameRoot(node.parent); + if (qualifierRoot) { + const moduleName = getImportOfNamespacedIdentifier(qualifierRoot, typeChecker); + if (moduleName) { + return {moduleName, symbolName: node.text}; + } + } + } else if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) { + const rootIdentifier = getPropertyAccessRoot(node.parent); + if (rootIdentifier) { + const moduleName = getImportOfNamespacedIdentifier(rootIdentifier, typeChecker); + if (moduleName) { + return {moduleName, symbolName: node.text}; + } + } + } + return null; +} + +/** + * Resolves the import of the specified identifier. Expects the identifier to resolve + * to a fine-grained import declaration with import specifiers. + */ +function getSpecificImportOfIdentifier(node: ts.Identifier, typeChecker: ts.TypeChecker): Import| + null { + const symbol = typeChecker.getSymbolAtLocation(node); + if (!symbol || !symbol.declarations.length) { + return null; + } + const declaration = symbol.declarations[0]; + if (!ts.isImportSpecifier(declaration)) { + return null; + } + const importDecl = declaration.parent.parent.parent; + if (!ts.isStringLiteral(importDecl.moduleSpecifier)) { + return null; + } + return { + moduleName: importDecl.moduleSpecifier.text, + symbolName: declaration.propertyName ? declaration.propertyName.text : declaration.name.text + }; +} + +/** + * Resolves the import of the specified identifier. Expects the identifier to + * resolve to a namespaced import declaration. e.g. "import * as core from ...". + */ +function getImportOfNamespacedIdentifier(node: ts.Identifier, typeChecker: ts.TypeChecker): string| + null { + const symbol = typeChecker.getSymbolAtLocation(node); + if (!symbol || !symbol.declarations.length) { + return null; + } + const declaration = symbol.declarations[0]; + if (!ts.isNamespaceImport(declaration)) { + return null; + } + const importDecl = declaration.parent.parent; + if (!ts.isStringLiteral(importDecl.moduleSpecifier)) { + return null; + } + + return importDecl.moduleSpecifier.text; +} + + +/** + * Gets the root identifier of a qualified type chain. For example: "core.GestureConfig" + * will return the "matCore" identifier. Allowing us to find the import of "core". + */ +function getQualifiedNameRoot(name: ts.QualifiedName): ts.Identifier|null { + while (ts.isQualifiedName(name.left)) { + name = name.left; + } + return ts.isIdentifier(name.left) ? name.left : null; +} + +/** + * Gets the root identifier of a property access chain. For example: "core.GestureConfig" + * will return the "matCore" identifier. Allowing us to find the import of "core". + */ +function getPropertyAccessRoot(node: ts.PropertyAccessExpression): ts.Identifier|null { + while (ts.isPropertyAccessExpression(node.expression)) { + node = node.expression; + } + return ts.isIdentifier(node.expression) ? node.expression : null; +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/import-manager.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/import-manager.ts new file mode 100644 index 000000000000..fdf0ed8554bf --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/import-manager.ts @@ -0,0 +1,463 @@ +/** + * @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 {UpdateRecorder} from '@angular-devkit/schematics'; +import {dirname, resolve} from 'path'; +import * as ts from 'typescript'; + +// tslint:disable:no-bitwise + +/** Enum describing the possible states of an analyzed import. */ +enum ImportState { + UNMODIFIED = 0b0, + MODIFIED = 0b10, + ADDED = 0b100, + DELETED = 0b1000, +} + +/** Interface describing an import specifier. */ +interface ImportSpecifier { + name: ts.Identifier; + propertyName?: ts.Identifier; +} + +/** Interface describing an analyzed import. */ +interface AnalyzedImport { + node: ts.ImportDeclaration; + moduleName: string; + name?: ts.Identifier; + specifiers?: ImportSpecifier[]; + namespace?: boolean; + state: ImportState; +} + +/** Checks whether an analyzed import has the given import flag set. */ +const hasFlag = (data: AnalyzedImport, flag: ImportState) => (data.state & flag) !== 0; + +/** + * Import manager that can be used to add or remove TypeScript imports within source + * files. The manager ensures that multiple transformations are applied properly + * without shifted offsets and that existing imports are re-used. + */ +export class ImportManager { + /** Map of source-files and their previously used identifier names. */ + private _usedIdentifierNames = new Map(); + + /** Map of source files and their analyzed imports. */ + private _importCache = new Map(); + + constructor( + private _getUpdateRecorder: (filePath: string) => UpdateRecorder, + private _printer: ts.Printer) {} + + /** + * Analyzes the import of the specified source file if needed. In order to perform + * modifications to imports of a source file, we store all imports in memory and + * update the source file once all changes have been made. This is essential to + * ensure that we can re-use newly added imports and not break file offsets. + */ + private _analyzeImportsIfNeeded(sourceFile: ts.SourceFile): AnalyzedImport[] { + if (this._importCache.has(sourceFile)) { + return this._importCache.get(sourceFile)!; + } + + const result: AnalyzedImport[] = []; + for (let node of sourceFile.statements) { + if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) { + continue; + } + + const moduleName = node.moduleSpecifier.text; + + // Handles side-effect imports which do neither have a name or + // specifiers. e.g. `import "my-package";` + if (!node.importClause) { + result.push({moduleName, node, state: ImportState.UNMODIFIED}); + continue; + } + + // Handles imports resolving to default exports of a module. + // e.g. `import moment from "moment";` + if (!node.importClause.namedBindings) { + result.push( + {moduleName, node, name: node.importClause.name, state: ImportState.UNMODIFIED}); + continue; + } + + // Handles imports with individual symbol specifiers. + // e.g. `import {A, B, C} from "my-module";` + if (ts.isNamedImports(node.importClause.namedBindings)) { + result.push({ + moduleName, + node, + specifiers: node.importClause.namedBindings.elements.map( + el => ({name: el.name, propertyName: el.propertyName})), + state: ImportState.UNMODIFIED, + }); + } else { + // Handles namespaced imports. e.g. `import * as core from "my-pkg";` + result.push({ + moduleName, + node, + name: node.importClause.namedBindings.name, + namespace: true, + state: ImportState.UNMODIFIED, + }); + } + } + this._importCache.set(sourceFile, result); + return result; + } + + /** + * Checks whether the given specifier, which can be relative to the base path, + * matches the passed module name. + */ + private _isModuleSpecifierMatching(basePath: string, specifier: string, moduleName: string): + boolean { + return specifier.startsWith('.') ? + resolve(basePath, specifier) === resolve(basePath, moduleName) : + specifier === moduleName; + } + + /** Deletes a given named binding import from the specified source file. */ + deleteNamedBindingImport(sourceFile: ts.SourceFile, symbolName: string, moduleName: string) { + const sourceDir = dirname(sourceFile.fileName); + const fileImports = this._analyzeImportsIfNeeded(sourceFile); + + for (let importData of fileImports) { + if (!this._isModuleSpecifierMatching(sourceDir, importData.moduleName, moduleName) || + !importData.specifiers) { + continue; + } + + const specifierIndex = + importData.specifiers.findIndex(d => (d.propertyName || d.name).text === symbolName); + if (specifierIndex !== -1) { + importData.specifiers.splice(specifierIndex, 1); + // if the import does no longer contain any specifiers after the removal of the + // given symbol, we can just mark the whole import for deletion. Otherwise, we mark + // it as modified so that it will be re-printed. + if (importData.specifiers.length === 0) { + importData.state |= ImportState.DELETED; + } else { + importData.state |= ImportState.MODIFIED; + } + } + } + } + + /** Deletes the import that matches the given import declaration if found. */ + deleteImportByDeclaration(declaration: ts.ImportDeclaration) { + const fileImports = this._analyzeImportsIfNeeded(declaration.getSourceFile()); + for (let importData of fileImports) { + if (importData.node === declaration) { + importData.state |= ImportState.DELETED; + } + } + } + + /** + * Adds an import to the given source file and returns the TypeScript expression that + * can be used to access the newly imported symbol. + * + * Whenever an import is added to a source file, it's recommended that the returned + * expression is used to reference th symbol. This is necessary because the symbol + * could be aliased if it would collide with existing imports in source file. + * + * @param sourceFile Source file to which the import should be added. + * @param symbolName Name of the symbol that should be imported. Can be null if + * the default export is requested. + * @param moduleName Name of the module of which the symbol should be imported. + * @param typeImport Whether the symbol is a type. + * @param ignoreIdentifierCollisions List of identifiers which can be ignored when + * the import manager checks for import collisions. + */ + addImportToSourceFile( + sourceFile: ts.SourceFile, symbolName: string|null, moduleName: string, typeImport = false, + ignoreIdentifierCollisions: ts.Identifier[] = []): ts.Expression { + const sourceDir = dirname(sourceFile.fileName); + const fileImports = this._analyzeImportsIfNeeded(sourceFile); + + let existingImport: AnalyzedImport|null = null; + for (let importData of fileImports) { + if (!this._isModuleSpecifierMatching(sourceDir, importData.moduleName, moduleName)) { + continue; + } + + // If no symbol name has been specified, the default import is requested. In that + // case we search for non-namespace and non-specifier imports. + if (!symbolName && !importData.namespace && !importData.specifiers) { + return ts.createIdentifier(importData.name!.text); + } + + // In case a "Type" symbol is imported, we can't use namespace imports + // because these only export symbols available at runtime (no types) + if (importData.namespace && !typeImport) { + return ts.createPropertyAccess( + ts.createIdentifier(importData.name!.text), + ts.createIdentifier(symbolName || 'default')); + } else if (importData.specifiers && symbolName) { + const existingSpecifier = importData.specifiers.find( + s => s.propertyName ? s.propertyName.text === symbolName : s.name.text === symbolName); + + if (existingSpecifier) { + return ts.createIdentifier(existingSpecifier.name.text); + } + + // In case the symbol could not be found in an existing import, we + // keep track of the import declaration as it can be updated to include + // the specified symbol name without having to create a new import. + existingImport = importData; + } + } + + // If there is an existing import that matches the specified module, we + // just update the import specifiers to also import the requested symbol. + if (existingImport) { + const propertyIdentifier = ts.createIdentifier(symbolName!); + const generatedUniqueIdentifier = + this._getUniqueIdentifier(sourceFile, symbolName!, ignoreIdentifierCollisions); + const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName; + const importName = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier; + + existingImport.specifiers!.push({ + name: importName, + propertyName: needsGeneratedUniqueName ? propertyIdentifier : undefined, + }); + existingImport.state |= ImportState.MODIFIED; + + if (hasFlag(existingImport, ImportState.DELETED)) { + // unset the deleted flag if the import is pending deletion, but + // can now be used for the new imported symbol. + existingImport.state &= ~ImportState.DELETED; + } + + return importName; + } + + let identifier: ts.Identifier|null = null; + let newImport: AnalyzedImport|null = null; + + if (symbolName) { + const propertyIdentifier = ts.createIdentifier(symbolName); + const generatedUniqueIdentifier = + this._getUniqueIdentifier(sourceFile, symbolName, ignoreIdentifierCollisions); + const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName; + identifier = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier; + + const newImportDecl = ts.createImportDeclaration( + undefined, undefined, ts.createImportClause(undefined, ts.createNamedImports([])), + ts.createStringLiteral(moduleName)); + + newImport = { + moduleName, + node: newImportDecl, + specifiers: [{ + propertyName: needsGeneratedUniqueName ? propertyIdentifier : undefined, + name: identifier + }], + state: ImportState.ADDED, + }; + } else { + identifier = + this._getUniqueIdentifier(sourceFile, 'defaultExport', ignoreIdentifierCollisions); + const newImportDecl = ts.createImportDeclaration( + undefined, undefined, ts.createImportClause(identifier, undefined), + ts.createStringLiteral(moduleName)); + newImport = { + moduleName, + node: newImportDecl, + name: identifier, + state: ImportState.ADDED, + }; + } + fileImports.push(newImport); + return identifier; + } + + /** + * Applies the recorded changes in the update recorders of the corresponding source files. + * The changes are applied separately after all changes have been recorded because otherwise + * file offsets will change and the source files would need to be re-parsed after each change. + */ + recordChanges() { + this._importCache.forEach((fileImports, sourceFile) => { + const recorder = this._getUpdateRecorder(sourceFile.fileName); + const lastUnmodifiedImport = + fileImports.reverse().find(i => i.state === ImportState.UNMODIFIED); + const importStartIndex = + lastUnmodifiedImport ? this._getEndPositionOfNode(lastUnmodifiedImport.node) : 0; + + fileImports.forEach(importData => { + if (importData.state === ImportState.UNMODIFIED) { + return; + } + + if (hasFlag(importData, ImportState.DELETED)) { + // Imports which do not exist in source file, can be just skipped as + // we do not need any replacement to delete the import. + if (!hasFlag(importData, ImportState.ADDED)) { + recorder.remove(importData.node.getFullStart(), importData.node.getFullWidth()); + } + return; + } + + if (importData.specifiers) { + const namedBindings = importData.node.importClause!.namedBindings as ts.NamedImports; + const importSpecifiers = + importData.specifiers.map(s => ts.createImportSpecifier(s.propertyName, s.name)); + const updatedBindings = ts.updateNamedImports(namedBindings, importSpecifiers); + + // In case an import has been added newly, we need to print the whole import + // declaration and insert it at the import start index. Otherwise, we just + // update the named bindings to not re-print the whole import (which could + // cause unnecessary formatting changes) + if (hasFlag(importData, ImportState.ADDED)) { + const updatedImport = ts.updateImportDeclaration( + importData.node, undefined, undefined, + ts.createImportClause(undefined, updatedBindings), + ts.createStringLiteral(importData.moduleName)); + const newImportText = + this._printer.printNode(ts.EmitHint.Unspecified, updatedImport, sourceFile); + recorder.insertLeft( + importStartIndex, + importStartIndex === 0 ? `${newImportText}\n` : `\n${newImportText}`); + return; + } else if (hasFlag(importData, ImportState.MODIFIED)) { + const newNamedBindingsText = + this._printer.printNode(ts.EmitHint.Unspecified, updatedBindings, sourceFile); + recorder.remove(namedBindings.getStart(), namedBindings.getWidth()); + recorder.insertRight(namedBindings.getStart(), newNamedBindingsText); + return; + } + } else if (hasFlag(importData, ImportState.ADDED)) { + const newImportText = + this._printer.printNode(ts.EmitHint.Unspecified, importData.node, sourceFile); + recorder.insertLeft( + importStartIndex, + importStartIndex === 0 ? `${newImportText}\n` : `\n${newImportText}`); + return; + } + + // we should never hit this, but we rather want to print a custom exception + // instead of just skipping imports silently. + throw Error('Unexpected import modification.'); + }); + }); + } + + /** + * Corrects the line and character position of a given node. Since nodes of + * source files are immutable and we sometimes make changes to the containing + * source file, the node position might shift (e.g. if we add a new import before). + * + * This method can be used to retrieve a corrected position of the given node. This + * is helpful when printing out error messages which should reflect the new state of + * source files. + */ + correctNodePosition(node: ts.Node, offset: number, position: ts.LineAndCharacter) { + const sourceFile = node.getSourceFile(); + + if (!this._importCache.has(sourceFile)) { + return position; + } + + const newPosition: ts.LineAndCharacter = {...position}; + const fileImports = this._importCache.get(sourceFile)!; + + for (let importData of fileImports) { + const fullEnd = importData.node.getFullStart() + importData.node.getFullWidth(); + // Subtract or add lines based on whether an import has been deleted or removed + // before the actual node offset. + if (offset > fullEnd && hasFlag(importData, ImportState.DELETED)) { + newPosition.line--; + } else if (offset > fullEnd && hasFlag(importData, ImportState.ADDED)) { + newPosition.line++; + } + } + return newPosition; + } + + /** + * Returns an unique identifier name for the specified symbol name. + * @param sourceFile Source file to check for identifier collisions. + * @param symbolName Name of the symbol for which we want to generate an unique name. + * @param ignoreIdentifierCollisions List of identifiers which should be ignored when + * checking for identifier collisions in the given source file. + */ + private _getUniqueIdentifier( + sourceFile: ts.SourceFile, symbolName: string, + ignoreIdentifierCollisions: ts.Identifier[]): ts.Identifier { + if (this._isUniqueIdentifierName(sourceFile, symbolName, ignoreIdentifierCollisions)) { + this._recordUsedIdentifier(sourceFile, symbolName); + return ts.createIdentifier(symbolName); + } + + let name: string|null = null; + let counter = 1; + do { + name = `${symbolName}_${counter++}`; + } while (!this._isUniqueIdentifierName(sourceFile, name, ignoreIdentifierCollisions)); + + this._recordUsedIdentifier(sourceFile, name!); + return ts.createIdentifier(name!); + } + + /** + * Checks whether the specified identifier name is used within the given source file. + * @param sourceFile Source file to check for identifier collisions. + * @param name Name of the identifier which is checked for its uniqueness. + * @param ignoreIdentifierCollisions List of identifiers which should be ignored when + * checking for identifier collisions in the given source file. + */ + private _isUniqueIdentifierName( + sourceFile: ts.SourceFile, name: string, ignoreIdentifierCollisions: ts.Identifier[]) { + if (this._usedIdentifierNames.has(sourceFile) && + this._usedIdentifierNames.get(sourceFile)!.indexOf(name) !== -1) { + return false; + } + + // Walk through the source file and search for an identifier matching + // the given name. In that case, it's not guaranteed that this name + // is unique in the given declaration scope and we just return false. + const nodeQueue: ts.Node[] = [sourceFile]; + while (nodeQueue.length) { + const node = nodeQueue.shift()!; + if (ts.isIdentifier(node) && node.text === name && + !ignoreIdentifierCollisions.includes(node)) { + return false; + } + nodeQueue.push(...node.getChildren()); + } + return true; + } + + /** + * Records that the given identifier is used within the specified source file. This + * is necessary since we do not apply changes to source files per change, but still + * want to avoid conflicts with newly imported symbols. + */ + private _recordUsedIdentifier(sourceFile: ts.SourceFile, identifierName: string) { + this._usedIdentifierNames.set( + sourceFile, (this._usedIdentifierNames.get(sourceFile) || []).concat(identifierName)); + } + + /** + * Determines the full end of a given node. By default the end position of a node is + * before all trailing comments. This could mean that generated imports shift comments. + */ + private _getEndPositionOfNode(node: ts.Node) { + const nodeEndPos = node.getEnd(); + const commentRanges = ts.getTrailingCommentRanges(node.getSourceFile().text, nodeEndPos); + if (!commentRanges || !commentRanges.length) { + return nodeEndPos; + } + return commentRanges[commentRanges.length - 1]!.end; + } +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-array-element.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-array-element.ts new file mode 100644 index 000000000000..7ea0a51f5553 --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-array-element.ts @@ -0,0 +1,67 @@ +/** + * @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 {UpdateRecorder} from '@angular-devkit/schematics'; +import * as ts from 'typescript'; + +/** + * Retrieves the parent syntax list of the given node. A syntax list node is usually + * hidden from the default AST node hierarchy because it only contains information that + * is need when printing a node. e.g. it contains information about comma positions in + * an array literal expression. + */ +export function getParentSyntaxList(node: ts.Node): ts.SyntaxList|null { + if (!node.parent) { + return null; + } + const parent = node.parent; + const {pos, end} = node; + for (const child of parent.getChildren()) { + if (child.pos > end || child === node) { + return null; + } + + if (child.kind === ts.SyntaxKind.SyntaxList && child.pos <= pos && child.end >= end) { + return child as ts.SyntaxList; + } + } + return null; +} + +/** Looks for the trailing comma of the given element within the syntax list. */ +function findTrailingCommaToken(list: ts.SyntaxList, element: ts.Node): ts.Node|null { + let foundElement = false; + for (let child of list.getChildren()) { + if (!foundElement && child === element) { + foundElement = true; + } else if (foundElement) { + if (child.kind === ts.SyntaxKind.CommaToken) { + return child; + } + break; + } + } + return null; +} + +/** Removes a given element from its parent array literal expression. */ +export function removeElementFromArrayExpression(element: ts.Node, recorder: UpdateRecorder) { + recorder.remove(element.getFullStart(), element.getFullWidth()); + + const syntaxList = getParentSyntaxList(element); + if (!syntaxList) { + return; + } + + // if there is a trailing comma token for the element, we need to remove it + // because otherwise the array literal expression will have syntax failures. + const trailingComma = findTrailingCommaToken(syntaxList, element); + if (trailingComma !== null) { + recorder.remove(trailingComma.getFullStart(), trailingComma.getFullWidth()); + } +} diff --git a/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-element-from-html.ts b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-element-from-html.ts new file mode 100644 index 000000000000..7e170c3109b5 --- /dev/null +++ b/src/material/schematics/ng-update/upgrade-rules/hammer-gestures-v9/remove-element-from-html.ts @@ -0,0 +1,33 @@ +/** + * @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 {UpdateRecorder} from '@angular-devkit/schematics'; +import {parse5} from '@angular/cdk/schematics'; + +/** + * Removes the specified element. Additionally, preceding whitespace will be removed + * to not leave empty lines in the resulting HTML. + */ +export function removeElementFromHtml( + element: parse5.DefaultTreeElement, recorder: UpdateRecorder) { + // sourceCodeLocation is always set since we parse with location info enabled. + const {startOffset, endOffset} = element.sourceCodeLocation!; + const parentIndex = element.parentNode.childNodes.indexOf(element); + const precedingTextSibling = element.parentNode.childNodes.find( + (f, i): f is parse5.DefaultTreeTextNode => f.nodeName === '#text' && i === parentIndex - 1); + + recorder.remove(startOffset, endOffset - startOffset); + + // If we found a preceding text node which just consists of whitespace, remove it. + if (precedingTextSibling && /^\s+$/.test(precedingTextSibling.value)) { + const textSiblingLocation = precedingTextSibling.sourceCodeLocation!; + recorder.remove( + textSiblingLocation.startOffset, + textSiblingLocation.endOffset - textSiblingLocation.startOffset); + } +}