Skip to content

Commit 1a99002

Browse files
devversionmmalerba
authored andcommitted
feat(material/schematics): initial foundation for TS code migrators
1 parent 8ec4864 commit 1a99002

File tree

9 files changed

+269
-7
lines changed

9 files changed

+269
-7
lines changed

src/cdk/schematics/update-tool/file-system.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {UpdateRecorder} from './update-recorder';
1818
* like `/package.json` could actually refer to the `package.json` file in `my-project`.
1919
* Note that in the real file system this would not match though.
2020
*
21-
* One wonder why another type has been declared for such paths, when there already
22-
* is the `Path` type provided by the devkit. We do this for a couple of reasons:
21+
* One might wonder why another type has been declared for such paths, when there
22+
* already is the `Path` type provided by the devkit. We do this for a couple of reasons:
2323
*
2424
* 1. The update-tool cannot have a dependency on the Angular devkit as that one
2525
* is not synced into g3. We want to be able to run migrations in g3 if needed.

src/material/schematics/ng-generate/mdc-migration/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ts_library(
2020
"@npm//@types/node",
2121
"@npm//postcss",
2222
"@npm//postcss-scss",
23+
"@npm//typescript",
2324
],
2425
)
2526

@@ -30,6 +31,7 @@ esbuild(
3031
"@angular/cdk/schematics",
3132
"@angular-devkit/schematics",
3233
"@angular-devkit/core",
34+
"typescript",
3335
],
3436
format = "cjs",
3537
output = "index_bundled.js",

src/material/schematics/ng-generate/mdc-migration/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {ComponentMigrator, MIGRATORS} from './rules';
10+
import {DevkitFileSystem, UpdateProject, findStylesheetFiles} from '@angular/cdk/schematics';
911
import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics';
12+
13+
import {RuntimeCodeMigration} from './rules/runtime-migration';
1014
import {Schema} from './schema';
11-
import {DevkitFileSystem, UpdateProject, findStylesheetFiles} from '@angular/cdk/schematics';
12-
import {ThemingStylesMigration} from './rules/theming-styles';
1315
import {TemplateMigration} from './rules/template-migration';
14-
import {ComponentMigrator, MIGRATORS} from './rules';
16+
import {ThemingStylesMigration} from './rules/theming-styles';
1517
import {dirname} from 'path';
1618

