From 0ca164e491c00e9ef730b002585eea551f432a4d Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Mon, 26 Mar 2018 21:57:32 +0300 Subject: [PATCH 1/2] test: add unit tests for refactor-nsng-modules --- src/refactor-nsng-modules/index_spec.ts | 225 ++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/refactor-nsng-modules/index_spec.ts diff --git a/src/refactor-nsng-modules/index_spec.ts b/src/refactor-nsng-modules/index_spec.ts new file mode 100644 index 0000000..2bf0797 --- /dev/null +++ b/src/refactor-nsng-modules/index_spec.ts @@ -0,0 +1,225 @@ +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { getFileContent } from '@schematics/angular/utility/test'; +import { VirtualTree } from '@angular-devkit/schematics'; +import * as path from 'path'; + +import { isInModuleMetadata } from '../test-utils'; +import { Schema } from './schema'; + +describe('Refactor NsNg Modules Schematic', () => { + const schematicRunner = new SchematicTestRunner( + 'nativescript-schematics', + path.join(__dirname, '../collection.json'), + ); + + const sourceDir = 'src'; + const defaultOptions: Schema = { sourceDir }; + + const rootModulePath = `${sourceDir}/app.module.ts`; + const rootModuleContent = ` + import { NgModule } from "@angular/core"; + import { NativeScriptModule } from "nativescript-angular/nativescript.module"; + + @NgModule({ + imports: [ + NativeScriptModule, + ], + }) + export class AppModule { } + `; + + const initAppTree = () => { + const appTree = new VirtualTree(); + appTree.create(`${sourceDir}/package.json`, `{ "main": "main.js" }`); + appTree.create(`${sourceDir}/main.ts`, ` + import { platformNativeScriptDynamic } from 'nativescript-angular/platform'; + import { AppModule } from './app.module'; + + platformNativeScriptDynamic().bootstrapModule(AppModule); + `); + + appTree.create(rootModulePath, rootModuleContent); + + return appTree; + }; + + + describe('when no changes are required', () => { + let tree; + beforeEach(() => { + const appTree = initAppTree(); + tree = schematicRunner.runSchematic('refactor-nsng-modules', defaultOptions, appTree); + }); + + it('should not change the tree', () => { + expect(tree.files.length).toEqual(3); + expect(tree.exists(rootModulePath)).toEqual(true); + expect(getFileContent(tree, rootModulePath)).toEqual(rootModuleContent); + }); + }); + + describe('when a feature module has NativeScriptModule imported', () => { + const featureModuleName = `LoginModule`; + const featureModulePath = `${sourceDir}/feature.module.ts`; + + let tree; + let featureModuleContent; + + beforeEach(() => { + const appTree = initAppTree(); + appTree.create(featureModulePath, ` + import { NativeScriptModule } from "nativescript-angular/nativescript.module"; + import { NativeScriptFormsModule } from "nativescript-angular/forms"; + import { NgModule, NO_ERRORS_SCHEMA } from "@angular/core"; + + import { loginRouting } from "./login.routing"; + import { LoginComponent } from "./login.component"; + + + @NgModule({ + imports: [ + NativeScriptFormsModule, + NativeScriptModule, + loginRouting + ], + declarations: [ + LoginComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }) + export class ${featureModuleName} { } + `); + + tree = schematicRunner.runSchematic('refactor-nsng-modules', defaultOptions, appTree); + featureModuleContent = getFileContent(tree, featureModulePath); + }); + + it('should remove the NativeScriptModule import', () => { + expect(featureModuleContent).not.toMatch(`NativeScriptModule`); + expect(featureModuleContent) + .not.toMatch('import { NativeScriptModule } from "nativescript-angular/nativescript.module";' + ); + }); + + it('should add the NativeScriptCommonModule to the module metadata', () => { + expect(featureModuleContent) + .toMatch( + isInModuleMetadata(featureModuleName, 'imports', 'NativeScriptCommonModule', true) + ); + }); + + it('should not change the root module', () => { + expect(getFileContent(tree, rootModulePath)).toEqual(rootModuleContent); + }); + }); + + describe('when a feature module has NativeScriptAnimationsModule imported', () => { + const featureModuleName = `SomeModule`; + const featureModulePath = `${sourceDir}/nested/dir/some.module.ts`; + + let tree; + let featureModuleContent; + + beforeEach(() => { + const appTree = initAppTree(); + appTree.create(featureModulePath, ` + import { NativeScriptAnimationsModule } from "nativescript-angular/animations"; + import { NgModule, NO_ERRORS_SCHEMA } from "@angular/core"; + + @NgModule({ + imports: [ + NativeScriptAnimationsModule, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + export class ${featureModuleName} { } + `); + + tree = schematicRunner.runSchematic('refactor-nsng-modules', defaultOptions, appTree); + featureModuleContent = getFileContent(tree, featureModulePath); + }); + + it('should remove the NativeScriptAnimationsModule import', () => { + expect(featureModuleContent).not.toMatch(`NativeScriptAnimationsModule`); + expect(featureModuleContent) + .not.toMatch('import { NativeScriptAnimationsModule } from "nativescript-angular/animations";' + ); + }); + + it('should add the animations module to the root module', () => { + const newRootModuleContent = getFileContent(tree, rootModulePath); + expect(newRootModuleContent).toMatch(`NativeScriptAnimationsModule`); + expect(newRootModuleContent) + .toMatch('import { NativeScriptAnimationsModule } from "nativescript-angular/animations";' + ); + }); + }); + + describe('when a feature module has both NativeScriptModule and NativeScriptAnimationsModule imported', () => { + const featureModuleName = `FeatureModule`; + const featureModulePath = `${sourceDir}/dir/feature-1.module.ts`; + + let tree; + let featureModuleContent; + + beforeEach(() => { + const appTree = initAppTree(); + appTree.create(featureModulePath, ` + import { NativeScriptModule } from "nativescript-angular/nativescript.module"; + import { NgModule, NO_ERRORS_SCHEMA } from "@angular/core"; + import { NativeScriptAnimationsModule } from "nativescript-angular/animations"; + + @NgModule({ + imports: [ + NativeScriptModule, + NativeScriptAnimationsModule, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + export class ${featureModuleName} { } + `); + + tree = schematicRunner.runSchematic('refactor-nsng-modules', defaultOptions, appTree); + featureModuleContent = getFileContent(tree, featureModulePath); + }); + + it('should remove the NativeScriptAnimationsModule import', () => { + expect(featureModuleContent).not.toMatch(`NativeScriptAnimationsModule`); + expect(featureModuleContent) + .not.toMatch('import { NativeScriptAnimationsModule } from "nativescript-angular/animations";' + ); + }); + + it('should add the animations module to the root module', () => { + const newRootModuleContent = getFileContent(tree, rootModulePath); + expect(newRootModuleContent).toMatch(`NativeScriptAnimationsModule`); + expect(newRootModuleContent) + .toMatch('import { NativeScriptAnimationsModule } from "nativescript-angular/animations";' + ); + }); + + it('should add the animations module to the root module', () => { + const newRootModuleContent = getFileContent(tree, rootModulePath); + expect(newRootModuleContent).toMatch(`NativeScriptAnimationsModule`); + expect(newRootModuleContent) + .toMatch('import { NativeScriptAnimationsModule } from "nativescript-angular/animations";' + ); + }); + + it('should remove the NativeScriptModule import', () => { + expect(featureModuleContent).not.toMatch(`NativeScriptModule`); + expect(featureModuleContent) + .not.toMatch('import { NativeScriptModule } from "nativescript-angular/nativescript.module";' + ); + }); + + it('should import the NativeScriptCommonModule to the feature module', () => { + expect(featureModuleContent).toMatch('import { NativeScriptCommonModule } from "nativescript-angular/common"'); + }); + + it('should add the NativeScriptCommonModule to the module metadata', () => { + expect(featureModuleContent).toMatch('NativeScriptCommonModule'); + }); + + }); +}); From 068b752ea05f452d6b086b662f92afcc626832a7 Mon Sep 17 00:00:00 2001 From: sis0k0 Date: Mon, 26 Mar 2018 23:36:26 +0300 Subject: [PATCH 2/2] feat: add schematic for fixing module imports in feature modules Replaces NativeScriptModule imports with NativeScriptCommonModule imports in feature modules. Removes NativeScriptAnimationsModule imports from feature modules and adds import to that module in the root module. Covers the breaking change https://github.com/NativeScript/nativescript-angular/pull/1196 --- package-lock.json | 106 +++++++------- src/ast-utils.ts | 196 +++++++++++++++++++++----- src/collection.json | 6 + src/module/index.ts | 23 +-- src/refactor-nsng-modules/index.ts | 181 ++++++++++++++++++++++++ src/refactor-nsng-modules/schema.d.ts | 6 + src/refactor-nsng-modules/schema.json | 14 ++ src/route-utils.ts | 86 +++++++++++ src/utils.ts | 9 +- 9 files changed, 516 insertions(+), 111 deletions(-) create mode 100644 src/refactor-nsng-modules/index.ts create mode 100644 src/refactor-nsng-modules/schema.d.ts create mode 100644 src/refactor-nsng-modules/schema.json create mode 100644 src/route-utils.ts diff --git a/package-lock.json b/package-lock.json index 578b542..44f6a20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,23 +5,23 @@ "requires": true, "dependencies": { "@angular-devkit/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-0.2.0.tgz", - "integrity": "sha512-UiY+JEi7/NqPEB61UXeId3GI1ryon4NRhhPJNFsMa8zBMX1e1xy52P7Wy79LU32mbtZhpt0jnVJhtxAAPKMOXA==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-0.4.6.tgz", + "integrity": "sha512-xf3sH98IE3G8BaksoUFBg3QNzFIq/wKNF6Qr116yxEJJoO+ikgAQlHG0z+2rIUTW4+qibJKXHAGjUKA6g5bYdw==", "requires": { "ajv": "5.5.2", "chokidar": "1.7.0", - "rxjs": "5.5.6", + "rxjs": "5.5.7", "source-map": "0.5.7" } }, "@angular-devkit/schematics": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-0.2.0.tgz", - "integrity": "sha512-gwZbkgdFcf+YXWa2iiha1hlGGj5P2OBZpJ5j0o5Nhd3U+V9GDH8nISIEkUGMRxjzBxQj68/IdRS1ODsjvwxn/g==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-0.4.6.tgz", + "integrity": "sha512-Iw9OB98/5oszKXha5MiXy3jCHzXUula+Pmo57owdqWtgAxpAFmkNC2QM91kcKgZpMJBjEQsADTu8Alv2aCGFcw==", "requires": { "@ngtools/json-schema": "1.1.0", - "rxjs": "5.5.6" + "rxjs": "5.5.7" } }, "@ngtools/json-schema": { @@ -30,23 +30,23 @@ "integrity": "sha1-w6DFRNYjkqzCgTpCyKDcb1j4aSI=" }, "@schematics/angular": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-0.2.0.tgz", - "integrity": "sha512-F/hCOPaIB4V9paFxl5dk8bC4mcLH5eH5EalVJ662ogeaT2VV3br8ugLZZpKa2M3bpVGRPo5x3NUoLnJjC1s7jw==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-0.4.6.tgz", + "integrity": "sha512-dszECpURkT05a7D0QFEvIZKGhIK3I9Y3//KnZ//Ajt5Qc7/Ulk8OzyzumYcpS78ZF95hXOMZTHfs1kGt5zZ3Ew==", "requires": { "typescript": "2.6.2" } }, "@types/jasmine": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-2.8.2.tgz", - "integrity": "sha512-RabEJPjYMpjWqW1qYj4k0rlgP5uzyguoc0yxedJdq7t5h19MYvqhjCR1evM3raZ/peHRxp1Qfl24iawvkibSug==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-2.8.6.tgz", + "integrity": "sha512-clg9raJTY0EOo5pVZKX3ZlMjlYzVU73L71q5OV1jhE2Uezb7oF94jh4CvwrW6wInquQAdhOxJz5VDF2TLUGmmA==", "dev": true }, "@types/node": { - "version": "8.0.53", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.0.53.tgz", - "integrity": "sha1-OWs1r4JvpmqtRyyMt7jV4nf05tg=", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.0.tgz", + "integrity": "sha512-7IGHZQfRfa0bCd7zUBVUGFKFn31SpaLDFfNoCAqkTGQO5JlHC9BwQA/CG9KZlABFxIUtXznyFgechjPQEGrUTg==", "dev": true }, "ajv": { @@ -55,7 +55,7 @@ "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", "requires": { "co": "4.6.0", - "fast-deep-equal": "1.0.0", + "fast-deep-equal": "1.1.0", "fast-json-stable-stringify": "2.0.0", "json-schema-traverse": "0.3.1" } @@ -103,9 +103,9 @@ "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU=" }, "brace-expansion": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", - "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "requires": { "balanced-match": "1.0.0", "concat-map": "0.0.1" @@ -183,9 +183,9 @@ } }, "fast-deep-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", - "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" }, "fast-json-stable-stringify": { "version": "2.0.0", @@ -234,7 +234,7 @@ "integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==", "optional": true, "requires": { - "nan": "2.8.0", + "nan": "2.10.0", "node-pre-gyp": "0.6.39" }, "dependencies": { @@ -1144,20 +1144,20 @@ } }, "jasmine": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-2.8.0.tgz", - "integrity": "sha1-awicChFXax8W3xG4AUbZHU6Lij4=", + "version": "2.99.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-2.99.0.tgz", + "integrity": "sha1-jKctEC5jm4Z8ZImFbg4YqceqQrc=", "dev": true, "requires": { "exit": "0.1.2", "glob": "7.1.2", - "jasmine-core": "2.8.0" + "jasmine-core": "2.99.1" } }, "jasmine-core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.8.0.tgz", - "integrity": "sha1-vMl5rh+f0FcB5F5S5l06XWPxok4=", + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.99.1.tgz", + "integrity": "sha1-5kAN8ea1bhMLYcS80JPap/boyhU=", "dev": true }, "json-schema-traverse": { @@ -1198,13 +1198,13 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "requires": { - "brace-expansion": "1.1.8" + "brace-expansion": "1.1.11" } }, "nan": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.8.0.tgz", - "integrity": "sha1-7XFfP+neArV6XmJS2QqWZ14fCFo=", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", + "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", "optional": true }, "normalize-path": { @@ -1255,9 +1255,9 @@ "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=" }, "process-nextick-args": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, "randomatic": { "version": "1.1.7", @@ -1297,14 +1297,14 @@ } }, "readable-stream": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", - "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.5.tgz", + "integrity": "sha512-tK0yDhrkygt/knjowCUiWP9YdV7c5R+8cR0r/kt9ZhBU906Fs6RpQJCEilamRJj1Nx2rWI6LkW9gKqjTkshhEw==", "requires": { "core-util-is": "1.0.2", "inherits": "2.0.3", "isarray": "1.0.0", - "process-nextick-args": "1.0.7", + "process-nextick-args": "2.0.0", "safe-buffer": "5.1.1", "string_decoder": "1.0.3", "util-deprecate": "1.0.2" @@ -1317,7 +1317,7 @@ "requires": { "graceful-fs": "4.1.11", "minimatch": "3.0.4", - "readable-stream": "2.3.3", + "readable-stream": "2.3.5", "set-immediate-shim": "1.0.1" } }, @@ -1345,18 +1345,11 @@ "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" }, "rxjs": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.6.tgz", - "integrity": "sha512-v4Q5HDC0FHAQ7zcBX7T2IL6O5ltl1a2GX4ENjPXg6SjDY69Cmx9v4113C99a4wGF16ClPv5Z8mghuYorVkg/kg==", + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.7.tgz", + "integrity": "sha512-Hxo2ac8gRQjwjtKgukMIwBRbq5+KAeEV5hXM4obYBOAghev41bDQWgFH4svYiU9UnQ5kNww2LgfyBdevCd2HXA==", "requires": { "symbol-observable": "1.0.1" - }, - "dependencies": { - "symbol-observable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", - "integrity": "sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ=" - } } }, "safe-buffer": { @@ -1382,6 +1375,11 @@ "safe-buffer": "5.1.1" } }, + "symbol-observable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", + "integrity": "sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ=" + }, "typescript": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.6.2.tgz", diff --git a/src/ast-utils.ts b/src/ast-utils.ts index 8772820..5cfeeae 100644 --- a/src/ast-utils.ts +++ b/src/ast-utils.ts @@ -1,15 +1,9 @@ -import { getDecoratorMetadata, addImportToModule, addBootstrapToModule, addSymbolToNgModuleMetadata } from '@schematics/angular/utility/ast-utils'; +import { getDecoratorMetadata, addImportToModule, addBootstrapToModule, addSymbolToNgModuleMetadata, findNodes } from '@schematics/angular/utility/ast-utils'; import { InsertChange, Change } from '@schematics/angular/utility/change'; import { SchematicsException, Rule, Tree } from '@angular-devkit/schematics'; import * as ts from 'typescript'; -import { toComponentClassName } from './utils'; - -export interface Node { - getStart(); - getFullStart(); - getEnd(); -} +import { toComponentClassName, Node, getSourceFile, removeNode } from './utils'; class RemoveContent implements Node { constructor(private pos: number, private end: number) { @@ -62,6 +56,10 @@ export function addSymbolToDecoratorMetadata( return []; } + return getSymbolsToAddToObject(componentPath, node, metadataField, symbolName); +} + +export function getSymbolsToAddToObject(path: string, node: any, metadataField: string, symbolName: string) { // Get all the children property assignment of object literals. const matchingProperties: ts.ObjectLiteralElement[] = (node as ts.ObjectLiteralExpression).properties @@ -72,7 +70,7 @@ export function addSymbolToDecoratorMetadata( const name = prop.name; switch (name.kind) { case ts.SyntaxKind.Identifier: - return (name as ts.Identifier).getText(source) == metadataField; + return (name as ts.Identifier).getText() == metadataField; case ts.SyntaxKind.StringLiteral: return (name as ts.StringLiteral).text == metadataField; } @@ -85,7 +83,7 @@ export function addSymbolToDecoratorMetadata( return []; } - if (matchingProperties.length == 0) { + 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; @@ -97,16 +95,16 @@ export function addSymbolToDecoratorMetadata( 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 text = node.getFullText(); const matches = text.match(/^\r?\n\s*/); - if (matches.length > 0) { + if (matches && matches.length > 0) { toInsert = `,${matches[0]}${metadataField}: ${symbolName},`; } else { toInsert = `, ${metadataField}: ${symbolName},`; } } - return [new InsertChange(componentPath, position, toInsert)]; + return [new InsertChange(path, position, toInsert)]; } const assignment = matchingProperties[0] as ts.PropertyAssignment; @@ -123,7 +121,6 @@ export function addSymbolToDecoratorMetadata( } else { node = arrLiteral.elements; } - if (!node) { console.log('No app module found. Please add your new class to your component.'); @@ -133,7 +130,7 @@ export function addSymbolToDecoratorMetadata( if (Array.isArray(node)) { const nodeArray = node as {} as Array; const symbolsArray = nodeArray.map(node => node.getText()); - if (symbolsArray.indexOf(symbolName) === -1) { + if (symbolsArray.indexOf(symbolName) !== -1) { return []; } @@ -142,7 +139,7 @@ export function addSymbolToDecoratorMetadata( let toInsert: string; let position = node.getEnd(); - if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { + if (node.kind === ts.SyntaxKind.ObjectLiteralExpression) { // We haven't found the field in the metadata declaration. Insert a new // field. const expr = node as ts.ObjectLiteralExpression; @@ -153,7 +150,7 @@ export function addSymbolToDecoratorMetadata( 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 text = node.getFullText(); if (text.match('^\r?\r?\n')) { toInsert = `,${text.match(/^\r?\n\s+/)[0]}${metadataField}: [${symbolName},]`; } else { @@ -166,7 +163,7 @@ export function addSymbolToDecoratorMetadata( toInsert = `${symbolName}`; } else { // Get the indentation of the last element, if any. - const text = node.getFullText(source); + const text = node.getFullText(); if (text.match(/^\r?\n/)) { toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${symbolName},`; } else { @@ -174,7 +171,7 @@ export function addSymbolToDecoratorMetadata( } } - return [new InsertChange(componentPath, position, toInsert)]; + return [new InsertChange(path, position, toInsert)]; } export function findFullImports(importName: string, source: ts.SourceFile): @@ -212,19 +209,49 @@ export function findFullImports(importName: string, source: ts.SourceFile): return imports; }, []); +} + +export function findImports(importName: string, source: ts.SourceFile): + (ts.ImportDeclaration)[] { + + const allImports = collectDeepNodes(source, ts.SyntaxKind.ImportDeclaration); + return allImports + .filter(({ importClause: clause }) => + clause && !clause.name && clause.namedBindings && + clause.namedBindings.kind === ts.SyntaxKind.NamedImports + ) + .reduce(( + imports: (ts.ImportDeclaration)[], + importDecl: ts.ImportDeclaration + ) => { + const importClause = importDecl.importClause as ts.ImportClause; + const namedImports = importClause.namedBindings as ts.NamedImports; + + namedImports.elements.forEach((importSpec: ts.ImportSpecifier) => { + const importId = importSpec.name; + if (importId.text === importName) { + imports.push(importDecl); + } + }); + + return imports; + }, []); } -export function findMetadataValueInArray(source: ts.SourceFile, property: string, value: string): +export function findMetadataValueInArray(source: ts.Node, property: string, value: string): (ts.Node | RemoveContent)[] { const decorators = collectDeepNodes(source, ts.SyntaxKind.Decorator) + return getNodesToRemoveFromNestedArray(decorators, property, value); +} - const valuesNode = decorators +export function getNodesToRemoveFromNestedArray(nodes: ts.Node[], property: string, value: string) { + const valuesNode = nodes .reduce( - (nodes, decorator) => [ + (nodes, current) => [ ...nodes, - ...collectDeepNodes(decorator, ts.SyntaxKind.PropertyAssignment) + ...collectDeepNodes(current, ts.SyntaxKind.PropertyAssignment) ], []) .find(assignment => { let isValueForProperty = false; @@ -255,7 +282,7 @@ export function findMetadataValueInArray(source: ts.SourceFile, property: string const values: (ts.Node | RemoveContent)[] = []; ts.forEachChild(arrayLiteral, (child: ts.Node) => { if (child.getText() === value) { - const toRemove = normalizeNodeToRemove(child, source); + const toRemove = normalizeNodeToRemove(child, arrayLiteral); values.push(toRemove); } }); @@ -265,26 +292,24 @@ export function findMetadataValueInArray(source: ts.SourceFile, property: string /** * - * @param node The node that should be remove + * @param node The node that should be removed * @param source The source file that we are removing from * This method ensures that if there's a comma before or after the node, * it will be removed, too. */ -function normalizeNodeToRemove(node: T, source: ts.SourceFile) +function normalizeNodeToRemove(node: T, source: ts.Node) : (T | RemoveContent) { const content = source.getText(); - const start = node.getFullStart(); - const end = node.getEnd(); + const nodeStart = node.getFullStart(); + const nodeEnd = node.getEnd(); + const start = nodeStart - source.getFullStart(); const symbolBefore = content.substring(start - 1, start); - const symbolAfter = content.substring(end, end + 1); if (symbolBefore === ",") { - return new RemoveContent(start - 1, end); - } else if (symbolAfter === ",") { - return new RemoveContent(start, end + 1); + return new RemoveContent(nodeStart - 1, nodeEnd); } else { - return node; + return new RemoveContent(nodeStart, nodeEnd + 1); } } @@ -335,7 +360,7 @@ export function addBootstrapToNgModule(modulePath: string, rootComponentName: st }; } -function collectDeepNodes(node: ts.Node, kind: ts.SyntaxKind): T[] { +export function collectDeepNodes(node: ts.Node, kind: ts.SyntaxKind): T[] { const nodes: T[] = []; const helper = (child: ts.Node) => { if (child.kind === kind) { @@ -348,3 +373,106 @@ function collectDeepNodes(node: ts.Node, kind: ts.SyntaxKind) return nodes; } +export function filterByChildNode( + root: ts.Node, + condition: (node: ts.Node) => boolean +): boolean { + let matches = false; + const helper = (child: ts.Node) => { + if (condition(child)) { + matches = true; + return; + } + } + + ts.forEachChild(root, helper); + + return matches; +} + +export const getDecoratedClass = (tree: Tree, filePath: string, decoratorName: string, className: string) => { + return getDecoratedClasses(tree, filePath, decoratorName) + .find(c => !!(c.name && c.name.getText() === className)); +}; + +export const getDecoratedClasses = (tree: Tree, filePath: string, decoratorName: string) => { + const moduleSource = getSourceFile(tree, filePath); + const classes = collectDeepNodes(moduleSource, ts.SyntaxKind.ClassDeclaration); + + return classes.filter(c => !!getDecorator(c, decoratorName)) +}; + +export const getDecoratorMetadataFromClass = (classNode: ts.Node, decoratorName: string) => { + const decorator = getDecorator(classNode, decoratorName); + if (!decorator) { + return; + } + + return (decorator.expression).arguments[0]; +}; + +const getDecorator = (node: ts.Node, name: string) => { + return node.decorators && node.decorators.find((decorator: ts.Decorator) => + decorator.expression.kind === ts.SyntaxKind.CallExpression && + (decorator.expression).expression.getText() === name + ); +}; + +export const removeMetadataArrayValue = (tree: Tree, filePath: string, property: string, value: string) => { + const source = getSourceFile(tree, filePath); + const nodesToRemove = findMetadataValueInArray(source, property, value); + + nodesToRemove.forEach(declaration => + removeNode(declaration, filePath, tree) + ); +} + +export const removeImport = (tree: Tree, filePath: string, importName: string) => { + const source = getSourceFile(tree, filePath); + const importsToRemove = findFullImports(importName, source); + + importsToRemove.forEach(declaration => + removeNode(declaration, filePath, tree) + ); +}; + +/** + * 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 insertBeforeFirstOccurence(nodes: ts.Node[], + toInsert: string, + file: string, + fallbackPos: number, + syntaxKind?: ts.SyntaxKind): Change { + let firstItem = nodes.sort(nodesByPosition).shift(); + if (!firstItem) { + throw new Error(); + } + if (syntaxKind) { + firstItem = findNodes(firstItem, syntaxKind).sort(nodesByPosition).shift(); + } + if (!firstItem && fallbackPos == undefined) { + throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`); + } + const firstItemPosition: number = firstItem ? firstItem.getStart() : fallbackPos; + + return new InsertChange(file, firstItemPosition, toInsert); +} + +/** + * 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(); +} diff --git a/src/collection.json b/src/collection.json index bb07851..58cddd0 100644 --- a/src/collection.json +++ b/src/collection.json @@ -46,6 +46,12 @@ "schema": "./migrate-ns/schema.json" }, + "refactor-nsng-modules": { + "factory": "./refactor-nsng-modules", + "description": "Upgrades existing {N} Angular projects.", + "schema": "./refactor-nsng-modules/schema.json" + }, + "class": { "aliases": [ "cl" ], "extends": "@schematics/angular:class" diff --git a/src/module/index.ts b/src/module/index.ts index e8668e7..4607ec1 100644 --- a/src/module/index.ts +++ b/src/module/index.ts @@ -13,15 +13,14 @@ import { Schema as ModuleOptions } from './schema'; import { getExtensions, getSourceFile, - removeNode, copy, ns, web, removeNsSchemaOptions, } from '../utils'; import { - findFullImports, - findMetadataValueInArray, + removeImport, + removeMetadataArrayValue, } from '../ast-utils'; import { dasherize } from '@angular-devkit/core/src/utils/strings'; @@ -242,21 +241,3 @@ const addNSCommonModule = (tree: Tree, modulePath: string) => { return tree; }; - -const removeMetadataArrayValue = (tree: Tree, filePath: string, property: string, value: string) => { - const source = getSourceFile(tree, filePath); - const nodesToRemove = findMetadataValueInArray(source, property, value); - - nodesToRemove.forEach(declaration => - removeNode(declaration, filePath, tree) - ); -} - -const removeImport = (tree: Tree, filePath: string, importName: string) => { - const source = getSourceFile(tree, filePath); - const importsToRemove = findFullImports(importName, source); - - importsToRemove.forEach(declaration => - removeNode(declaration, filePath, tree) - ); -}; diff --git a/src/refactor-nsng-modules/index.ts b/src/refactor-nsng-modules/index.ts new file mode 100644 index 0000000..4cc8987 --- /dev/null +++ b/src/refactor-nsng-modules/index.ts @@ -0,0 +1,181 @@ +import { join } from 'path'; + +import { + chain, + Tree, +} from '@angular-devkit/schematics'; +import { insertImport } from '../route-utils'; + +import { Schema } from './schema'; +import { getJsonFile, getSourceFile, removeNode } from '../utils'; +import { + collectDeepNodes, + filterByChildNode, + findImports, + getDecoratedClasses, + getDecoratorMetadataFromClass, + getNodesToRemoveFromNestedArray, + getSymbolsToAddToObject, + removeImport, + getDecoratedClass, +} from '../ast-utils'; + +import * as ts from 'typescript'; +import { SchematicsException } from '@angular-devkit/schematics/src/exception/exception'; +import { InsertChange } from '@schematics/angular/utility/change'; + +export default function (options: Schema) { + const { sourceDir } = options; + + return chain([ + (tree: Tree) => { + const entry = getEntryModule(tree, sourceDir); + const { rootModule, rootModulePath } = getBootstrappedModule(tree, entry, sourceDir); + + let animationModuleIsUsed = false; + tree.visit(path => { + if ( + path.startsWith('/node_modules') || + path.startsWith('/platforms') || + !path.endsWith('.ts') || + path === `/${rootModulePath}` + ) { + return; + } + + const ngModules = getDecoratedClasses(tree, path, 'NgModule'); + const metadataObjects = ngModules + .map(m => ({ + metadataObject: getDecoratorMetadataFromClass(m, 'NgModule') as ts.ObjectLiteralExpression, + classNode: m, + })) + .filter(({ metadataObject }) => !!metadataObject); + + metadataObjects.forEach(({ metadataObject, classNode }) => { + const nativeScriptModuleRemoved = + removeImportedNgModule(tree, path, metadataObject, 'NativeScriptModule'); + if (nativeScriptModuleRemoved) { + metadataObject = refetchMetadata(tree, path, classNode); + importNgModule(tree, path, metadataObject, 'NativeScriptCommonModule', 'nativescript-angular/common'); + } + + metadataObject = refetchMetadata(tree, path, classNode); + const animationsModuleRemoved = + removeImportedNgModule(tree, path, metadataObject, 'NativeScriptAnimationsModule'); + animationModuleIsUsed = animationModuleIsUsed || animationsModuleRemoved; + }); + + return true; + }); + + if (animationModuleIsUsed) { + const rootModuleMetadata = getDecoratorMetadataFromClass(rootModule !, 'NgModule') as ts.ObjectLiteralExpression; + importNgModule( + tree, + rootModulePath, + rootModuleMetadata, + 'NativeScriptAnimationsModule', + 'nativescript-angular/animations' + ); + } + } + ]); +} + +const getEntryModule = (tree: Tree, sourceDir: string) => { + const innerPackageJson = getJsonFile(tree, `${sourceDir}/package.json`); + const entry = innerPackageJson.main; + const tsEntry = entry.replace(/\.js$/i, '.ts'); + + return `${sourceDir}/${tsEntry}`; +}; + +const getBootstrappedModule = (tree: Tree, path: string, sourceDir: string) => { + const entrySource = getSourceFile(tree, path); + const bootstrappedModules = collectDeepNodes(entrySource, ts.SyntaxKind.CallExpression) + .filter(node => filterByChildNode(node, (child: ts.Node) => + child.kind === ts.SyntaxKind.PropertyAccessExpression && + ['bootstrapModule', 'bootstrapModuleNgFactory'].includes( + (child).name.getFullText() + ) + ) + ) + .map((node: ts.CallExpression) => node.arguments[0]); + + if (bootstrappedModules.length !== 1) { + throw new SchematicsException(`You should have exactly one bootstrapped module inside ${path}!`); + } + + const moduleName = bootstrappedModules[0].getText(); + const imports = findImports(moduleName, entrySource); + const lastImport = imports[imports.length - 1]; + const moduleSpecifier = lastImport.moduleSpecifier.getText(); + const moduleRelativePath = `${moduleSpecifier.replace(/"|'/g, '')}.ts`; + + const rootModulePath = join(sourceDir, moduleRelativePath); + const rootModule = getDecoratedClasses(tree, rootModulePath, 'NgModule') + .find(c => !!(c.name && c.name.getText() === moduleName)); + + return { rootModule, rootModulePath }; +}; + +const refetchMetadata = (tree: Tree, path: string, classNode: ts.ClassDeclaration) => { + const newClassNode = getDecoratedClass(tree, path, 'NgModule', classNode.name!.getText())!; + const newMetadataObject = getDecoratorMetadataFromClass(newClassNode, 'NgModule') as ts.ObjectLiteralExpression; + + return newMetadataObject; +}; + +const importNgModule = ( + tree: Tree, + path: string, + metadataObject: ts.ObjectLiteralExpression, + name: string, + importPath: string +) => { + const nodesToAdd = getSymbolsToAddToObject(path, metadataObject, 'imports', name); + const recorder = tree.beginUpdate(path); + nodesToAdd.forEach(change => { + recorder.insertRight(change.pos, change.toAdd) + }); + tree.commitUpdate(recorder); + + const source = getSourceFile(tree, path); + const newImport = insertImport(source, path, name, importPath) as InsertChange; + const importRecorder = tree.beginUpdate(path); + if (newImport.toAdd) { + importRecorder.insertLeft(newImport.pos, newImport.toAdd); + } + tree.commitUpdate(importRecorder); +}; + +const removeImportedNgModule = ( + tree: Tree, + path: string, + metadataObject: ts.ObjectLiteralExpression, + name: string +) => { + const removed = removeNgModuleFromMetadata(tree, path, metadataObject, name); + if (removed) { + removeImport(tree, path, name); + } + + return removed; +}; + +const removeNgModuleFromMetadata = ( + tree: Tree, + path: string, + metadataObject: ts.ObjectLiteralExpression, + name: string +): boolean => { + const metadataImports = getNodesToRemoveFromNestedArray([metadataObject], 'imports', name); + const isInMetadata = !!metadataImports.length; + if (isInMetadata) { + metadataImports.forEach(declaration => { + removeNode(declaration, path, tree) + }); + } + + return isInMetadata; +}; diff --git a/src/refactor-nsng-modules/schema.d.ts b/src/refactor-nsng-modules/schema.d.ts new file mode 100644 index 0000000..d17df76 --- /dev/null +++ b/src/refactor-nsng-modules/schema.d.ts @@ -0,0 +1,6 @@ +export interface Schema { + /** + * The path of the source directory. + */ + sourceDir: string; +} diff --git a/src/refactor-nsng-modules/schema.json b/src/refactor-nsng-modules/schema.json new file mode 100644 index 0000000..a49cd94 --- /dev/null +++ b/src/refactor-nsng-modules/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsRefactorNsNgModules", + "title": "Refactor NativeScript Angular Modules Options Schema", + "type": "object", + "properties": { + "sourceDir": { + "type": "string", + "description": "The path of the source directory.", + "default": "app" + } + } +} + diff --git a/src/route-utils.ts b/src/route-utils.ts new file mode 100644 index 0000000..b9ff0ac --- /dev/null +++ b/src/route-utils.ts @@ -0,0 +1,86 @@ +import * as ts from 'typescript'; +import { Change, NoopChange } from '@schematics/angular/utility/change'; +import { findNodes } from '@schematics/angular/utility/ast-utils'; +import { insertBeforeFirstOccurence } from './ast-utils'; + +/** +* 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 quote (specifies the type of quotes that should be used) +* @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, + quote: '\"'|'\''|'\`' = '\"', + 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(child => child.kind === ts.SyntaxKind.StringLiteral) + .map(n => (n as ts.StringLiteral).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 insertBeforeFirstOccurence(imports, `, ${symbolName}`, fileToEdit, fallbackPos); + } + + return new NoopChange(); + } + + // no such import declaration exists + const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral) + .filter((n: ts.StringLiteral) => n.text === 'use strict'); + let fallbackPos = 0; + if (useStrict.length > 0) { + fallbackPos = useStrict[0].end; + } + const open = isDefault ? '' : '{ '; + const close = isDefault ? '' : ' }'; + const toInsert = `import ${open}${symbolName}${close}` + + ` from ${quote}${fileName}${quote};\n`; + + return insertBeforeFirstOccurence( + allImports, + toInsert, + fileToEdit, + fallbackPos, + // ts.SyntaxKind.StringLiteral, + ); +} diff --git a/src/utils.ts b/src/utils.ts index 97260f3..b309cd8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,7 +8,12 @@ import { configPath, CliConfig } from '@schematics/angular/utility/config'; import { strings as angularStringUtils } from '@angular-devkit/core'; import * as ts from 'typescript'; -import { Node } from "./ast-utils"; +export interface Node { + getStart(); + getFullStart(); + getEnd(); +} + class FileNotFoundException extends Error { constructor(fileName: string) { @@ -155,7 +160,7 @@ const setDependency = ( const getPackageJson = (tree: Tree) => getJsonFile(tree, 'package.json'); -const getJsonFile = (tree: Tree, path: string) => { +export const getJsonFile = (tree: Tree, path: string) => { const file = tree.get(path); if (!file) { throw new FileNotFoundException(path);