Skip to content

Commit

Permalink
feat(material/schematics): initial foundation for TS code migrators
Browse files Browse the repository at this point in the history
  • Loading branch information
devversion authored and mmalerba committed Jul 15, 2022
1 parent 8ec4864 commit 1a99002
Show file tree
Hide file tree
Showing 9 changed files with 269 additions and 7 deletions.
4 changes: 2 additions & 2 deletions src/cdk/schematics/update-tool/file-system.ts
Expand Up @@ -18,8 +18,8 @@ import {UpdateRecorder} from './update-recorder';
* like `/package.json` could actually refer to the `package.json` file in `my-project`.
* Note that in the real file system this would not match though.
*
* One wonder why another type has been declared for such paths, when there already
* is the `Path` type provided by the devkit. We do this for a couple of reasons:
* One might wonder why another type has been declared for such paths, when there
* already is the `Path` type provided by the devkit. We do this for a couple of reasons:
*
* 1. The update-tool cannot have a dependency on the Angular devkit as that one
* is not synced into g3. We want to be able to run migrations in g3 if needed.
Expand Down
2 changes: 2 additions & 0 deletions src/material/schematics/ng-generate/mdc-migration/BUILD.bazel
Expand Up @@ -20,6 +20,7 @@ ts_library(
"@npm//@types/node",
"@npm//postcss",
"@npm//postcss-scss",
"@npm//typescript",
],
)

Expand All @@ -30,6 +31,7 @@ esbuild(
"@angular/cdk/schematics",
"@angular-devkit/schematics",
"@angular-devkit/core",
"typescript",
],
format = "cjs",
output = "index_bundled.js",
Expand Down
10 changes: 6 additions & 4 deletions src/material/schematics/ng-generate/mdc-migration/index.ts
Expand Up @@ -6,12 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentMigrator, MIGRATORS} from './rules';
import {DevkitFileSystem, UpdateProject, findStylesheetFiles} from '@angular/cdk/schematics';
import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics';

import {RuntimeCodeMigration} from './rules/runtime-migration';
import {Schema} from './schema';
import {DevkitFileSystem, UpdateProject, findStylesheetFiles} from '@angular/cdk/schematics';
import {ThemingStylesMigration} from './rules/theming-styles';
import {TemplateMigration} from './rules/template-migration';
import {ComponentMigrator, MIGRATORS} from './rules';
import {ThemingStylesMigration} from './rules/theming-styles';
import {dirname} from 'path';