1719
/** Groups of components that must be migrated together. */
@@ -78,7 +80,7 @@ export default function (options: Schema): Rule {
7880
const additionalStylesheetPaths = findStylesheetFiles(tree, migrationDir);
7981
const project = new UpdateProject(context, program, fileSystem, new Set(), context.logger);
8082
const {hasFailures} = project.migrate(
81-
[ThemingStylesMigration, TemplateMigration],
83+
[ThemingStylesMigration, TemplateMigration, RuntimeCodeMigration],
8284
null,
8385
migrators,
8486
additionalStylesheetPaths,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {APP_MODULE_FILE, createNewTestRunner, migrateComponents} from '../test-setup-helper';
2+
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
3+
import {createTestApp, patchDevkitTreeToExposeTypeScript} from '@angular/cdk/schematics/testing';
4+
5+
describe('button runtime code', () => {
6+
let runner: SchematicTestRunner;
7+
let cliAppTree: UnitTestTree;
8+
9+
beforeEach(async () => {
10+
runner = createNewTestRunner();
11+
cliAppTree = patchDevkitTreeToExposeTypeScript(await createTestApp(runner));
12+
});
13+
14+
async function runMigrationTest(oldFileContent: string, newFileContent: string) {
15+
cliAppTree.overwrite(APP_MODULE_FILE, oldFileContent);
16+
const tree = await migrateComponents(['button'], runner, cliAppTree);
17+
expect(tree.readContent(APP_MODULE_FILE)).toBe(newFileContent);
18+
}
19+
20+
describe('import statements', () => {
21+
it('should replace the old import with the new one', async () => {
22+
await runMigrationTest(
23+
`
24+
import {NgModule} from '@angular/core';
25+
import {MatButtonModule} from '@angular/material/button';
26+
27+
@NgModule({imports: [MatButtonModule]})
28+
export class AppModule {}
29+
`,
30+
`
31+
import {NgModule} from '@angular/core';
32+
import {MatButtonModule} from '@angular/material-experimental/mdc-button';
33+
34+
@NgModule({imports: [MatButtonModule]})
35+
export class AppModule {}
36+
`,
37+
);
38+
});
39+
40+
it('should migrate multi-line imports', async () => {
41+
await runMigrationTest(
42+
`
43+
import {NgModule} from '@angular/core';
44+
import {
45+
MatButton,
46+
MatButtonModule,
47+
} from '@angular/material/button';
48+
49+
@NgModule({imports: [MatButtonModule]})
50+
export class AppModule {}
51+
`,
52+
`
53+
import {NgModule} from '@angular/core';
54+
import {
55+
MatButton,
56+
MatButtonModule,
57+
} from '@angular/material-experimental/mdc-button';
58+
59+
@NgModule({imports: [MatButtonModule]})
60+
export class AppModule {}
61+
`,
62+
);
63+
});
64+
65+
it('should migrate multiple statements', async () => {
66+
await runMigrationTest(
67+
`
68+
import {NgModule} from '@angular/core';
69+
import {MatButton} from '@angular/material/button';
70+
import {MatButtonModule} from '@angular/material/button';
71+
72+
@NgModule({imports: [MatButtonModule]})
73+
export class AppModule {}
74+
`,
75+
`
76+
import {NgModule} from '@angular/core';
77+
import {MatButton} from '@angular/material-experimental/mdc-button';
78+
import {MatButtonModule} from '@angular/material-experimental/mdc-button';
79+
80+
@NgModule({imports: [MatButtonModule]})
81+
export class AppModule {}
82+
`,
83+
);
84+
});
85+
86+
it('should preserve import comments', async () => {
87+
await runMigrationTest(
88+
`
89+
import {NgModule} from '@angular/core';
90+
import {MatButton /* comment */} from '@angular/material/button';
91+
import {MatButtonModule} from '@angular/material/button'; // a comment
92+
93+
@NgModule({imports: [MatButtonModule]})
94+
export class AppModule {}
95+
`,
96+
`
97+
import {NgModule} from '@angular/core';
98+
import {MatButton /* comment */} from '@angular/material-experimental/mdc-button';
99+
import {MatButtonModule} from '@angular/material-experimental/mdc-button'; // a comment
100+
101+
@NgModule({imports: [MatButtonModule]})
102+
export class AppModule {}
103+
`,
104+
);
105+
});
106+
});
107+
108+
describe('import expressions', () => {
109+
it('should replace the old import with the new one', async () => {
110+
await runMigrationTest(
111+
`
112+
const buttonModule = import('@angular/material/button');
113+
`,
114+
`
115+
const buttonModule = import('@angular/material-experimental/mdc-button');
116+
`,
117+
);
118+
});
119+
120+
it('should replace type import expressions', async () => {
121+
await runMigrationTest(
122+
`
123+
let buttonModule: typeof import("@angular/material/button");
124+
`,
125+
`
126+
let buttonModule: typeof import("@angular/material-experimental/mdc-button");
127+
`,
128+
);
129+
});
130+
});
131+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {RuntimeMigrator} from '../../runtime-migrator';
10+
11+
export class ButtonRuntimeMigrator extends RuntimeMigrator {
12+
oldImportModule = '@angular/material/button';
13+
newImportModule = '@angular/material-experimental/mdc-button';
14+
}

src/material/schematics/ng-generate/mdc-migration/rules/components/test-setup-helper.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
*/
88

99
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
10+
1011
import {runfiles} from '@bazel/runfiles';
1112

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

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

1719
export function createNewTestRunner(): SchematicTestRunner {

src/material/schematics/ng-generate/mdc-migration/rules/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {ButtonRuntimeMigrator} from './components/button/button-runtime';
910
import {ButtonStylesMigrator} from './components/button/button-styles';
1011
import {CardStylesMigrator} from './components/card/card-styles';
1112
import {CardTemplateMigrator} from './components/card/card-template';
@@ -17,23 +18,26 @@ import {PaginatorStylesMigrator} from './components/paginator/paginator-styles';
1718
import {ProgressBarStylesMigrator} from './components/progress-bar/progress-bar-styles';
1819
import {ProgressSpinnerStylesMigrator} from './components/progress-spinner/progress-spinner-styles';
1920
import {RadioStylesMigrator} from './components/radio/radio-styles';
21+
import {RuntimeMigrator} from './runtime-migrator';
2022
import {SlideToggleStylesMigrator} from './components/slide-toggle/slide-toggle-styles';
2123
import {SliderStylesMigrator} from './components/slider/slider-styles';
22-
import {TableStylesMigrator} from './components/table/table-styles';
2324
import {StyleMigrator} from './style-migrator';
25+
import {TableStylesMigrator} from './components/table/table-styles';
2426
import {TemplateMigrator} from './template-migrator';
2527

2628
/** Contains the migrators to migrate a single component. */
2729
export interface ComponentMigrator {
2830
component: string;
2931
styles: StyleMigrator;
3032
template?: TemplateMigrator;
33+
runtime?: RuntimeMigrator;
3134
}
3235

3336
export const MIGRATORS: ComponentMigrator[] = [
3437
{
3538
component: 'button',
3639
styles: new ButtonStylesMigrator(),
40+
runtime: new ButtonRuntimeMigrator(),
3741
},
3842
{
3943
component: 'card',
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Migration} from '@angular/cdk/schematics';
10+
import {SchematicContext} from '@angular-devkit/schematics';
11+
import {ComponentMigrator} from './index';
12+
import * as ts from 'typescript';
13+
14+
export class RuntimeCodeMigration extends Migration<ComponentMigrator[], SchematicContext> {
15+
enabled = true;
16+
17+
private _printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed});
18+
19+
override visitNode(node: ts.Node): void {
20+
if (this._isImportExpression(node)) {
21+
this._migrateModuleSpecifier(node.arguments[0]);
22+
} else if (this._isTypeImportExpression(node)) {
23+
this._migrateModuleSpecifier(node.argument.literal);
24+
} else if (ts.isImportDeclaration(node)) {
25+
// Note: TypeScript enforces the `moduleSpecifier` to be a string literal in its syntax.
26+
this._migrateModuleSpecifier(node.moduleSpecifier as ts.StringLiteral);
27+
}
28+
}
29+
30+
private _migrateModuleSpecifier(specifierLiteral: ts.StringLiteralLike) {
31+
const sourceFile = specifierLiteral.getSourceFile();
32+
33+
// Iterate through all activated migrators and check if the import can be migrated.
34+
for (const migrator of this.upgradeData) {
35+
const newModuleSpecifier = migrator.runtime?.updateModuleSpecifier(specifierLiteral) ?? null;
36+
37+
if (newModuleSpecifier !== null) {
38+
this._printAndUpdateNode(sourceFile, specifierLiteral, newModuleSpecifier);
39+
40+
// If the import has been replaced, break the loop as no others can match.
41+
break;
42+
}
43+
}
44+
}
45+
46+
/** Gets whether the specified node is an import expression. */
47+
private _isImportExpression(
48+
node: ts.Node,
49+
): node is ts.CallExpression & {arguments: [ts.StringLiteralLike]} {
50+
return (
51+
ts.isCallExpression(node) &&
52+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
53+
node.arguments.length === 1 &&
54+
ts.isStringLiteralLike(node.arguments[0])
55+
);
56+
}
57+
58+
/** Gets whether the specified node is a type import expression. */
59+
private _isTypeImportExpression(
60+
node: ts.Node,
61+
): node is ts.ImportTypeNode & {argument: {literal: ts.StringLiteralLike}} {
62+
return (
63+
ts.isImportTypeNode(node) &&
64+
ts.isLiteralTypeNode(node.argument) &&
65+
ts.isStringLiteralLike(node.argument.literal)
66+
);
67+
}
68+
69+
private _printAndUpdateNode(sourceFile: ts.SourceFile, oldNode: ts.Node, newNode: ts.Node) {
70+
const filePath = this.fileSystem.resolve(sourceFile.fileName);
71+
const newNodeText = this._printer.printNode(ts.EmitHint.Unspecified, newNode, sourceFile);
72+
const start = oldNode.getStart();
73+
const width = oldNode.getWidth();
74+
75+
this.fileSystem.edit(filePath).remove(start, width).insertRight(start, newNodeText);
76+
}
77+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as ts from 'typescript';
10+
11+
export abstract class RuntimeMigrator {
12+
abstract oldImportModule: string;
13+
abstract newImportModule: string;
14+
15+
updateModuleSpecifier(specifier: ts.StringLiteralLike): ts.StringLiteral | null {
16+
if (specifier.text !== this.oldImportModule) {
17+
return null;
18+
}
19+
20+
return ts.factory.createStringLiteral(
21+
this.newImportModule,
22+
this._isSingleQuoteLiteral(specifier),
23+
);
24+
}
25+
26+
private _isSingleQuoteLiteral(literal: ts.StringLiteralLike): boolean {
27+
// Note: We prefer single-quote for no-substitution literals as well.
28+
return literal.getText()[0] !== `"`;
29+
}
30+
}

0 commit comments

Comments
 (0)