From a88c2d3c647938ac698d2f10c9716c5afd3db770 Mon Sep 17 00:00:00 2001 From: Krzysztof Skorupka Date: Mon, 12 Jul 2021 14:45:09 +0200 Subject: [PATCH] feat(schematic-utils): schematic utils from angular schematics --- packages/schematic-utils/package.json | 2 +- packages/schematic-utils/src/index.ts | 8 + packages/schematic-utils/src/tree/empty.ts | 15 + .../src/tree/unit-test-tree.ts | 19 + .../src/utils/ast-utils.spec.ts | 744 ++++++++++++++++ .../schematic-utils/src/utils/ast-utils.ts | 792 ++++++++++++++++++ packages/schematic-utils/src/utils/change.ts | 151 ++++ .../src/utils/create-app-module.ts | 28 + .../src/utils/dependencies.spec.ts | 95 +++ .../schematic-utils/src/utils/dependencies.ts | 72 ++ .../src/utils/find-module.spec.ts | 227 +++++ .../schematic-utils/src/utils/find-module.ts | 160 ++++ .../src/utils/get-file-content.ts | 11 + .../schematic-utils/src/utils/json-file.ts | 101 +++ .../schematic-utils/src/utils/ng-ast-utils.ts | 100 +++ .../src/utils/runtime/index.ts | 3 + .../src/utils/runtime/parse-name.spec.ts | 37 + .../src/utils/runtime/parse-name.ts | 16 + .../src/utils/runtime/paths.spec.ts | 30 + .../src/utils/runtime/paths.ts | 13 + .../src/utils/runtime/validation.ts | 72 ++ .../src/utils/workspace-models.ts | 197 +++++ .../schematic-utils/src/utils/workspace.ts | 135 +++ 23 files changed, 3027 insertions(+), 1 deletion(-) create mode 100644 packages/schematic-utils/src/tree/empty.ts create mode 100644 packages/schematic-utils/src/tree/unit-test-tree.ts create mode 100644 packages/schematic-utils/src/utils/ast-utils.spec.ts create mode 100644 packages/schematic-utils/src/utils/ast-utils.ts create mode 100644 packages/schematic-utils/src/utils/change.ts create mode 100644 packages/schematic-utils/src/utils/create-app-module.ts create mode 100644 packages/schematic-utils/src/utils/dependencies.spec.ts create mode 100644 packages/schematic-utils/src/utils/dependencies.ts create mode 100644 packages/schematic-utils/src/utils/find-module.spec.ts create mode 100644 packages/schematic-utils/src/utils/find-module.ts create mode 100644 packages/schematic-utils/src/utils/get-file-content.ts create mode 100644 packages/schematic-utils/src/utils/json-file.ts create mode 100644 packages/schematic-utils/src/utils/ng-ast-utils.ts create mode 100644 packages/schematic-utils/src/utils/runtime/parse-name.spec.ts create mode 100644 packages/schematic-utils/src/utils/runtime/parse-name.ts create mode 100644 packages/schematic-utils/src/utils/runtime/paths.spec.ts create mode 100644 packages/schematic-utils/src/utils/runtime/paths.ts create mode 100644 packages/schematic-utils/src/utils/runtime/validation.ts create mode 100644 packages/schematic-utils/src/utils/workspace-models.ts create mode 100644 packages/schematic-utils/src/utils/workspace.ts diff --git a/packages/schematic-utils/package.json b/packages/schematic-utils/package.json index 6ed5833..81f7f17 100644 --- a/packages/schematic-utils/package.json +++ b/packages/schematic-utils/package.json @@ -26,7 +26,7 @@ "scripts": { "test": "jest", "build": "tsc -p tsconfig.json", - "lint": "eslint ./src/**/**", + "lint": "eslint ./src/**/** --ignore-pattern '*.spec.ts'", "preversion": "npm run build" }, "dependencies": { diff --git a/packages/schematic-utils/src/index.ts b/packages/schematic-utils/src/index.ts index 7df837e..e0b5037 100644 --- a/packages/schematic-utils/src/index.ts +++ b/packages/schematic-utils/src/index.ts @@ -1 +1,9 @@ export * from "./utils/template"; +export * from "./utils/create-app-module"; +export * from "./utils/get-file-content"; +export * from "./utils/ast-utils"; +export * from "./utils/dependencies"; +export * from "./utils/find-module"; +export * from "./utils/ng-ast-utils"; +export * from "./utils/workspace"; +export * from "./utils/workspace-models"; diff --git a/packages/schematic-utils/src/tree/empty.ts b/packages/schematic-utils/src/tree/empty.ts new file mode 100644 index 0000000..fe2af56 --- /dev/null +++ b/packages/schematic-utils/src/tree/empty.ts @@ -0,0 +1,15 @@ +/** + * @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 { HostTree } from "./host-tree"; + +export class EmptyTree extends HostTree { + constructor() { + super(); + } +} diff --git a/packages/schematic-utils/src/tree/unit-test-tree.ts b/packages/schematic-utils/src/tree/unit-test-tree.ts new file mode 100644 index 0000000..ebd546e --- /dev/null +++ b/packages/schematic-utils/src/tree/unit-test-tree.ts @@ -0,0 +1,19 @@ +import { DelegateTree } from "./delegate"; + +export class UnitTestTree extends DelegateTree { + get files(): string[] { + const result: string[] = []; + this.visit((path) => result.push(path)); + + return result; + } + + readContent(path: string): string { + const buffer = this.read(path); + if (buffer === null) { + return ""; + } + + return buffer.toString(); + } +} diff --git a/packages/schematic-utils/src/utils/ast-utils.spec.ts b/packages/schematic-utils/src/utils/ast-utils.spec.ts new file mode 100644 index 0000000..9ea1095 --- /dev/null +++ b/packages/schematic-utils/src/utils/ast-utils.spec.ts @@ -0,0 +1,744 @@ +import { tags } from "@angular-devkit/core"; +import * as ts from "typescript"; +import { Change, InsertChange } from "./change"; +import { getFileContent } from "./get-file-content"; +import { + addDeclarationToModule, + addExportToModule, + addProviderToModule, + addRouteDeclarationToModule, + addSymbolToNgModuleMetadata, + findNodes, + insertAfterLastOccurrence, +} from "./ast-utils"; +import { HostTree } from "../tree/host-tree"; + +function getTsSource(path: string, content: string): ts.SourceFile { + return ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true); +} + +function applyChanges( + path: string, + content: string, + changes: Change[] +): string { + const tree = new HostTree(); + tree.create(path, content); + const exportRecorder = tree.beginUpdate(path); + for (const change of changes) { + if (change instanceof InsertChange) { + exportRecorder.insertLeft(change.pos, change.toAdd); + } + } + tree.commitUpdate(exportRecorder); + + return getFileContent(tree, path); +} + +describe("ast utils", () => { + let modulePath: string; + let moduleContent: string; + beforeEach(() => { + modulePath = "/src/app/app.module.ts"; + moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + }); + + it("should add export to module", () => { + const source = getTsSource(modulePath, moduleContent); + const changes = addExportToModule( + source, + modulePath, + "FooComponent", + "./foo.component" + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch(/exports: \[\n(\s*) {2}FooComponent\n\1\]/); + }); + + it("should add export to module if not indented", () => { + moduleContent = tags.stripIndents`${moduleContent}`; + const source = getTsSource(modulePath, moduleContent); + const changes = addExportToModule( + source, + modulePath, + "FooComponent", + "./foo.component" + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch(/exports: \[\n(\s*) {2}FooComponent\n\1\]/); + }); + + it("should add declarations to module if not indented", () => { + moduleContent = tags.stripIndents`${moduleContent}`; + const source = getTsSource(modulePath, moduleContent); + const changes = addDeclarationToModule( + source, + modulePath, + "FooComponent", + "./foo.component" + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch(/declarations: \[\nAppComponent,\nFooComponent\n\]/); + }); + + it("should add declarations to module when PropertyAssignment is StringLiteral", () => { + moduleContent = tags.stripIndents` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + "declarations": [ + AppComponent + ], + "imports": [ + BrowserModule + ], + "providers": [], + "bootstrap": [AppComponent] + })`; + const source = getTsSource(modulePath, moduleContent); + const changes = addDeclarationToModule( + source, + modulePath, + "FooComponent", + "./foo.component" + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch( + /"declarations": \[\nAppComponent,\nFooComponent\n\]/ + ); + }); + + it("should add metadata", () => { + const source = getTsSource(modulePath, moduleContent); + const changes = addSymbolToNgModuleMetadata( + source, + modulePath, + "imports", + "HelloWorld" + ); + expect(changes).not.toBeNull(); + + const output = applyChanges(modulePath, moduleContent, changes || []); + expect(output).toMatch(/imports: \[[^\]]+,\n(\s*) {2}HelloWorld\n\1\]/); + }); + + it("should add metadata (comma)", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + const source = getTsSource(modulePath, moduleContent); + const changes = addSymbolToNgModuleMetadata( + source, + modulePath, + "imports", + "HelloWorld" + ); + expect(changes).not.toBeNull(); + + const output = applyChanges(modulePath, moduleContent, changes || []); + expect(output).toMatch(/imports: \[[^\]]+,\n(\s*) {2}HelloWorld,\n\1\]/); + }); + + it("should add metadata (missing)", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + @NgModule({ + declarations: [ + AppComponent + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + const source = getTsSource(modulePath, moduleContent); + const changes = addSymbolToNgModuleMetadata( + source, + modulePath, + "imports", + "HelloWorld" + ); + expect(changes).not.toBeNull(); + + const output = applyChanges(modulePath, moduleContent, changes || []); + expect(output).toMatch(/imports: \[\n(\s*) {2}HelloWorld\n\1\]/); + }); + + it("should add metadata (empty)", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + @NgModule({ + declarations: [ + AppComponent + ], + providers: [], + imports: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + const source = getTsSource(modulePath, moduleContent); + const changes = addSymbolToNgModuleMetadata( + source, + modulePath, + "imports", + "HelloWorld" + ); + expect(changes).not.toBeNull(); + + const output = applyChanges(modulePath, moduleContent, changes || []); + expect(output).toMatch(/imports: \[\n(\s*) {2}HelloWorld\n\1\],\n/); + }); + + it(`should handle NgModule with newline after '@'`, () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + @ + NgModule({imports: [BrowserModule], declarations: []}) + export class AppModule { } + `; + const source = getTsSource(modulePath, moduleContent); + const changes = addExportToModule( + source, + modulePath, + "FooComponent", + "./foo.component" + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch(/exports: \[FooComponent\]/); + }); + + it("should handle NgModule with no newlines", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + @NgModule({imports: [BrowserModule], declarations: []}) + export class AppModule { } + `; + const source = getTsSource(modulePath, moduleContent); + const changes = addExportToModule( + source, + modulePath, + "FooComponent", + "./foo.component" + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch(/exports: \[FooComponent\]/); + }); + + it("should add into providers metadata in new line ", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + @NgModule({ + imports: [BrowserModule], + declarations: [], + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true + } + ] + }) + export class AppModule { } + `; + const source = getTsSource(modulePath, moduleContent); + const changes = addProviderToModule( + source, + modulePath, + "LogService", + "./log.service" + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { LogService } from '.\/log.service';/); + expect(output).toMatch(/\},\r?\n\s*LogService\r?\n\s*\]/); + }); + + describe("insertAfterLastOccurrence", () => { + const filePath = "./src/foo.ts"; + + it("should work for the default scenario", () => { + const fileContent = `const arr = ['foo'];`; + const source = getTsSource(filePath, fileContent); + const arrayNode = findNodes( + source.getChildren().shift() as ts.Node, + ts.SyntaxKind.ArrayLiteralExpression + ); + const elements = (arrayNode.pop() as ts.ArrayLiteralExpression).elements; + + const change = insertAfterLastOccurrence( + (elements as unknown) as ts.Node[], + `, 'bar'`, + filePath, + elements.pos, + ts.SyntaxKind.StringLiteral + ); + const output = applyChanges(filePath, fileContent, [change]); + + expect(output).toMatch(/const arr = \['foo', 'bar'\];/); + }); + + it("should work without occurrences", () => { + const fileContent = `const arr = [];`; + const source = getTsSource(filePath, fileContent); + const arrayNode = findNodes( + source.getChildren().shift() as ts.Node, + ts.SyntaxKind.ArrayLiteralExpression + ); + const elements = (arrayNode.pop() as ts.ArrayLiteralExpression).elements; + + const change = insertAfterLastOccurrence( + (elements as unknown) as ts.Node[], + `'bar'`, + filePath, + elements.pos, + ts.SyntaxKind.StringLiteral + ); + const output = applyChanges(filePath, fileContent, [change]); + + expect(output).toMatch(/const arr = \['bar'\];/); + }); + }); + + describe("addRouteDeclarationToModule", () => { + it("should throw an error when there is no router module", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [BrowserModule], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const change = () => addRouteDeclarationToModule(source, "./src/app", ""); + expect(change).toThrowError( + `Couldn't find a route declaration in ./src/app.` + ); + }); + + it(`should throw an error when router module doesn't have arguments`, () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot() + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const change = () => addRouteDeclarationToModule(source, "./src/app", ""); + expect(change).toThrowError( + `The router module method doesn't have arguments at line 11 in ./src/app` + ); + }); + + it(`should throw an error when the provided var (array) to router module doesn't exist`, () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot(routes) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const change = () => addRouteDeclarationToModule(source, "./src/app", ""); + expect(change).toThrowError( + `No route declaration array was found that corresponds to router module at line 11 in ./src/app` + ); + }); + + it(`should throw an error, if the provided first argument of router module is not an identifier`, () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot(42) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const change = () => addRouteDeclarationToModule(source, "./src/app", ""); + expect(change).toThrowError( + `No route declaration array was found that corresponds to router module at line 11 in ./src/app` + ); + }); + + it("should add a route to the routes array", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + const routes = []; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot(routes) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const changes = addRouteDeclarationToModule( + source, + "./src/app", + `{ path: 'foo', component: FooComponent }` + ); + const output = applyChanges(modulePath, moduleContent, [changes]); + + expect(output).toMatch( + /const routes = \[{ path: 'foo', component: FooComponent }\]/ + ); + }); + + it("should add a route to the routes array when there are multiple declarations", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + const routes = [ + { path: 'foo', component: FooComponent } + ]; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot(routes) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const changes = addRouteDeclarationToModule( + source, + "./src/app", + `{ path: 'bar', component: BarComponent }` + ); + const output = applyChanges(modulePath, moduleContent, [changes]); + + expect(output).toMatch( + /const routes = \[\r?\n?\s*{ path: 'foo', component: FooComponent },\r?\n?\s*{ path: 'bar', component: BarComponent }\r?\n?\s*\]/ + ); + }); + + it("should add a route before the last wildcard path", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + const routes = [ + { path: '**', component: FooComponent } + ]; + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot(routes) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const changes = addRouteDeclarationToModule( + source, + "./src/app", + `{ path: 'bar', component: BarComponent }` + ); + const output = applyChanges(modulePath, moduleContent, [changes]); + + expect(output).toMatch( + /const routes = \[\r?\n?\s*{ path: 'bar', component: BarComponent },\r?\n?\s*{ path: '\*\*', component: FooComponent }\r?\n?\s*\]/ + ); + }); + + it("should add a route to the routes to the correct array when having guards", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + const routes = [ + { path: 'foo', component: FooComponent, canLoad: [Guard] } + ]; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot(routes) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const changes = addRouteDeclarationToModule( + source, + "./src/app", + `{ path: 'bar', component: BarComponent }` + ); + const output = applyChanges(modulePath, moduleContent, [changes]); + /* eslint-disable max-len */ + expect(output).toMatch( + /const routes = \[\r?\n?\s*{ path: 'foo', component: FooComponent, canLoad: \[Guard\] },\r?\n?\s*{ path: 'bar', component: BarComponent }\r?\n?\s*\]/ + ); + /* eslint-enable max-len */ + }); + + it("should add a route to the routes to the correct array when having nested object literal", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + const routes = [ + { path: 'foo', component: FooComponent, data: { path: 'test' }} + ]; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot(routes) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const changes = addRouteDeclarationToModule( + source, + "./src/app", + `{ path: 'bar', component: BarComponent }` + ); + const output = applyChanges(modulePath, moduleContent, [changes]); + expect(output).toMatch( + // eslint-disable-next-line max-len + /const routes = \[\r?\n?\s*{ path: 'foo', component: FooComponent, data: { path: 'test' }},\r?\n?\s*{ path: 'bar', component: BarComponent }\r?\n?\s*\]/ + ); + }); + + it("should add a route to the routes argument of RouteModule", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot([]) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const changes = addRouteDeclarationToModule( + source, + "./src/app", + `{ path: 'foo', component: FooComponent }` + ); + const output = applyChanges(modulePath, moduleContent, [changes]); + + expect(output).toMatch( + /RouterModule\.forRoot\(\[{ path: 'foo', component: FooComponent }\]\)/ + ); + }); + + it("should add a route to the routes argument of RouterModule when there are multiple declarations", () => { + const moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot([{ path: 'foo', component: FooComponent }]) + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + + const source = getTsSource(modulePath, moduleContent); + const changes = addRouteDeclarationToModule( + source, + "./src/app", + `{ path: 'bar', component: BarComponent }` + ); + const output = applyChanges(modulePath, moduleContent, [changes]); + + expect(output).toMatch( + // eslint-disable-next-line max-len + /RouterModule\.forRoot\(\[\r?\n?\s*{ path: 'foo', component: FooComponent },\r?\n?\s*{ path: 'bar', component: BarComponent }\r?\n?\s*\]\)/ + ); + }); + }); + + describe("findNodes", () => { + const filePath = "./src/foo.ts"; + const fileContent = ` + const a = { + nodeAtDepth0: { + nodeAtDepth1: { + nodeAtDepth2: { nodeAtDepth3: 'foo' }, + }, + }, + }; + `; + + let recursive: boolean; + + describe("when `recursive` is not set", () => { + beforeEach(() => { + recursive = false; + }); + + it("should return node excluding nested nodes", () => { + const source = getTsSource(filePath, fileContent); + const paNodes = findNodes( + source, + ts.SyntaxKind.PropertyAssignment, + Infinity, + recursive + ); + + expect(paNodes.length).toEqual(1); + }); + }); + + describe("when `recursive` is set", () => { + beforeEach(() => { + recursive = true; + }); + + it("should return node including all nested nodes", () => { + const source = getTsSource(filePath, fileContent); + const paNodes = findNodes( + source, + ts.SyntaxKind.PropertyAssignment, + Infinity, + recursive + ); + + expect(paNodes.length).toEqual(4); + }); + }); + }); +}); diff --git a/packages/schematic-utils/src/utils/ast-utils.ts b/packages/schematic-utils/src/utils/ast-utils.ts new file mode 100644 index 0000000..6f80494 --- /dev/null +++ b/packages/schematic-utils/src/utils/ast-utils.ts @@ -0,0 +1,792 @@ +import { tags } from "@angular-devkit/core"; +import * as ts from "typescript"; +import { Change, InsertChange, NoopChange } from "./change"; + +/** + * Add Import `import { symbolName } from fileName` if the import doesn't exit + * already. Assumes fileToEdit can be resolved and accessed. + * @param fileToEdit (file we want to add import to) + * @param symbolName (item to import) + * @param fileName (path to the file) + * @param isDefault (if true, import follows style for importing default exports) + * @return Change + */ +export function insertImport( + source: ts.SourceFile, + fileToEdit: string, + symbolName: string, + fileName: string, + isDefault = false +): Change { + const rootNode = source; + const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + + // get nodes that map to import statements from the file fileName + const relevantImports = allImports.filter((node) => { + // StringLiteral of the ImportDeclaration is the import file (fileName in this case). + const importFiles = node + .getChildren() + .filter(ts.isStringLiteral) + .map((n) => n.text); + + return importFiles.filter((file) => file === fileName).length === 1; + }); + + if (relevantImports.length > 0) { + let importsAsterisk = false; + // imports from import file + const imports: ts.Node[] = []; + relevantImports.forEach((n) => { + Array.prototype.push.apply( + imports, + findNodes(n, ts.SyntaxKind.Identifier) + ); + if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { + importsAsterisk = true; + } + }); + + // if imports * from fileName, don't add symbolName + if (importsAsterisk) { + return new NoopChange(); + } + + const importTextNodes = imports.filter( + (n) => (n as ts.Identifier).text === symbolName + ); + + // insert import if it's not there + if (importTextNodes.length === 0) { + const fallbackPos = + findNodes( + relevantImports[0], + ts.SyntaxKind.CloseBraceToken + )[0].getStart() || + findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart(); + + return insertAfterLastOccurrence( + imports, + `, ${symbolName}`, + fileToEdit, + fallbackPos + ); + } + + return new NoopChange(); + } + + // no such import declaration exists + const useStrict = findNodes(rootNode, ts.isStringLiteral).filter( + (n) => n.text === "use strict" + ); + let fallbackPos = 0; + if (useStrict.length > 0) { + fallbackPos = useStrict[0].end; + } + const open = isDefault ? "" : "{ "; + const close = isDefault ? "" : " }"; + // if there are no imports or 'use strict' statement, insert import at beginning of file + const insertAtBeginning = allImports.length === 0 && useStrict.length === 0; + const separator = insertAtBeginning ? "" : ";\n"; + const toInsert = + `${separator}import ${open}${symbolName}${close}` + + ` from '${fileName}'${insertAtBeginning ? ";\n" : ""}`; + + return insertAfterLastOccurrence( + allImports, + toInsert, + fileToEdit, + fallbackPos, + ts.SyntaxKind.StringLiteral + ); +} + +/** + * Find all nodes from the AST in the subtree of node of SyntaxKind kind. + * @param node + * @param kind + * @param max The maximum number of items to return. + * @param recursive Continue looking for nodes of kind recursive until end + * the last child even when node of kind has been found. + * @return all nodes of kind, or [] if none is found + */ +export function findNodes( + node: ts.Node, + kind: ts.SyntaxKind, + max?: number, + recursive?: boolean +): ts.Node[]; + +/** + * Find all nodes from the AST in the subtree that satisfy a type guard. + * @param node + * @param guard + * @param max The maximum number of items to return. + * @param recursive Continue looking for nodes of kind recursive until end + * the last child even when node of kind has been found. + * @return all nodes that satisfy the type guard, or [] if none is found + */ +export function findNodes( + node: ts.Node, + guard: (node: ts.Node) => node is T, + max?: number, + recursive?: boolean +): T[]; + +export function findNodes( + node: ts.Node, + kindOrGuard: ts.SyntaxKind | ((node: ts.Node) => node is T), + max = Infinity, + recursive = false +): T[] { + if (!node || max == 0) { + return []; + } + + const test = + typeof kindOrGuard === "function" + ? kindOrGuard + : (node: ts.Node): node is T => node.kind === kindOrGuard; + + const arr: T[] = []; + if (test(node)) { + arr.push(node); + max--; + } + if (max > 0 && (recursive || !test(node))) { + for (const child of node.getChildren()) { + findNodes(child, test, max, recursive).forEach((node) => { + if (max > 0) { + arr.push(node); + } + max--; + }); + + if (max <= 0) { + break; + } + } + } + + return arr; +} + +/** + * Get all the nodes from a source. + * @param sourceFile The source file object. + * @returns {Array} An array of all the nodes in the source. + */ +export function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] { + const nodes: ts.Node[] = [sourceFile]; + const result = []; + + while (nodes.length > 0) { + const node = nodes.shift(); + + if (node) { + result.push(node); + if (node.getChildCount(sourceFile) >= 0) { + nodes.unshift(...node.getChildren()); + } + } + } + + return result; +} + +export function findNode( + node: ts.Node, + kind: ts.SyntaxKind, + text: string +): ts.Node | null { + if (node.kind === kind && node.getText() === text) { + // throw new Error(node.getText()); + return node; + } + + let foundNode: ts.Node | null = null; + ts.forEachChild(node, (childNode) => { + foundNode = foundNode || findNode(childNode, kind, text); + }); + + return foundNode; +} + +/** + * Helper for sorting nodes. + * @return function to sort nodes in increasing order of position in sourceFile + */ +function nodesByPosition(first: ts.Node, second: ts.Node): number { + return first.getStart() - second.getStart(); +} + +/** + * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` + * or after the last of occurence of `syntaxKind` if the last occurence is a sub child + * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. + * + * @param nodes insert after the last occurence of nodes + * @param toInsert string to insert + * @param file file to insert changes into + * @param fallbackPos position to insert if toInsert happens to be the first occurence + * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after + * @return Change instance + * @throw Error if toInsert is first occurence but fall back is not set + */ +export function insertAfterLastOccurrence( + nodes: ts.Node[], + toInsert: string, + file: string, + fallbackPos: number, + syntaxKind?: ts.SyntaxKind +): Change { + let lastItem: ts.Node | undefined; + for (const node of nodes) { + if (!lastItem || lastItem.getStart() < node.getStart()) { + lastItem = node; + } + } + if (syntaxKind && lastItem) { + lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop(); + } + if (!lastItem && fallbackPos == undefined) { + throw new Error( + `tried to insert ${toInsert} as first occurence with no fallback position` + ); + } + const lastItemPosition: number = lastItem ? lastItem.getEnd() : fallbackPos; + + return new InsertChange(file, lastItemPosition, toInsert); +} + +function _angularImportsFromNode( + node: ts.ImportDeclaration +): { [name: string]: string } { + const ms = node.moduleSpecifier; + let modulePath: string; + switch (ms.kind) { + case ts.SyntaxKind.StringLiteral: + modulePath = (ms as ts.StringLiteral).text; + break; + default: + return {}; + } + + if (!modulePath.startsWith("@angular/")) { + return {}; + } + + if (node.importClause) { + if (node.importClause.name) { + // This is of the form `import Name from 'path'`. Ignore. + return {}; + } else if (node.importClause.namedBindings) { + const nb = node.importClause.namedBindings; + if (nb.kind == ts.SyntaxKind.NamespaceImport) { + // This is of the form `import * as name from 'path'`. Return `name.`. + return { + [nb.name.text + "."]: modulePath, + }; + } else { + // This is of the form `import {a,b,c} from 'path'` + const namedImports = nb; + + return namedImports.elements + .map((is: ts.ImportSpecifier) => + is.propertyName ? is.propertyName.text : is.name.text + ) + .reduce((acc: { [name: string]: string }, curr: string) => { + acc[curr] = modulePath; + + return acc; + }, {}); + } + } + + return {}; + } else { + // This is of the form `import 'path';`. Nothing to do. + return {}; + } +} + +export function getDecoratorMetadata( + source: ts.SourceFile, + identifier: string, + module: string +): ts.Node[] { + const angularImports = findNodes(source, ts.isImportDeclaration) + .map((node) => _angularImportsFromNode(node)) + .reduce((acc, current) => { + for (const key of Object.keys(current)) { + acc[key] = current[key]; + } + + return acc; + }, {}); + + return getSourceNodes(source) + .filter((node) => { + return ( + node.kind == ts.SyntaxKind.Decorator && + (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression + ); + }) + .map((node) => (node as ts.Decorator).expression as ts.CallExpression) + .filter((expr) => { + if (expr.expression.kind == ts.SyntaxKind.Identifier) { + const id = expr.expression as ts.Identifier; + + return id.text == identifier && angularImports[id.text] === module; + } else if ( + expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression + ) { + // This covers foo.NgModule when importing * as foo. + const paExpr = expr.expression as ts.PropertyAccessExpression; + // If the left expression is not an identifier, just give up at that point. + if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { + return false; + } + + const id = paExpr.name.text; + const moduleId = (paExpr.expression as ts.Identifier).text; + + return id === identifier && angularImports[moduleId + "."] === module; + } + + return false; + }) + .filter( + (expr) => + expr.arguments[0] && + expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression + ) + .map((expr) => expr.arguments[0] as ts.ObjectLiteralExpression); +} + +export function getMetadataField( + node: ts.ObjectLiteralExpression, + metadataField: string +): ts.ObjectLiteralElement[] { + return ( + node.properties + .filter(ts.isPropertyAssignment) + // Filter out every fields that's not "metadataField". Also handles string literals + // (but not expressions). + .filter(({ name }) => { + return ( + (ts.isIdentifier(name) || ts.isStringLiteral(name)) && + name.text === metadataField + ); + }) + ); +} + +export function addSymbolToNgModuleMetadata( + source: ts.SourceFile, + ngModulePath: string, + metadataField: string, + symbolName: string, + importPath: string | null = null +): Change[] { + const nodes = getDecoratorMetadata(source, "NgModule", "@angular/core"); + let node: any = nodes[0]; // eslint-disable-line @typescript-eslint/no-explicit-any + + // Find the decorator declaration. + if (!node) { + return []; + } + + // Get all the children property assignment of object literals. + const matchingProperties = getMetadataField( + node as ts.ObjectLiteralExpression, + metadataField + ); + + if (matchingProperties.length == 0) { + // We haven't found the field in the metadata declaration. Insert a new field. + const expr = node as ts.ObjectLiteralExpression; + let position: number; + let toInsert: string; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = `\n ${metadataField}: [\n${tags.indentBy( + 4 + )`${symbolName}`}\n ]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^(\r?\n)(\s*)/); + if (matches) { + toInsert = + `,${matches[0]}${metadataField}: [${matches[1]}` + + `${tags.indentBy(matches[2].length + 2)`${symbolName}`}${ + matches[0] + }]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + if (importPath !== null) { + return [ + new InsertChange(ngModulePath, position, toInsert), + insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ""), + importPath + ), + ]; + } else { + return [new InsertChange(ngModulePath, position, toInsert)]; + } + } + const assignment = matchingProperties[0] as ts.PropertyAssignment; + + // If it's not an array, nothing we can do really. + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return []; + } + + const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression; + if (arrLiteral.elements.length == 0) { + // Forward the property. + node = arrLiteral; + } else { + node = arrLiteral.elements; + } + + if (Array.isArray(node)) { + const nodeArray = (node as unknown) as Array; + const symbolsArray = nodeArray.map( + (node) => tags.oneLine`${node.getText()}` + ); + if (symbolsArray.includes(tags.oneLine`${symbolName}`)) { + return []; + } + + node = node[node.length - 1]; + } + + let toInsert: string; + let position = node.getEnd(); + if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { + // We found the field but it's empty. Insert it just before the `]`. + position--; + toInsert = `\n${tags.indentBy(4)`${symbolName}`}\n `; + } else { + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^(\r?\n)(\s*)/); + if (matches) { + toInsert = `,${matches[1]}${tags.indentBy( + matches[2].length + )`${symbolName}`}`; + } else { + toInsert = `, ${symbolName}`; + } + } + if (importPath !== null) { + return [ + new InsertChange(ngModulePath, position, toInsert), + insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ""), + importPath + ), + ]; + } + + return [new InsertChange(ngModulePath, position, toInsert)]; +} + +/** + * Custom function to insert a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +export function addDeclarationToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return addSymbolToNgModuleMetadata( + source, + modulePath, + "declarations", + classifiedName, + importPath + ); +} + +/** + * Custom function to insert an NgModule into NgModule imports. It also imports the module. + */ +export function addImportToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return addSymbolToNgModuleMetadata( + source, + modulePath, + "imports", + classifiedName, + importPath + ); +} + +/** + * Custom function to insert a provider into NgModule. It also imports it. + */ +export function addProviderToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return addSymbolToNgModuleMetadata( + source, + modulePath, + "providers", + classifiedName, + importPath + ); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addExportToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return addSymbolToNgModuleMetadata( + source, + modulePath, + "exports", + classifiedName, + importPath + ); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addBootstrapToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return addSymbolToNgModuleMetadata( + source, + modulePath, + "bootstrap", + classifiedName, + importPath + ); +} + +/** + * Determine if an import already exists. + */ +export function isImported( + source: ts.SourceFile, + classifiedName: string, + importPath: string +): boolean { + const allNodes = getSourceNodes(source); + const matchingNodes = allNodes + .filter(ts.isImportDeclaration) + .filter( + (imp) => + ts.isStringLiteral(imp.moduleSpecifier) && + imp.moduleSpecifier.text === importPath + ) + .filter((imp) => { + if (!imp.importClause) { + return false; + } + const nodes = findNodes(imp.importClause, ts.isImportSpecifier).filter( + (n) => n.getText() === classifiedName + ); + + return nodes.length > 0; + }); + + return matchingNodes.length > 0; +} + +/** + * This function returns the name of the environment export + * whether this export is aliased or not. If the environment file + * is not imported, then it will return `null`. + */ +export function getEnvironmentExportName(source: ts.SourceFile): string | null { + // Initial value is `null` as we don't know yet if the user + // has imported `environment` into the root module or not. + let environmentExportName: string | null = null; + + const allNodes = getSourceNodes(source); + + allNodes + .filter(ts.isImportDeclaration) + .filter( + (declaration) => + declaration.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral && + declaration.importClause !== undefined + ) + .map((declaration) => + // If `importClause` property is defined then the first + // child will be `NamedImports` object (or `namedBindings`). + (declaration.importClause as ts.ImportClause).getChildAt(0) + ) + // Find those `NamedImports` object that contains `environment` keyword + // in its text. E.g. `{ environment as env }`. + .filter(ts.isNamedImports) + .filter((namedImports) => namedImports.getText().includes("environment")) + .forEach((namedImports) => { + for (const specifier of namedImports.elements) { + // `propertyName` is defined if the specifier + // has an aliased import. + const name = specifier.propertyName || specifier.name; + + // Find specifier that contains `environment` keyword in its text. + // Whether it's `environment` or `environment as env`. + if (name.text.includes("environment")) { + environmentExportName = specifier.name.text; + } + } + }); + + return environmentExportName; +} + +/** + * Returns the RouterModule declaration from NgModule metadata, if any. + */ +export function getRouterModuleDeclaration( + source: ts.SourceFile +): ts.Expression | undefined { + const result = getDecoratorMetadata(source, "NgModule", "@angular/core"); + const node = result[0] as ts.ObjectLiteralExpression; + const matchingProperties = getMetadataField(node, "imports"); + + if (!matchingProperties) { + return; + } + + const assignment = matchingProperties[0] as ts.PropertyAssignment; + + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return; + } + + const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression; + + return arrLiteral.elements + .filter((el) => el.kind === ts.SyntaxKind.CallExpression) + .find((el) => (el as ts.Identifier).getText().startsWith("RouterModule")); +} + +/** + * Adds a new route declaration to a router module (i.e. has a RouterModule declaration) + */ +export function addRouteDeclarationToModule( + source: ts.SourceFile, + fileToAdd: string, + routeLiteral: string +): Change { + const routerModuleExpr = getRouterModuleDeclaration(source); + if (!routerModuleExpr) { + throw new Error(`Couldn't find a route declaration in ${fileToAdd}.`); + } + const scopeConfigMethodArgs = (routerModuleExpr as ts.CallExpression) + .arguments; + if (!scopeConfigMethodArgs.length) { + const { line } = source.getLineAndCharacterOfPosition( + routerModuleExpr.getStart() + ); + throw new Error( + `The router module method doesn't have arguments ` + + `at line ${line} in ${fileToAdd}` + ); + } + + let routesArr: ts.ArrayLiteralExpression | undefined; + const routesArg = scopeConfigMethodArgs[0]; + + // Check if the route declarations array is + // an inlined argument of RouterModule or a standalone variable + if (ts.isArrayLiteralExpression(routesArg)) { + routesArr = routesArg; + } else { + const routesVarName = routesArg.getText(); + let routesVar; + if (routesArg.kind === ts.SyntaxKind.Identifier) { + routesVar = source.statements.filter(ts.isVariableStatement).find((v) => { + return ( + v.declarationList.declarations[0].name.getText() === routesVarName + ); + }); + } + + if (!routesVar) { + const { line } = source.getLineAndCharacterOfPosition( + routesArg.getStart() + ); + throw new Error( + `No route declaration array was found that corresponds ` + + `to router module at line ${line} in ${fileToAdd}` + ); + } + + routesArr = findNodes( + routesVar, + ts.SyntaxKind.ArrayLiteralExpression, + 1 + )[0] as ts.ArrayLiteralExpression; + } + + const occurrencesCount = routesArr.elements.length; + const text = routesArr.getFullText(source); + + let route: string = routeLiteral; + let insertPos = routesArr.elements.pos; + + if (occurrencesCount > 0) { + const lastRouteLiteral = [...routesArr.elements].pop() as ts.Expression; + const lastRouteIsWildcard = + ts.isObjectLiteralExpression(lastRouteLiteral) && + lastRouteLiteral.properties.some( + (n) => + ts.isPropertyAssignment(n) && + ts.isIdentifier(n.name) && + n.name.text === "path" && + ts.isStringLiteral(n.initializer) && + n.initializer.text === "**" + ); + + const indentation = text.match(/\r?\n(\r?)\s*/) || []; + const routeText = `${indentation[0] || " "}${routeLiteral}`; + + // Add the new route before the wildcard route + // otherwise we'll always redirect to the wildcard route + if (lastRouteIsWildcard) { + insertPos = lastRouteLiteral.pos; + route = `${routeText},`; + } else { + insertPos = lastRouteLiteral.end; + route = `,${routeText}`; + } + } + + return new InsertChange(fileToAdd, insertPos, route); +} diff --git a/packages/schematic-utils/src/utils/change.ts b/packages/schematic-utils/src/utils/change.ts new file mode 100644 index 0000000..ca00060 --- /dev/null +++ b/packages/schematic-utils/src/utils/change.ts @@ -0,0 +1,151 @@ +import { UpdateRecorder } from "../tree/interface"; + +export interface Host { + write(path: string, content: string): Promise; + + read(path: string): Promise; +} + +export interface Change { + apply(host: Host): Promise; + + // The file this change should be applied to. Some changes might not apply to + // a file (maybe the config). + readonly path: string | null; + + // The order this change should be applied. Normally the position inside the file. + // Changes are applied from the bottom of a file to the top. + readonly order: number; + + // The description of this change. This will be outputted in a dry or verbose run. + readonly description: string; +} + +/** + * An operation that does nothing. + */ +export class NoopChange implements Change { + description = "No operation."; + order = Infinity; + path = null; + + apply(): Promise { + return Promise.resolve(); + } +} + +/** + * Will add text to the source code. + */ +export class InsertChange implements Change { + order: number; + description: string; + + constructor(public path: string, public pos: number, public toAdd: string) { + if (pos < 0) { + throw new Error("Negative positions are invalid"); + } + this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; + this.order = pos; + } + + /** + * This method does not insert spaces if there is none in the original string. + */ + apply(host: Host): Promise { + return host.read(this.path).then((content) => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos); + + return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); + }); + } +} + +/** + * Will remove text from the source code. + */ +export class RemoveChange implements Change { + order: number; + description: string; + + constructor( + public path: string, + private pos: number, + public toRemove: string + ) { + if (pos < 0) { + throw new Error("Negative positions are invalid"); + } + this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then((content) => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.toRemove.length); + + // TODO: throw error if toRemove doesn't match removed string. + return host.write(this.path, `${prefix}${suffix}`); + }); + } +} + +/** + * Will replace text from the source code. + */ +export class ReplaceChange implements Change { + order: number; + description: string; + + constructor( + public path: string, + private pos: number, + public oldText: string, + public newText: string + ) { + if (pos < 0) { + throw new Error("Negative positions are invalid"); + } + this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then((content) => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.oldText.length); + const text = content.substring(this.pos, this.pos + this.oldText.length); + + if (text !== this.oldText) { + return Promise.reject( + new Error(`Invalid replace: "${text}" != "${this.oldText}".`) + ); + } + + // TODO: throw error if oldText doesn't match removed string. + return host.write(this.path, `${prefix}${this.newText}${suffix}`); + }); + } +} + +export function applyToUpdateRecorder( + recorder: UpdateRecorder, + changes: Change[] +): void { + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.order, change.toRemove.length); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.order, change.oldText.length); + recorder.insertLeft(change.order, change.newText); + } else if (!(change instanceof NoopChange)) { + throw new Error( + "Unknown Change type encountered when updating a recorder." + ); + } + } +} diff --git a/packages/schematic-utils/src/utils/create-app-module.ts b/packages/schematic-utils/src/utils/create-app-module.ts new file mode 100644 index 0000000..0e7d783 --- /dev/null +++ b/packages/schematic-utils/src/utils/create-app-module.ts @@ -0,0 +1,28 @@ +import { UnitTestTree } from "@angular-devkit/schematics/testing"; + +export function createAppModule( + tree: UnitTestTree, + path?: string +): UnitTestTree { + tree.create( + path || "/src/app/app.module.ts", + ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + ` + ); + + return tree; +} diff --git a/packages/schematic-utils/src/utils/dependencies.spec.ts b/packages/schematic-utils/src/utils/dependencies.spec.ts new file mode 100644 index 0000000..6707701 --- /dev/null +++ b/packages/schematic-utils/src/utils/dependencies.spec.ts @@ -0,0 +1,95 @@ +import { + addPackageJsonDependency, + getPackageJsonDependency, + NodeDependency, + NodeDependencyType, +} from "./dependencies"; +import { UnitTestTree } from "../tree/unit-test-tree"; +import { EmptyTree } from "../tree/empty"; + +describe("dependencies", () => { + describe("addDependency", () => { + let tree: UnitTestTree; + const pkgJsonPath = "/package.json"; + let dependency: NodeDependency; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + tree.create(pkgJsonPath, "{}"); + + dependency = { + type: NodeDependencyType.Default, + name: "my-pkg", + version: "1.2.3", + }; + }); + + [ + { type: NodeDependencyType.Default, key: "dependencies" }, + { type: NodeDependencyType.Dev, key: "devDependencies" }, + { type: NodeDependencyType.Optional, key: "optionalDependencies" }, + { type: NodeDependencyType.Peer, key: "peerDependencies" }, + ].forEach((type) => { + describe(`Type: ${type.toString()}`, () => { + beforeEach(() => { + dependency.type = type.type; + }); + + it("should add a dependency", () => { + addPackageJsonDependency(tree, dependency); + const pkgJson = JSON.parse(tree.readContent(pkgJsonPath)); + expect(pkgJson[type.key][dependency.name]).toEqual( + dependency.version + ); + }); + + it("should handle an existing dependency (update version)", () => { + addPackageJsonDependency(tree, { ...dependency, version: "0.0.0" }); + addPackageJsonDependency(tree, { ...dependency, overwrite: true }); + const pkgJson = JSON.parse(tree.readContent(pkgJsonPath)); + expect(pkgJson[type.key][dependency.name]).toEqual( + dependency.version + ); + }); + }); + }); + + it("should throw when missing package.json", () => { + expect(() => + addPackageJsonDependency(new EmptyTree(), dependency) + ).toThrow(); + }); + }); + + describe("getDependency", () => { + let tree: UnitTestTree; + beforeEach(() => { + const pkgJsonPath = "/package.json"; + const pkgJsonContent = JSON.stringify( + { + dependencies: { + "my-pkg": "1.2.3", + }, + }, + null, + 2 + ); + tree = new UnitTestTree(new EmptyTree()); + tree.create(pkgJsonPath, pkgJsonContent); + }); + + it("should get a dependency", () => { + const dep = getPackageJsonDependency(tree, "my-pkg") as NodeDependency; + expect(dep.type).toEqual(NodeDependencyType.Default); + expect(dep.name).toEqual("my-pkg"); + expect(dep.version).toEqual("1.2.3"); + }); + + it("should return null if dependency does not exist", () => { + const dep = getPackageJsonDependency( + tree, + "missing-pkg" + ) as NodeDependency; + expect(dep).toBeNull(); + }); + }); +}); diff --git a/packages/schematic-utils/src/utils/dependencies.ts b/packages/schematic-utils/src/utils/dependencies.ts new file mode 100644 index 0000000..1ee837e --- /dev/null +++ b/packages/schematic-utils/src/utils/dependencies.ts @@ -0,0 +1,72 @@ +import { JSONFile } from "./json-file"; +import { Tree } from "../tree/interface"; + +const PKG_JSON_PATH = "/package.json"; +export enum NodeDependencyType { + Default = "dependencies", + Dev = "devDependencies", + Peer = "peerDependencies", + Optional = "optionalDependencies", +} + +export interface NodeDependency { + type: NodeDependencyType; + name: string; + version: string; + overwrite?: boolean; +} + +const ALL_DEPENDENCY_TYPE = [ + NodeDependencyType.Default, + NodeDependencyType.Dev, + NodeDependencyType.Optional, + NodeDependencyType.Peer, +]; + +export function addPackageJsonDependency( + tree: Tree, + dependency: NodeDependency, + pkgJsonPath = PKG_JSON_PATH +): void { + const json = new JSONFile(tree, pkgJsonPath); + + const { overwrite, type, name, version } = dependency; + const path = [type, name]; + if (overwrite || !json.get(path)) { + json.modify(path, version); + } +} + +export function removePackageJsonDependency( + tree: Tree, + name: string, + pkgJsonPath = PKG_JSON_PATH +): void { + const json = new JSONFile(tree, pkgJsonPath); + + for (const depType of ALL_DEPENDENCY_TYPE) { + json.remove([depType, name]); + } +} + +export function getPackageJsonDependency( + tree: Tree, + name: string, + pkgJsonPath = PKG_JSON_PATH +): NodeDependency | null { + const json = new JSONFile(tree, pkgJsonPath); + + for (const depType of ALL_DEPENDENCY_TYPE) { + const version = json.get([depType, name]); + + if (typeof version === "string") { + return { + type: depType, + name: name, + version, + }; + } + } + + return null; +} diff --git a/packages/schematic-utils/src/utils/find-module.spec.ts b/packages/schematic-utils/src/utils/find-module.spec.ts new file mode 100644 index 0000000..4486382 --- /dev/null +++ b/packages/schematic-utils/src/utils/find-module.spec.ts @@ -0,0 +1,227 @@ +import { Path } from "@angular-devkit/core"; +import { + ModuleOptions, + buildRelativePath, + findModule, + findModuleFromOptions, +} from "./find-module"; +import { Tree } from "../tree/interface"; +import { EmptyTree } from "../tree/empty"; + +describe("find-module", () => { + describe("findModule", () => { + let host: Tree; + const modulePath = "/foo/src/app/app.module.ts"; + beforeEach(() => { + host = new EmptyTree(); + host.create(modulePath, "app module"); + }); + + it("should find a module", () => { + const foundModule = findModule(host, "foo/src/app/bar"); + expect(foundModule).toEqual(modulePath); + }); + + it("should not find a module in another sub dir", () => { + host.create("/foo/src/app/buzz/buzz.module.ts", "app module"); + const foundModule = findModule(host, "foo/src/app/bar"); + expect(foundModule).toEqual(modulePath); + }); + + it("should ignore routing modules", () => { + host.create("/foo/src/app/app-routing.module.ts", "app module"); + const foundModule = findModule(host, "foo/src/app/bar"); + expect(foundModule).toEqual(modulePath); + }); + + it("should work with weird paths", () => { + host.create("/foo/src/app/app-routing.module.ts", "app module"); + const foundModule = findModule(host, "foo//src//app/bar/"); + expect(foundModule).toEqual(modulePath); + }); + + it("should throw if no modules found", () => { + host.create("/foo/src/app/oops.module.ts", "app module"); + try { + findModule(host, "foo/src/app/bar"); + throw new Error("Succeeded, should have failed"); + } catch (err) { + expect(err.message).toMatch(/More than one module matches/); + } + }); + + it("should throw if only routing modules were found", () => { + host = new EmptyTree(); + host.create( + "/foo/src/app/anything-routing.module.ts", + "anything routing module" + ); + + try { + findModule(host, "foo/src/app/anything-routing"); + throw new Error("Succeeded, should have failed"); + } catch (err) { + expect(err.message).toMatch(/Could not find a non Routing NgModule/); + } + }); + + it("should throw if two modules found", () => { + try { + host = new EmptyTree(); + findModule(host, "foo/src/app/bar"); + throw new Error("Succeeded, should have failed"); + } catch (err) { + expect(err.message).toMatch(/Could not find an NgModule/); + } + }); + + it("should accept custom ext for module", () => { + const host = new EmptyTree(); + const modulePath = "/foo/src/app/app_module.ts"; + host.create(modulePath, "app module"); + // Should find module if given a custom ext + const foundModule = findModule(host, "foo/src/app/bar", "_module.ts"); + expect(foundModule).toBe(modulePath); + // Should not find module if using default ext + expect(() => findModule(host, "foo/src/app/bar")).toThrowError( + /Could not find an NgModule/ + ); + }); + + it("should not find module if ext is invalid", () => { + expect(() => + findModule(host, "foo/src/app/bar", "-module.ts") + ).toThrowError(/Could not find an NgModule/); + expect(() => + findModule(host, "foo/src/app/bar", "_module.ts") + ).toThrowError(/Could not find an NgModule/); + }); + }); + + describe("findModuleFromOptions", () => { + let tree: Tree; + let options: ModuleOptions; + beforeEach(() => { + tree = new EmptyTree(); + options = { name: "foo" }; + }); + + it("should find a module", () => { + tree.create("/projects/my-proj/src/app.module.ts", ""); + options.module = "app.module.ts"; + options.path = "/projects/my-proj/src"; + const modPath = findModuleFromOptions(tree, options); + expect(modPath).toEqual("/projects/my-proj/src/app.module.ts" as Path); + }); + + it("should find a module when name has underscore", () => { + tree.create( + "/projects/my-proj/src/feature_module/app_test.module.ts", + "" + ); + options.path = "/projects/my-proj/src"; + options.name = "feature_module/new_component"; + const modPath = findModuleFromOptions(tree, options); + expect(modPath).toEqual( + "/projects/my-proj/src/feature_module/app_test.module.ts" as Path + ); + }); + + it("should find a module when name has uppercase", () => { + tree.create("/projects/my-proj/src/featureModule/appTest.module.ts", ""); + options.path = "/projects/my-proj/src"; + options.name = "featureModule/newComponent"; + const modPath = findModuleFromOptions(tree, options); + expect(modPath).toEqual( + "/projects/my-proj/src/featureModule/appTest.module.ts" as Path + ); + }); + + it("should find a module if flat is true", () => { + tree.create("/projects/my-proj/src/module/app_test.module.ts", ""); + options.path = "/projects/my-proj/src"; + options.flat = true; + options.name = "/module/directive"; + const modPath = findModuleFromOptions(tree, options); + expect(modPath).toEqual( + "/projects/my-proj/src/module/app_test.module.ts" as Path + ); + }); + + it("should find a module in a sub dir", () => { + tree.create("/projects/my-proj/src/admin/foo.module.ts", ""); + options.name = "other/test"; + options.module = "admin/foo"; + options.path = "/projects/my-proj/src"; + const modPath = findModuleFromOptions(tree, options); + expect(modPath).toEqual( + "/projects/my-proj/src/admin/foo.module.ts" as Path + ); + }); + + it("should find a module in a sub dir (2)", () => { + tree.create("/projects/my-proj/src/admin/foo.module.ts", ""); + options.name = "admin/hello"; + options.module = "foo"; + options.path = "/projects/my-proj/src"; + const modPath = findModuleFromOptions(tree, options); + expect(modPath).toEqual( + "/projects/my-proj/src/admin/foo.module.ts" as Path + ); + }); + + it("should find a module using custom ext", () => { + tree.create("/projects/my-proj/src/app_module.ts", ""); + options.module = "app"; + options.path = "/projects/my-proj/src"; + options.moduleExt = "_module.ts"; + // Should find module using custom moduleExt + const modPath = findModuleFromOptions(tree, options); + expect(modPath).toBe("/projects/my-proj/src/app_module.ts" as Path); + // Should not find module if using invalid ext + options.moduleExt = "-module.ts"; + expect(() => findModuleFromOptions(tree, options)).toThrowError( + /Specified module 'app' does not exist/ + ); + // Should not find module if using default ext + options.moduleExt = undefined; // use default ext + expect(() => findModuleFromOptions(tree, options)).toThrowError( + /Specified module 'app' does not exist/ + ); + }); + + it("should ignore custom ext if module or ${module}.ts exists", () => { + tree.create("/projects/my-proj/src/app.module.ts", ""); + options.path = "/projects/my-proj/src"; + options.moduleExt = "_module.ts"; + let modPath; + + // moduleExt ignored because exact path is found + options.module = "app.module.ts"; + modPath = findModuleFromOptions(tree, options); + expect(modPath).toBe("/projects/my-proj/src/app.module.ts" as Path); + + // moduleExt ignored because module + .ts is found + options.module = "app.module"; + modPath = findModuleFromOptions(tree, options); + expect(modPath).toBe("/projects/my-proj/src/app.module.ts" as Path); + }); + }); + + describe("buildRelativePath", () => { + it("works", () => { + expect(buildRelativePath("/test/module", "/test/service")).toEqual( + "./service" + ); + expect(buildRelativePath("/test/module", "/other/service")).toEqual( + "../other/service" + ); + expect(buildRelativePath("/module", "/test/service")).toEqual( + "./test/service" + ); + expect(buildRelativePath("/test/service", "/module")).toEqual( + "../module" + ); + }); + }); +}); diff --git a/packages/schematic-utils/src/utils/find-module.ts b/packages/schematic-utils/src/utils/find-module.ts new file mode 100644 index 0000000..7f0ee02 --- /dev/null +++ b/packages/schematic-utils/src/utils/find-module.ts @@ -0,0 +1,160 @@ +import { + NormalizedRoot, + Path, + dirname, + join, + normalize, + relative, +} from "@angular-devkit/core"; +import { DirEntry } from "@angular-devkit/schematics"; +import { Tree } from "../tree/interface"; + +export interface ModuleOptions { + module?: string; + name: string; + flat?: boolean; + path?: string; + skipImport?: boolean; + moduleExt?: string; + routingModuleExt?: string; +} + +export const MODULE_EXT = ".module.ts"; +export const ROUTING_MODULE_EXT = "-routing.module.ts"; + +/** + * Find the module referred by a set of options passed to the schematics. + */ +export function findModuleFromOptions( + host: Tree, + options: ModuleOptions +): Path | undefined { + // eslint-disable-next-line no-prototype-builtins + if (options.hasOwnProperty("skipImport") && options.skipImport) { + return undefined; + } + + const moduleExt = options.moduleExt || MODULE_EXT; + const routingModuleExt = options.routingModuleExt || ROUTING_MODULE_EXT; + + if (!options.module) { + const pathToCheck = (options.path || "") + "/" + options.name; + + return normalize( + findModule(host, pathToCheck, moduleExt, routingModuleExt) + ); + } else { + const modulePath = normalize(`/${options.path}/${options.module}`); + const componentPath = normalize(`/${options.path}/${options.name}`); + const moduleBaseName = normalize(modulePath).split("/").pop(); + + const candidateSet = new Set([normalize(options.path || "/")]); + + for (let dir = modulePath; dir != NormalizedRoot; dir = dirname(dir)) { + candidateSet.add(dir); + } + for (let dir = componentPath; dir != NormalizedRoot; dir = dirname(dir)) { + candidateSet.add(dir); + } + + const candidatesDirs = [...candidateSet].sort( + (a, b) => b.length - a.length + ); + for (const c of candidatesDirs) { + const candidateFiles = [ + "", + `${moduleBaseName}.ts`, + `${moduleBaseName}${moduleExt}`, + ].map((x) => join(c, x)); + + for (const sc of candidateFiles) { + if (host.exists(sc)) { + return normalize(sc); + } + } + } + + throw new Error( + `Specified module '${options.module}' does not exist.\n` + + `Looked in the following directories:\n ${candidatesDirs.join( + "\n " + )}` + ); + } +} + +/** + * Function to find the "closest" module to a generated file's path. + */ +export function findModule( + host: Tree, + generateDir: string, + moduleExt = MODULE_EXT, + routingModuleExt = ROUTING_MODULE_EXT +): Path { + let dir: DirEntry | null = host.getDir("/" + generateDir); + let foundRoutingModule = false; + + while (dir) { + const allMatches = dir.subfiles.filter((p) => p.endsWith(moduleExt)); + const filteredMatches = allMatches.filter( + (p) => !p.endsWith(routingModuleExt) + ); + + foundRoutingModule = + foundRoutingModule || allMatches.length !== filteredMatches.length; + + if (filteredMatches.length == 1) { + return join(dir.path, filteredMatches[0]); + } else if (filteredMatches.length > 1) { + throw new Error( + "More than one module matches. Use the skip-import option to skip importing " + + "the component into the closest module or use the module option to specify a module." + ); + } + + dir = dir.parent; + } + + const errorMsg = foundRoutingModule + ? "Could not find a non Routing NgModule." + + `\nModules with suffix '${routingModuleExt}' are strictly reserved for routing.` + + "\nUse the skip-import option to skip importing in NgModule." + : "Could not find an NgModule. Use the skip-import option to skip importing in NgModule."; + + throw new Error(errorMsg); +} + +/** + * Build a relative path from one file path to another file path. + */ +export function buildRelativePath(from: string, to: string): string { + from = normalize(from); + to = normalize(to); + + // Convert to arrays. + const fromParts = from.split("/"); + const toParts = to.split("/"); + + // Remove file names (preserving destination) + fromParts.pop(); + const toFileName = toParts.pop(); + + const relativePath = relative( + normalize(fromParts.join("/") || "/"), + normalize(toParts.join("/") || "/") + ); + let pathPrefix = ""; + + // Set the path prefix for same dir or child dir, parent dir starts with `..` + if (!relativePath) { + pathPrefix = "."; + } else if (!relativePath.startsWith(".")) { + pathPrefix = `./`; + } + if (pathPrefix && !pathPrefix.endsWith("/")) { + pathPrefix += "/"; + } + + return pathPrefix + (relativePath ? relativePath + "/" : "") + toFileName; +} diff --git a/packages/schematic-utils/src/utils/get-file-content.ts b/packages/schematic-utils/src/utils/get-file-content.ts new file mode 100644 index 0000000..66d096d --- /dev/null +++ b/packages/schematic-utils/src/utils/get-file-content.ts @@ -0,0 +1,11 @@ +import { Tree } from "../tree/interface"; + +export function getFileContent(tree: Tree, path: string): string { + const fileEntry = tree.get(path); + + if (!fileEntry) { + throw new Error(`The file (${path}) does not exist.`); + } + + return fileEntry.content.toString(); +} diff --git a/packages/schematic-utils/src/utils/json-file.ts b/packages/schematic-utils/src/utils/json-file.ts new file mode 100644 index 0000000..7fa8267 --- /dev/null +++ b/packages/schematic-utils/src/utils/json-file.ts @@ -0,0 +1,101 @@ +import { + Node, + ParseError, + applyEdits, + findNodeAtLocation, + getNodeValue, + modify, + parseTree, + printParseErrorCode, +} from "jsonc-parser"; +import { JsonValue } from "../json/interface"; +import { Tree } from "../tree/interface"; + +export type InsertionIndex = (properties: string[]) => number; +export type JSONPath = (string | number)[]; + +/** @internal */ +export class JSONFile { + content: string; + + constructor(private readonly host: Tree, private readonly path: string) { + const buffer = this.host.read(this.path); + if (buffer) { + this.content = buffer.toString(); + } else { + throw new Error(`Could not read '${path}'.`); + } + } + + private _jsonAst: Node | undefined; + private get JsonAst(): Node | undefined { + if (this._jsonAst) { + return this._jsonAst; + } + + const errors: ParseError[] = []; + this._jsonAst = parseTree(this.content, errors, { + allowTrailingComma: true, + }); + if (errors.length) { + const { error, offset } = errors[0]; + throw new Error( + `Failed to parse "${ + this.path + }" as JSON AST Object. ${printParseErrorCode( + error + )} at location: ${offset}.` + ); + } + + return this._jsonAst; + } + + get(jsonPath: JSONPath): unknown { + const jsonAstNode = this.JsonAst; + if (!jsonAstNode) { + return undefined; + } + + if (jsonPath.length === 0) { + return getNodeValue(jsonAstNode); + } + + const node = findNodeAtLocation(jsonAstNode, jsonPath); + + return node === undefined ? undefined : getNodeValue(node); + } + + modify( + jsonPath: JSONPath, + value: JsonValue | undefined, + insertInOrder?: InsertionIndex | false + ): void { + let getInsertionIndex: InsertionIndex | undefined; + if (insertInOrder === undefined) { + const property = jsonPath.slice(-1)[0]; + getInsertionIndex = (properties): number => + [...properties, property].sort().findIndex((p) => p === property); + } else if (insertInOrder !== false) { + getInsertionIndex = insertInOrder; + } + + const edits = modify(this.content, jsonPath, value, { + getInsertionIndex, + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, + }); + + this.content = applyEdits(this.content, edits); + this.host.overwrite(this.path, this.content); + this._jsonAst = undefined; + } + + remove(jsonPath: JSONPath): void { + if (this.get(jsonPath) !== undefined) { + this.modify(jsonPath, undefined); + } + } +} diff --git a/packages/schematic-utils/src/utils/ng-ast-utils.ts b/packages/schematic-utils/src/utils/ng-ast-utils.ts new file mode 100644 index 0000000..1b9b50e --- /dev/null +++ b/packages/schematic-utils/src/utils/ng-ast-utils.ts @@ -0,0 +1,100 @@ +import { normalize } from "@angular-devkit/core"; +import { SchematicsException } from "@angular-devkit/schematics"; +import { dirname } from "path"; +import * as ts from "typescript"; +import { findNode, getSourceNodes } from "./ast-utils"; +import { Tree } from "../tree/interface"; + +export function findBootstrapModuleCall( + host: Tree, + mainPath: string +): ts.CallExpression | null { + const mainBuffer = host.read(mainPath); + if (!mainBuffer) { + throw new SchematicsException(`Main file (${mainPath}) not found`); + } + const mainText = mainBuffer.toString("utf-8"); + const source = ts.createSourceFile( + mainPath, + mainText, + ts.ScriptTarget.Latest, + true + ); + + const allNodes = getSourceNodes(source); + + let bootstrapCall: ts.CallExpression | null = null; + + for (const node of allNodes) { + let bootstrapCallNode: ts.Node | null = null; + bootstrapCallNode = findNode( + node, + ts.SyntaxKind.Identifier, + "bootstrapModule" + ); + + // Walk up the parent until CallExpression is found. + while ( + bootstrapCallNode && + bootstrapCallNode.parent && + bootstrapCallNode.parent.kind !== ts.SyntaxKind.CallExpression + ) { + bootstrapCallNode = bootstrapCallNode.parent; + } + + if ( + bootstrapCallNode !== null && + bootstrapCallNode.parent !== undefined && + bootstrapCallNode.parent.kind === ts.SyntaxKind.CallExpression + ) { + bootstrapCall = bootstrapCallNode.parent as ts.CallExpression; + break; + } + } + + return bootstrapCall; +} + +export function findBootstrapModulePath(host: Tree, mainPath: string): string { + const bootstrapCall = findBootstrapModuleCall(host, mainPath); + if (!bootstrapCall) { + throw new SchematicsException("Bootstrap call not found"); + } + + const bootstrapModule = bootstrapCall.arguments[0]; + + const mainBuffer = host.read(mainPath); + if (!mainBuffer) { + throw new SchematicsException( + `Client app main file (${mainPath}) not found` + ); + } + const mainText = mainBuffer.toString("utf-8"); + const source = ts.createSourceFile( + mainPath, + mainText, + ts.ScriptTarget.Latest, + true + ); + const allNodes = getSourceNodes(source); + const bootstrapModuleRelativePath = allNodes + .filter(ts.isImportDeclaration) + .filter((imp) => { + return findNode(imp, ts.SyntaxKind.Identifier, bootstrapModule.getText()); + }) + .map((imp) => { + const modulePathStringLiteral = imp.moduleSpecifier as ts.StringLiteral; + + return modulePathStringLiteral.text; + })[0]; + + return bootstrapModuleRelativePath; +} + +export function getAppModulePath(host: Tree, mainPath: string): string { + const moduleRelativePath = findBootstrapModulePath(host, mainPath); + const mainDir = dirname(mainPath); + const modulePath = normalize(`/${mainDir}/${moduleRelativePath}.ts`); + + return modulePath; +} diff --git a/packages/schematic-utils/src/utils/runtime/index.ts b/packages/schematic-utils/src/utils/runtime/index.ts index 6034e77..282d6d4 100644 --- a/packages/schematic-utils/src/utils/runtime/index.ts +++ b/packages/schematic-utils/src/utils/runtime/index.ts @@ -8,8 +8,11 @@ export * from "./strings"; export * from "./base"; export * from "./call"; export * from "./move"; +export * from "./parse-name"; +export * from "./paths"; export * from "./random"; export * from "./rename"; export * from "./schematic"; export * from "./template"; export * from "./url"; +export * from "./validation"; diff --git a/packages/schematic-utils/src/utils/runtime/parse-name.spec.ts b/packages/schematic-utils/src/utils/runtime/parse-name.spec.ts new file mode 100644 index 0000000..831f92c --- /dev/null +++ b/packages/schematic-utils/src/utils/runtime/parse-name.spec.ts @@ -0,0 +1,37 @@ +import { parseName } from "./parse-name"; + +describe("parse-name", () => { + it("should handle just the name", () => { + const result = parseName("src/app", "foo"); + expect(result.name).toEqual("foo"); + expect(result.path).toEqual("/src/app"); + }); + + it("should handle no path", () => { + const result = parseName("", "foo"); + expect(result.name).toEqual("foo"); + expect(result.path).toEqual("/"); + }); + + it("should handle name has a path (sub-dir)", () => { + const result = parseName("src/app", "bar/foo"); + expect(result.name).toEqual("foo"); + expect(result.path).toEqual("/src/app/bar"); + }); + + it("should handle name has a higher path", () => { + const result = parseName("src/app", "../foo"); + expect(result.name).toEqual("foo"); + expect(result.path).toEqual("/src"); + }); + + it("should handle name has a higher path above root", () => { + expect(() => parseName("src/app", "../../../foo")).toThrow(); + }); + + it("should handle Windows paths", () => { + const result = parseName("", "foo\\bar\\baz"); + expect(result.name).toEqual("baz"); + expect(result.path).toEqual("/foo/bar"); + }); +}); diff --git a/packages/schematic-utils/src/utils/runtime/parse-name.ts b/packages/schematic-utils/src/utils/runtime/parse-name.ts new file mode 100644 index 0000000..5784604 --- /dev/null +++ b/packages/schematic-utils/src/utils/runtime/parse-name.ts @@ -0,0 +1,16 @@ +import { basename, dirname, join, normalize, Path } from "@angular-devkit/core"; + +export interface Location { + name: string; + path: Path; +} + +export function parseName(path: string, name: string): Location { + const nameWithoutPath = basename(normalize(name)); + const namePath = dirname(join(normalize(path), name)); + + return { + name: nameWithoutPath, + path: normalize("/" + namePath), + }; +} diff --git a/packages/schematic-utils/src/utils/runtime/paths.spec.ts b/packages/schematic-utils/src/utils/runtime/paths.spec.ts new file mode 100644 index 0000000..f13748d --- /dev/null +++ b/packages/schematic-utils/src/utils/runtime/paths.spec.ts @@ -0,0 +1,30 @@ +import { relativePathToWorkspaceRoot } from "./paths"; + +describe("paths", () => { + describe("relativePathToWorkspaceRoot", () => { + it(`should handle root '/'`, () => { + const result = relativePathToWorkspaceRoot("/"); + expect(result).toBe("."); + }); + + it(`should handle an empty path`, () => { + const result = relativePathToWorkspaceRoot(""); + expect(result).toBe("."); + }); + + it(`should handle an undefined path`, () => { + const result = relativePathToWorkspaceRoot(undefined); + expect(result).toBe("."); + }); + + it(`should handle path with trailing '/'`, () => { + const result = relativePathToWorkspaceRoot("foo/bar/"); + expect(result).toBe("../.."); + }); + + it(`should handle path without trailing '/'`, () => { + const result = relativePathToWorkspaceRoot("foo/bar"); + expect(result).toBe("../.."); + }); + }); +}); diff --git a/packages/schematic-utils/src/utils/runtime/paths.ts b/packages/schematic-utils/src/utils/runtime/paths.ts new file mode 100644 index 0000000..b9382b2 --- /dev/null +++ b/packages/schematic-utils/src/utils/runtime/paths.ts @@ -0,0 +1,13 @@ +import { normalize, split } from "@angular-devkit/core"; + +export function relativePathToWorkspaceRoot( + projectRoot: string | undefined +): string { + const normalizedPath = split(normalize(projectRoot || "")); + + if (normalizedPath.length === 0 || !normalizedPath[0]) { + return "."; + } else { + return normalizedPath.map(() => "..").join("/"); + } +} diff --git a/packages/schematic-utils/src/utils/runtime/validation.ts b/packages/schematic-utils/src/utils/runtime/validation.ts new file mode 100644 index 0000000..3401545 --- /dev/null +++ b/packages/schematic-utils/src/utils/runtime/validation.ts @@ -0,0 +1,72 @@ +import { tags } from "@angular-devkit/core"; +import { SchematicsException } from "../../exceptions/exception"; + +export function validateName(name: string): void { + if (name && /^\d/.test(name)) { + throw new SchematicsException(tags.oneLine`name (${name}) + can not start with a digit.`); + } +} + +// Must start with a letter, and must contain only alphanumeric characters or dashes. +// When adding a dash the segment after the dash must also start with a letter. +export const htmlSelectorRe = /^[a-zA-Z][.0-9a-zA-Z]*(:?-[a-zA-Z][.0-9a-zA-Z]*)*$/; + +export function validateHtmlSelector(selector: string): void { + if (selector && !htmlSelectorRe.test(selector)) { + throw new SchematicsException(tags.oneLine`Selector (${selector}) + is invalid.`); + } +} + +export function validateProjectName(projectName: string): void { + const errorIndex = getRegExpFailPosition(projectName); + const unsupportedProjectNames: string[] = []; + const packageNameRegex = /^(?:@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_-]+$/; + if (errorIndex !== null) { + const firstMessage = tags.oneLine` + Project name "${projectName}" is not valid. New project names must + start with a letter, and must contain only alphanumeric characters or dashes. + When adding a dash the segment after the dash must also start with a letter. + `; + const msg = tags.stripIndent` + ${firstMessage} + ${projectName} + ${Array(errorIndex + 1).join(" ") + "^"} + `; + throw new SchematicsException(msg); + } else if (unsupportedProjectNames.indexOf(projectName) !== -1) { + throw new SchematicsException( + `Project name ${JSON.stringify(projectName)} is not a supported name.` + ); + } else if (!packageNameRegex.test(projectName)) { + throw new SchematicsException( + `Project name ${JSON.stringify(projectName)} is invalid.` + ); + } +} + +function getRegExpFailPosition(str: string): number | null { + const isScope = /^@.*\/.*/.test(str); + if (isScope) { + // Remove starting @ + str = str.replace(/^@/, ""); + // Change / to - for validation + str = str.replace(/\//g, "-"); + } + + const parts = str.indexOf("-") >= 0 ? str.split("-") : [str]; + const matched: string[] = []; + + const projectNameRegexp = /^[a-zA-Z][.0-9a-zA-Z]*(-[.0-9a-zA-Z]*)*$/; + + parts.forEach((part) => { + if (part.match(projectNameRegexp)) { + matched.push(part); + } + }); + + const compare = matched.join("-"); + + return str !== compare ? compare.length : null; +} diff --git a/packages/schematic-utils/src/utils/workspace-models.ts b/packages/schematic-utils/src/utils/workspace-models.ts new file mode 100644 index 0000000..7b7a409 --- /dev/null +++ b/packages/schematic-utils/src/utils/workspace-models.ts @@ -0,0 +1,197 @@ +export enum ProjectType { + Application = "application", + Library = "library", +} + +export enum Builders { + AppShell = "@angular-devkit/build-angular:app-shell", + Server = "@angular-devkit/build-angular:server", + Browser = "@angular-devkit/build-angular:browser", + Karma = "@angular-devkit/build-angular:karma", + TsLint = "@angular-devkit/build-angular:tslint", + DeprecatedNgPackagr = "@angular-devkit/build-ng-packagr:build", + NgPackagr = "@angular-devkit/build-angular:ng-packagr", + DevServer = "@angular-devkit/build-angular:dev-server", + ExtractI18n = "@angular-devkit/build-angular:extract-i18n", + Protractor = "@angular-devkit/build-angular:protractor", +} + +export interface FileReplacements { + replace: string; + with: string; +} + +export interface BrowserBuilderBaseOptions { + main: string; + tsConfig: string; + fileReplacements?: FileReplacements[]; + outputPath?: string; + index?: string; + polyfills: string; + assets?: (Record | string)[]; + styles?: (Record | string)[]; + scripts?: (Record | string)[]; + sourceMap?: boolean; +} + +export type OutputHashing = "all" | "media" | "none" | "bundles"; + +export interface BrowserBuilderOptions extends BrowserBuilderBaseOptions { + serviceWorker?: boolean; + optimization?: boolean; + outputHashing?: OutputHashing; + resourcesOutputPath?: string; + extractCss?: boolean; + namedChunks?: boolean; + aot?: boolean; + extractLicenses?: boolean; + vendorChunk?: boolean; + buildOptimizer?: boolean; + ngswConfigPath?: string; + budgets?: { + type: string; + maximumWarning?: string; + maximumError?: string; + }[]; + webWorkerTsConfig?: string; +} + +export interface ServeBuilderOptions { + browserTarget: string; +} + +export interface LibraryBuilderOptions { + tsConfig: string; + project: string; +} + +export interface ServerBuilderOptions { + outputPath: string; + tsConfig: string; + main: string; + fileReplacements?: FileReplacements[]; + optimization?: + | boolean + | { + scripts?: boolean; + styles?: boolean; + }; + sourceMap?: + | boolean + | { + scripts?: boolean; + styles?: boolean; + hidden?: boolean; + vendor?: boolean; + }; +} + +export interface AppShellBuilderOptions { + browserTarget: string; + serverTarget: string; + route: string; +} + +export interface TestBuilderOptions extends Partial { + karmaConfig: string; +} + +export interface ExtractI18nOptions { + browserTarget: string; +} + +export interface E2EOptions { + protractorConfig: string; + devServerTarget: string; +} + +export interface BuilderTarget { + builder: TBuilder; + options: TOptions; + configurations?: { + production: Partial; + [key: string]: Partial; + }; +} + +export type LibraryBuilderTarget = BuilderTarget< + Builders.NgPackagr, + LibraryBuilderOptions +>; +export type BrowserBuilderTarget = BuilderTarget< + Builders.Browser, + BrowserBuilderOptions +>; +export type ServerBuilderTarget = BuilderTarget< + Builders.Server, + ServerBuilderOptions +>; +export type AppShellBuilderTarget = BuilderTarget< + Builders.AppShell, + AppShellBuilderOptions +>; +export type TestBuilderTarget = BuilderTarget< + Builders.Karma, + TestBuilderOptions +>; +export type ServeBuilderTarget = BuilderTarget< + Builders.DevServer, + ServeBuilderOptions +>; +export type ExtractI18nBuilderTarget = BuilderTarget< + Builders.ExtractI18n, + ExtractI18nOptions +>; +export type E2EBuilderTarget = BuilderTarget; + +export interface WorkspaceSchema { + version: 1; + defaultProject?: string; + cli?: { warnings?: Record }; + projects: { + [key: string]: WorkspaceProject< + ProjectType.Application | ProjectType.Library + >; + }; +} + +export interface WorkspaceProject< + TProjectType extends ProjectType = ProjectType.Application +> { + /** + * Project type. + */ + projectType: ProjectType; + + root: string; + sourceRoot: string; + prefix: string; + + cli?: { warnings?: Record }; + + /** + * Tool options. + */ + architect?: WorkspaceTargets; + /** + * Tool options. + */ + targets?: WorkspaceTargets; +} + +export interface WorkspaceTargets< + TProjectType extends ProjectType = ProjectType.Application +> { + build?: TProjectType extends ProjectType.Library + ? LibraryBuilderTarget + : BrowserBuilderTarget; + server?: ServerBuilderTarget; + test?: TestBuilderTarget; + serve?: ServeBuilderTarget; + e2e?: E2EBuilderTarget; + "app-shell"?: AppShellBuilderTarget; + "extract-i18n"?: ExtractI18nBuilderTarget; + // TODO(hans): change this any to unknown when google3 supports TypeScript 3.0. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} diff --git a/packages/schematic-utils/src/utils/workspace.ts b/packages/schematic-utils/src/utils/workspace.ts new file mode 100644 index 0000000..5171f5d --- /dev/null +++ b/packages/schematic-utils/src/utils/workspace.ts @@ -0,0 +1,135 @@ +import { json, virtualFs, workspaces } from "@angular-devkit/core"; +import { ProjectType } from "./workspace-models"; +import { Tree } from "../tree/interface"; +import { Rule } from "../engine/interface"; +import { noop } from "./runtime"; + +function createHost(tree: Tree): workspaces.WorkspaceHost { + return { + async readFile(path: string): Promise { + const data = tree.read(path); + if (!data) { + throw new Error("File not found."); + } + + return virtualFs.fileBufferToString(data); + }, + async writeFile(path: string, data: string): Promise { + return tree.overwrite(path, data); + }, + async isDirectory(path: string): Promise { + // approximate a directory check + return !tree.exists(path) && tree.getDir(path).subfiles.length > 0; + }, + async isFile(path: string): Promise { + return tree.exists(path); + }, + }; +} + +export function updateWorkspace( + updater: ( + workspace: workspaces.WorkspaceDefinition + ) => void | Rule | PromiseLike +): Rule; +export function updateWorkspace( + workspace: workspaces.WorkspaceDefinition +): Rule; +export function updateWorkspace( + updaterOrWorkspace: + | workspaces.WorkspaceDefinition + | (( + workspace: workspaces.WorkspaceDefinition + ) => void | Rule | PromiseLike) +): Rule { + return async (tree: Tree): Promise => { + const host = createHost(tree); + + if (typeof updaterOrWorkspace === "function") { + const { workspace } = await workspaces.readWorkspace("/", host); + + const result = await updaterOrWorkspace(workspace); + + await workspaces.writeWorkspace(workspace, host); + + return result || noop; + } else { + await workspaces.writeWorkspace(updaterOrWorkspace, host); + + return noop; + } + }; +} + +export async function getWorkspace( + tree: Tree, + path = "/" +): Promise { + const host = createHost(tree); + + const { workspace } = await workspaces.readWorkspace(path, host); + + return workspace; +} + +/** + * Build a default project path for generating. + * @param project The project which will have its default path generated. + */ +export function buildDefaultPath( + project: workspaces.ProjectDefinition +): string { + const root = project.sourceRoot + ? `/${project.sourceRoot}/` + : `/${project.root}/src/`; + const projectDirName = + project.extensions["projectType"] === ProjectType.Application + ? "app" + : "lib"; + + return `${root}${projectDirName}`; +} + +export async function createDefaultPath( + tree: Tree, + projectName: string +): Promise { + const workspace = await getWorkspace(tree); + const project = workspace.projects.get(projectName); + if (!project) { + throw new Error(`Project "${projectName}" does not exist.`); + } + + return buildDefaultPath(project); +} + +export function* allWorkspaceTargets( + workspace: workspaces.WorkspaceDefinition +): Iterable< + [string, workspaces.TargetDefinition, string, workspaces.ProjectDefinition] +> { + for (const [projectName, project] of workspace.projects) { + for (const [targetName, target] of project.targets) { + yield [targetName, target, projectName, project]; + } + } +} + +export function* allTargetOptions( + target: workspaces.TargetDefinition, + skipBaseOptions = false +): Iterable<[string | undefined, Record]> { + if (!skipBaseOptions && target.options) { + yield [undefined, target.options]; + } + + if (!target.configurations) { + return; + } + + for (const [name, options] of Object.entries(target.configurations)) { + if (options !== undefined) { + yield [name, options]; + } + } +}