/** Groups of components that must be migrated together. */
Expand Down Expand Up @@ -78,7 +80,7 @@ export default function (options: Schema): Rule {
const additionalStylesheetPaths = findStylesheetFiles(tree, migrationDir);
const project = new UpdateProject(context, program, fileSystem, new Set(), context.logger);
const {hasFailures} = project.migrate(
[ThemingStylesMigration, TemplateMigration],
[ThemingStylesMigration, TemplateMigration, RuntimeCodeMigration],
null,
migrators,
additionalStylesheetPaths,
Expand Down
@@ -0,0 +1,131 @@
import {APP_MODULE_FILE, createNewTestRunner, migrateComponents} from '../test-setup-helper';
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
import {createTestApp, patchDevkitTreeToExposeTypeScript} from '@angular/cdk/schematics/testing';

describe('button runtime code', () => {
let runner: SchematicTestRunner;
let cliAppTree: UnitTestTree;

beforeEach(async () => {
runner = createNewTestRunner();
cliAppTree = patchDevkitTreeToExposeTypeScript(await createTestApp(runner));
});

async function runMigrationTest(oldFileContent: string, newFileContent: string) {
cliAppTree.overwrite(APP_MODULE_FILE, oldFileContent);
const tree = await migrateComponents(['button'], runner, cliAppTree);
expect(tree.readContent(APP_MODULE_FILE)).toBe(newFileContent);
}

describe('import statements', () => {
it('should replace the old import with the new one', async () => {
await runMigrationTest(
`
import {NgModule} from '@angular/core';
import {MatButtonModule} from '@angular/material/button';
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
`
import {NgModule} from '@angular/core';
import {MatButtonModule} from '@angular/material-experimental/mdc-button';
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
);
});

it('should migrate multi-line imports', async () => {
await runMigrationTest(
`
import {NgModule} from '@angular/core';
import {
MatButton,
MatButtonModule,
} from '@angular/material/button';
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
`
import {NgModule} from '@angular/core';
import {
MatButton,
MatButtonModule,
} from '@angular/material-experimental/mdc-button';
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
);
});

it('should migrate multiple statements', async () => {
await runMigrationTest(
`
import {NgModule} from '@angular/core';
import {MatButton} from '@angular/material/button';
import {MatButtonModule} from '@angular/material/button';
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
`
import {NgModule} from '@angular/core';
import {MatButton} from '@angular/material-experimental/mdc-button';
import {MatButtonModule} from '@angular/material-experimental/mdc-button';
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
);
});

it('should preserve import comments', async () => {
await runMigrationTest(
`
import {NgModule} from '@angular/core';
import {MatButton /* comment */} from '@angular/material/button';
import {MatButtonModule} from '@angular/material/button'; // a comment
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
`
import {NgModule} from '@angular/core';
import {MatButton /* comment */} from '@angular/material-experimental/mdc-button';
import {MatButtonModule} from '@angular/material-experimental/mdc-button'; // a comment
@NgModule({imports: [MatButtonModule]})
export class AppModule {}
`,
);
});
});

describe('import expressions', () => {
it('should replace the old import with the new one', async () => {
await runMigrationTest(
`
const buttonModule = import('@angular/material/button');
`,
`
const buttonModule = import('@angular/material-experimental/mdc-button');
`,
);
});

it('should replace type import expressions', async () => {
await runMigrationTest(
`
let buttonModule: typeof import("@angular/material/button");
`,
`
let buttonModule: typeof import("@angular/material-experimental/mdc-button");
`,
);
});
});
});
@@ -0,0 +1,14 @@
/**
* @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 {RuntimeMigrator} from '../../runtime-migrator';

export class ButtonRuntimeMigrator extends RuntimeMigrator {
oldImportModule = '@angular/material/button';
newImportModule = '@angular/material-experimental/mdc-button';
}
Expand Up @@ -7,11 +7,13 @@
*/

import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';

import {runfiles} from '@bazel/runfiles';

const TS_CONFIG = '/projects/material/tsconfig.app.json';

export const THEME_FILE = '/projects/material/src/theme.scss';
export const APP_MODULE_FILE = '/projects/material/src/app/app.module.ts';
export const TEMPLATE_FILE = '/projects/material/src/app/app.component.html';

export function createNewTestRunner(): SchematicTestRunner {
Expand Down
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ButtonRuntimeMigrator} from './components/button/button-runtime';
import {ButtonStylesMigrator} from './components/button/button-styles';
import {CardStylesMigrator} from './components/card/card-styles';
import {CardTemplateMigrator} from './components/card/card-template';
Expand All @@ -17,23 +18,26 @@ import {PaginatorStylesMigrator} from './components/paginator/paginator-styles';
import {ProgressBarStylesMigrator} from './components/progress-bar/progress-bar-styles';
import {ProgressSpinnerStylesMigrator} from './components/progress-spinner/progress-spinner-styles';
import {RadioStylesMigrator} from './components/radio/radio-styles';
import {RuntimeMigrator} from './runtime-migrator';
import {SlideToggleStylesMigrator} from './components/slide-toggle/slide-toggle-styles';
import {SliderStylesMigrator} from './components/slider/slider-styles';
import {TableStylesMigrator} from './components/table/table-styles';
import {StyleMigrator} from './style-migrator';
import {TableStylesMigrator} from './components/table/table-styles';
import {TemplateMigrator} from './template-migrator';

/** Contains the migrators to migrate a single component. */
export interface ComponentMigrator {
component: string;
styles: StyleMigrator;
template?: TemplateMigrator;
runtime?: RuntimeMigrator;
}

export const MIGRATORS: ComponentMigrator[] = [
{
component: 'button',
styles: new ButtonStylesMigrator(),
runtime: new ButtonRuntimeMigrator(),
},
{
component: 'card',
Expand Down
@@ -0,0 +1,77 @@
/**
* @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 {Migration} from '@angular/cdk/schematics';
import {SchematicContext} from '@angular-devkit/schematics';
import {ComponentMigrator} from './index';
import * as ts from 'typescript';

export class RuntimeCodeMigration extends Migration<ComponentMigrator[], SchematicContext> {
enabled = true;

private _printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed});

override visitNode(node: ts.Node): void {
if (this._isImportExpression(node)) {
this._migrateModuleSpecifier(node.arguments[0]);
} else if (this._isTypeImportExpression(node)) {
this._migrateModuleSpecifier(node.argument.literal);
} else if (ts.isImportDeclaration(node)) {
// Note: TypeScript enforces the `moduleSpecifier` to be a string literal in its syntax.
this._migrateModuleSpecifier(node.moduleSpecifier as ts.StringLiteral);
}
}

private _migrateModuleSpecifier(specifierLiteral: ts.StringLiteralLike) {
const sourceFile = specifierLiteral.getSourceFile();

// Iterate through all activated migrators and check if the import can be migrated.
for (const migrator of this.upgradeData) {
const newModuleSpecifier = migrator.runtime?.updateModuleSpecifier(specifierLiteral) ?? null;

if (newModuleSpecifier !== null) {
this._printAndUpdateNode(sourceFile, specifierLiteral, newModuleSpecifier);

// If the import has been replaced, break the loop as no others can match.
break;
}
}
}

/** Gets whether the specified node is an import expression. */
private _isImportExpression(
node: ts.Node,
): node is ts.CallExpression & {arguments: [ts.StringLiteralLike]} {
return (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length === 1 &&
ts.isStringLiteralLike(node.arguments[0])
);
}

/** Gets whether the specified node is a type import expression. */
private _isTypeImportExpression(
node: ts.Node,
): node is ts.ImportTypeNode & {argument: {literal: ts.StringLiteralLike}} {
return (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteralLike(node.argument.literal)
);
}

private _printAndUpdateNode(sourceFile: ts.SourceFile, oldNode: ts.Node, newNode: ts.Node) {
const filePath = this.fileSystem.resolve(sourceFile.fileName);
const newNodeText = this._printer.printNode(ts.EmitHint.Unspecified, newNode, sourceFile);
const start = oldNode.getStart();
const width = oldNode.getWidth();

this.fileSystem.edit(filePath).remove(start, width).insertRight(start, newNodeText);
}
}
@@ -0,0 +1,30 @@
/**
* @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';

export abstract class RuntimeMigrator {
abstract oldImportModule: string;
abstract newImportModule: string;

updateModuleSpecifier(specifier: ts.StringLiteralLike): ts.StringLiteral | null {
if (specifier.text !== this.oldImportModule) {
return null;
}

return ts.factory.createStringLiteral(
this.newImportModule,
this._isSingleQuoteLiteral(specifier),
);
}

private _isSingleQuoteLiteral(literal: ts.StringLiteralLike): boolean {
// Note: We prefer single-quote for no-substitution literals as well.
return literal.getText()[0] !== `"`;
}
}

0 comments on commit 1a99002

Please sign in to comment.