From 7d24f53d1ed76ed01f0cb149f2ef981c456b3b64 Mon Sep 17 00:00:00 2001 From: Ashok Tamang Date: Wed, 10 Aug 2016 11:14:03 -0700 Subject: [PATCH] feat: add 'ng promote' command add design doc for 'promote' process --- addon/ng2/commands/promote.ts | 108 +++++++++++++++ addon/ng2/index.js | 1 + addon/ng2/utilities/module-resolver.ts | 2 +- docs/design/promote.md | 175 +++++++++++++++++++++++++ tests/e2e/e2e_workflow.spec.js | 46 +++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 addon/ng2/commands/promote.ts create mode 100644 docs/design/promote.md diff --git a/addon/ng2/commands/promote.ts b/addon/ng2/commands/promote.ts new file mode 100644 index 000000000000..1e0f02cc95d6 --- /dev/null +++ b/addon/ng2/commands/promote.ts @@ -0,0 +1,108 @@ +import * as Command from 'ember-cli/lib/models/command'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as glob from 'glob'; +import * as chalk from 'chalk'; +import * as SilentError from 'silent-error'; +import * as denodeify from 'denodeify'; +import { Promise } from 'es6-promise'; +import { ModuleResolver } from '../utilities/module-resolver'; +import * as dependentFilesUtils from '../utilities/get-dependent-files'; + +// denodeify asynchronous methods +// const stat = denodeify(fs.stat); +const move = denodeify(fs.rename); +// const access = denodeify(fs.access); +const globSearch = denodeify(glob); + +const PromoteCommand = Command.extend({ + name: 'promote', + description: 'Rewrites a file\'s relative imports and other component unit\'s dependencies' + + 'on that moved file to reflect the change in location of the file. ' + + 'Then, it moves the file and its associated files to the new locaiton.', + aliases: ['p'], + works: 'insideProject', + anonymousOptions: [' '], + + beforeRun: function(rawArgs: string[]) { + // All operations should be sync in beforeRun. + // Promise.reject() still causes 'run` attribute to execute. + + if (rawArgs.length === 0) { + throw new SilentError(chalk.red('Please pass the arguments: ') + + chalk.cyan('ng promote ')); + } + // Current Directory in the project. + const CWD = process.env.PWD; + + let filePaths: string[] = rawArgs + .map((argument: string) => path.resolve(CWD, argument)); + + // Validate first argument + let oldPath = filePaths[0]; + + const oldPathStats = fs.statSync(oldPath); + // Check if it is a file. + if (!oldPathStats.isFile()) { + throw new SilentError(chalk.red('Give the full path of file.')); + }; + // Throw error if a file is not a typescript file. + if (path.extname(oldPath) !== '.ts') { + throw new SilentError(`The file is not a typescript file: ${oldPath}`); + }; + // Throw error if a file is an index file. + if (path.basename(oldPath) === 'index.ts') { + throw new SilentError(`Cannot promote index file: ${oldPath}`); + }; + // Throw error if a file is a spec file. + if (path.extname(path.basename(oldPath, '.ts')) === '.spec') { + throw new SilentError(`Cannot promote a spec file: ${oldPath}`); + }; + // Check the permission to read and/or write in the file. + fs.accessSync(oldPath, fs.R_OK || fs.W_OK); + + // Validate second argument + const newPath = filePaths[1]; + const newPathStats = fs.statSync(newPath); + + // Check if it is a directory + if (!newPathStats.isDirectory) { + throw new SilentError(`newPath must be a directory: ${newPath}`); + }; + // Check the permission to read/write/execute(move) in the directory. + fs.accessSync(newPath, fs.R_OK || fs.X_OK || fs.W_OK); + // Check for any files with the same name as oldPath. + let sameNameFiles = glob.sync(path.join(newPath, '*.*.ts'), { nodir: true }) + .filter((file) => path.basename(file) === path.basename(oldPath)); + if (sameNameFiles.length > 0) { + throw new SilentError(`newPath has a file with same name as oldPath: ${sameNameFiles}`); + }; + }, + + run: function (commandOptions, rawArgs: string[]) { + // Get absolute paths of old path and new path + let filePaths: string[] = rawArgs + .map((argument: string) => path.resolve(process.env.PWD, argument)); + const oldPath = filePaths[0]; + const newPath = filePaths[1]; + const ROOT_PATH = path.resolve('src/app'); + let resolver = new ModuleResolver(oldPath, newPath, ROOT_PATH); + console.log('Promoting...'); + return Promise.all([ + resolver.resolveDependentFiles(), + resolver.resolveOwnImports() + ]) + .then(([changesForDependentFiles, changesForOwnImports]) => { + let allChanges = changesForDependentFiles.concat(changesForOwnImports); + return resolver.applySortedChangePromise(allChanges); + }) + // Move the related files to new path. + .then(() => dependentFilesUtils.getAllAssociatedFiles(oldPath)) + .then((files: string[]) => { + return files.map((file) => move(file, path.join(newPath, path.basename(file)))); + }) + .then(() => console.log(`${chalk.green(oldPath)} is promoted to ${chalk.green(newPath)}.`)); + }, +}); + +module.exports = PromoteCommand; diff --git a/addon/ng2/index.js b/addon/ng2/index.js index 1d6248f0c179..c06514abdcfd 100644 --- a/addon/ng2/index.js +++ b/addon/ng2/index.js @@ -24,6 +24,7 @@ module.exports = { 'completion': require('./commands/completion'), 'doc': require('./commands/doc'), 'github-pages-deploy': require('./commands/github-pages-deploy'), + 'promote': require('./commands/promote'), // Easter eggs. 'make-this-awesome': require('./commands/easter-egg')('make-this-awesome'), diff --git a/addon/ng2/utilities/module-resolver.ts b/addon/ng2/utilities/module-resolver.ts index d02c97d54404..823929badd65 100644 --- a/addon/ng2/utilities/module-resolver.ts +++ b/addon/ng2/utilities/module-resolver.ts @@ -67,7 +67,7 @@ export class ModuleResolver { let tempChanges: ReplaceChange[] = files[file] .map(specifier => { let componentName = path.basename(this.oldFilePath, '.ts'); - let fileDir = path.dirname(file); + let fileDir = path.dirname(path.normalize(file)); let changeText = path.relative(fileDir, path.join(this.newFilePath, componentName)); if (changeText.length > 0 && changeText.charAt(0) !== '.') { changeText = `.${path.sep}${changeText}`; diff --git a/docs/design/promote.md b/docs/design/promote.md new file mode 100644 index 000000000000..9e623c67bd11 --- /dev/null +++ b/docs/design/promote.md @@ -0,0 +1,175 @@ +Component Promotion +=================== + +Abstract +----------- +One of the main features of the Angular CLI is to create a nested structure of components for the organization of an Angular application. Occasionally, components in one particular level of the application structure tree can be useful in other parts of the application, and hence components tend to be moved (by Developers) along the structure tree. Currently, when such cases arise, the developers have to manually look for the component’s relative imports and other component units’ dependencies on that moved component, and resolve those imports to reflect the change in the location. + +Why is it important to Angular? +---------------------------------------------- +The goal of Angular CLI is to help facilitate the Angular developers to organize and develop Angular projects efficiently. With the implementation of ‘promote’ command in the CLI, the workflow will be more automated as the command will further aid the CLI to reduce the errors caused by typos generated from manual fixes of relative imports to reflect the promotion process of a given component unit. + + +Promote Process (Detailed Design) +---------------------------------------------- + +### Assumptions and Implementation + +1. The command works **only inside the project** created by [angular-cli](https://github.com/angular/angular-cli). + * The root directory of the project is assumed to be `angular-cli-project/src/app`. +2. In order to prevent cluttered code-flow in the command source file, the implemenation for command consists of using + utility functions and a class implemented separately. + * `get-dependent-files.ts`: + Given a file name, the function parses every Typescript files under the root directory into AST Typescript source file + as defined by Typescript Language Service API. Then, function iteratively filters out relative imports from each file + to determine which file is importing the given file. + + The file composes of exported utility functions: + ``` + /** + * Interface that represents a module specifier and its position in the source file. + * Use for storing a string literal, start position and end posittion of ImportClause node kinds. + */ + export interface ModuleImport { + specifierText: string; + pos: number; + end: number; + }; + + export interface ModuleMap { + [key: string]: ModuleImport[]; + } + + /** + * Create a SourceFile as defined by Typescript Compiler API. + * Generate a AST structure from a source file. + * + * @param fileName source file for which AST is to be extracted + */ + export function createTsSourceFile(fileName: string): Promise {} + + /** + * Traverses through AST of a given file of kind 'ts.SourceFile', filters out child + * nodes of the kind 'ts.SyntaxKind.ImportDeclaration' and returns import clauses as + * ModuleImport[] + * + * @param {ts.SourceFile} node: Typescript Node of whose AST is being traversed + * + * @return {ModuleImport[]} traverses through ts.Node and returns an array of moduleSpecifiers. + */ + export function getImportClauses(node: ts.SourceFile): ModuleImport[] {} + + /** + * Find the file, 'index.ts' given the directory name and return boolean value + * based on its findings. + * + * @param dirPath + * + * @return a boolean value after it searches for a barrel (index.ts by convention) in a given path + */ + export function hasIndexFile(dirPath: string): Promise {} + + /** + * Returns a map of all dependent file/s' path with their moduleSpecifier object + * (specifierText, pos, end) + * + * @param fileName file upon which other files depend + * @param rootPath root of the project + * + * @return {Promise} ModuleMap of all dependent file/s (specifierText, pos, end) + * + */ + export function getDependentFiles(fileName: string, rootPath: string): Promise {} + ``` + * `Change` API: Then Change Interface provides a way to insert/remove/replace string in a given file. + The implementation of Change API is provided [here](https://github.com/hansl/angular-cli/blob/7ea3e78ff3d899d5277aac5dfeeece4056d0efe3/docs/design/upgrade.md#change-api). + + * `class ModuleResolver` + ``` + class ModuleResolver { + + constructor(public oldFilePath: string, public newFilePath: string, public rootPath: string) {} + + /** + * Changes are applied from the bottom of a file to the top. + * An array of Change instances are sorted based upon the order, + * then apply() method is called sequentially. + * + * @param changes {Change []} + * @return Promise after all apply() method of Change class is called + * to all Change instances sequentially. + */ + applySortedChangePromise(changes: Change[]): Promise {} + + /** + * Assesses the import specifier and determines if it is a relative import. + * + * @return {boolean} boolean value if the import specifier is a relative import. + */ + isRelativeImport(importClause: dependentFilesUtils.ModuleImport): boolean {} + + /** + * Rewrites the import specifiers of all the dependent files (cases for no index file). + * + * @todo Implement the logic for rewriting imports of the dependent files when the file + * being moved has index file in its old path and/or in its new path. + * + * @return {Promise} + */ + resolveDependentFiles(): Promise {} + + /** + * Rewrites the file's own relative imports after it has been moved to new path. + * + * @return {Promise} + */ + resolveOwnImports(): Promise {} + } + ``` +3. The implementation adopts asynchronous operations in every applicable scenarios so that the process of + `promote` command does not block other processes. Promises used in the implemenation are adopted from ES6 Promise. + +### Components of `Promote` command + +The project constituents a new ‘ng’ command system. The command will look as follows: +* ng promote `oldPath` `newPath` +* The command only takes two arguments: + * oldPath: the path to the file being promoted. + * newPath: the path to the directory where the file is being promoted. +* The command has no other options available besides `--help`. +* The command should only execute inside an Angular project created by angular-cli. + +Validation +--------------- + +Validation is required for the two arguments in the promote command. It is an important part of the process. +* Validation executes in three parts which are done in order: + + 1. The two arguments of the command (`oldPath` and `newPath`) should always be passed. + 2. Validtion in `oldPath` + * The file should exist inside the project. + * The file should be a TypeScript file. + * The owner should have read/write permission of the file. + 3. Validation in `newPath` + * The argument should be a directory. + * The directory must exist. (the command doesn’t create a new directory) + * The directory must not contain file with same name as `oldPath`. + * The owner should have read/write/execute permission of the directory. +* Validation is done in the `BeforeRun:` if any of the validation throws an error, whole promote process is stopped. + +Process (Steps) +---------------- +The promote command is extended from `Ember CLI`'s [`Command`](https://github.com/ember-cli/ember-cli/blob/master/lib/models/command.js) +object. +The command will execute in two attribute methods of `Command` Object. + +* `beforeRun`: + 1. Validation process is executed. If any of the validation steps fails, then whole process is stopped with an `Error`. +* `run`: + 1. Parse the provided arguments to get the absolute path. + 2. Create a new instance of class ModuleResolver + 3. Store all the changes for rewriting the imports of all dependent files in memory. + 4. Store all the changes for rewriting the imports of the moved file itself in memory. + 5. Apply all the changes. + 6. Move the file and its associated files to new path. + diff --git a/tests/e2e/e2e_workflow.spec.js b/tests/e2e/e2e_workflow.spec.js index deb515ffd943..2b184498c52a 100644 --- a/tests/e2e/e2e_workflow.spec.js +++ b/tests/e2e/e2e_workflow.spec.js @@ -11,6 +11,8 @@ var treeKill = require('tree-kill'); var child_process = require('child_process'); var ng = require('../helpers/ng'); var root = path.join(process.cwd(), 'tmp'); +var InsertChange = require('../../addon/ng2/utilities/change').InsertChange; +var dependentFilesUtils = require('../../addon/ng2/utilities/get-dependent-files'); function existsSync(path) { try { @@ -249,6 +251,50 @@ describe('Basic end-to-end Workflow', function () { }); }); + it('Can promote a component file using `ng promote `', () => { + // Create two new component and then make test-foo depend on test-bar. + const fooComponentDir = path.join(process.cwd(), 'src', 'app', 'test-foo'); + const barComponentDir = path.join(process.cwd(), 'src', 'app', 'test-bar'); + const testFooFile = path.join(fooComponentDir, 'test-foo.component.ts'); + const testBarFile = path.join(barComponentDir, 'test-bar.component.ts'); + const promotionComponentDir = path.join(process.cwd(), 'src', 'app', 'test-promotion'); + return ng(['generate', 'component', 'test-foo']) + .then(() => ng(['generate', 'component', 'test-bar'])) + .then(() => { + // Remove index file to handle base cases only (for now). + sh.rm(path.join(fooComponentDir, 'index.ts')); + sh.rm(path.join(barComponentDir, 'index.ts')); + // Create an empty directory to promote a component into. + sh.mkdir(promotionComponentDir); + // Insert import statement reflecting the dependence of test-foo on test-bar. + let addText = `import { TestBarComponent } from '../test-bar/test-bar.component';\n`; + let addChange = new InsertChange(testFooFile, 0, addText); + return addChange.apply(); + }) + .then(() => ng(['promote', testBarFile, promotionComponentDir])) + .then(() => dependentFilesUtils.createTsSourceFile(testFooFile)) + .then((tsFooFile) => dependentFilesUtils.getImportClauses(tsFooFile)) + .then((specifiers) => { + // Check if the specifiers reflect the change of component unit in application structure. + let expectedTestBarContent = path.normalize('../test-promotion/test-bar.component'); + expect(specifiers[0].specifierText).to.equal(expectedTestBarContent); + // Check if all the related files of the promoted component exist in new path. + expect(existsSync(path.join(promotionComponentDir, 'test-bar.component.ts'))).to.equal(true); + expect(existsSync(path.join(promotionComponentDir, 'test-bar.component.html'))).to.equal(true); + expect(existsSync(path.join(promotionComponentDir, 'test-bar.component.css'))).to.equal(true); + expect(existsSync(path.join(promotionComponentDir, 'test-bar.component.spec.ts'))).to.equal(true); + }); + }); + + it('Perform `ng test` after promoting a compilation unit', function () { + this.timeout(420000); + + return ng(testArgs).then(function (result) { + const exitCode = typeof result === 'object' ? result.exitCode : result; + expect(exitCode).to.be.equal(0); + }); + }); + xit('Can create a test route using `ng generate route test-route`', function () { return ng(['generate', 'route', 'test-route']).then(function () { var routeDir = path.join(process.cwd(), 'src', 'app', '+test-route');