Skip to content

Commit

Permalink
feat: add support for manifest file
Browse files Browse the repository at this point in the history
Manifest file improves the speed drastically by caching the metadata
of commands and lazy loading only the executed command.
  • Loading branch information
thetutlage committed May 29, 2019
1 parent ebf98b4 commit dae9a53
Show file tree
Hide file tree
Showing 10 changed files with 333 additions and 58 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"tag": "next"
},
"devDependencies": {
"@adonisjs/dev-utils": "^1.4.0",
"@adonisjs/mrm-preset": "^2.0.3",
"@types/node": "^12.0.2",
"commitizen": "^3.1.1",
Expand Down
14 changes: 14 additions & 0 deletions src/Contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,17 @@ export interface CommandContract {
parsed?: ParsedOptions,
handle (): Promise<void>,
}

/**
* Shape of a command inside the manifest file.
*/
export type ManifestCommand = Pick<
CommandConstructorContract, Exclude<keyof CommandConstructorContract, 'new'>
> & { commandPath: string }

/**
* Shape of manifest JSON file
*/
export type ManifestNode = {
[command: string]: ManifestCommand,
}
32 changes: 32 additions & 0 deletions src/Exceptions/CommandValidationException.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* @adonisjs/ace
*
* (c) Harminder Virk <virk@adonisjs.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { Exception } from '@poppinss/utils'

/**
* CommandValidationException is used when validating a command before
* registering it with Ace.
*/
export class CommandValidationException extends Exception {
public static invalidManifestExport (commandPath: string) {
return new this(`make sure to have a default export from {${commandPath}}`)
}

public static missingCommandName (className: string) {
return new this(`missing command name for {${className}} class`)
}

public static invalidSpreadArgOrder (arg: string) {
return new this(`spread argument {${arg}} must be at last position`)
}

public static invalidOptionalArgOrder (optionalArg: string, currentArg: string) {
return new this(`optional argument {${optionalArg}} must be after required argument {${currentArg}}`)
}
}
15 changes: 13 additions & 2 deletions src/Exceptions/InvalidArgumentException.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,25 @@

import { Exception } from '@poppinss/utils'

/**
* InvalidArgumentException is raised when command arguments
* or flags doesn't satisfy the requirements of a given
* command.
*/
export class InvalidArgumentException extends Exception {
/**
* Argument or flag type validation failed.
*/
public static invalidType (prop: string, expected: string) {
const message = `${prop} must be defined as a ${expected}`
const message = `{${prop}} must be defined as a {${expected}}`
return new InvalidArgumentException(message, 500)
}

/**
* A required argument is missing
*/
public static missingArgument (name: string) {
const message = `missing required argument ${name}`
const message = `missing required argument {${name}}`
return new InvalidArgumentException(message, 500)
}
}
53 changes: 5 additions & 48 deletions src/Kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,13 @@
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { Parser } from '../Parser'
import {
CommandConstructorContract,
CommandFlag,
GlobalFlagHandler,
CommandArg,
} from '../Contracts'

import * as getopts from 'getopts'

import { Parser } from '../Parser'
import { validateCommand } from '../utils/validateCommand'
import { CommandConstructorContract, CommandFlag, GlobalFlagHandler } from '../Contracts'

/**
* Ace kernel class is used to register, find and invoke commands by
* parsing `process.argv.splice(2)` value.
Expand All @@ -31,46 +28,6 @@ export class Kernel {
*/
public flags: { [name: string]: CommandFlag & { handler: GlobalFlagHandler } } = {}

/**
* Since arguments are matched based on their position, we need to make
* sure that the command author doesn't put optional args before the
* required args.
*
* The concept is similar to Javascript function arguments, you cannot have a
* required argument after an optional argument.
*/
private _validateCommand (command: CommandConstructorContract) {
/**
* Ensure command has a name
*/
if (!command.commandName) {
throw new Error(`missing command name for ${command.name} class`)
}

let optionalArg: CommandArg

command.args.forEach((arg, index) => {
/**
* Ensure optional arguments comes after required
* arguments
*/
if (optionalArg && arg.required) {
throw new Error(`option argument {${optionalArg.name}} must be after required argument {${arg.name}}`)
}

/**
* Ensure spread arg is the last arg
*/
if (arg.type === 'spread' && command.args.length > index + 1) {
throw new Error('spread arguments must be last')
}

if (!arg.required) {
optionalArg = arg
}
})
}

/**
* Executing global flag handlers. The global flag handlers are
* not async as of now, but later we can look into making them
Expand Down Expand Up @@ -101,7 +58,7 @@ export class Kernel {
*/
public register (commands: CommandConstructorContract[]): this {
commands.forEach((command) => {
this._validateCommand(command)
validateCommand(command)
this.commands[command.commandName] = command
})

Expand Down
92 changes: 92 additions & 0 deletions src/Manifest/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* @adonisjs/cli
*
* (c) Harminder Virk <virk@adonisjs.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { join } from 'path'
import { esmRequire } from '@poppinss/utils'
import { writeFile, readFile } from 'fs'

import { CommandValidationException } from '../Exceptions/CommandValidationException'
import { validateCommand } from '../utils/validateCommand'
import { ManifestNode, CommandConstructorContract } from '../Contracts'

/**
* Manifest class drastically improves the commands performance, by generating
* a manifest file for all the commands and lazy load only the executed
* command.
*/
export class Manifest {
constructor (private _appRoot: string) {
}

/**
* Require and return command
*/
private _getCommand (commandPath: string): CommandConstructorContract {
const command = esmRequire(join(this._appRoot, commandPath))
if (!command.name) {
throw CommandValidationException.invalidManifestExport(commandPath)
}

validateCommand(command)
return command
}

/**
* Write file to the disk
*/
private _writeManifest (manifest: ManifestNode): Promise<void> {
return new Promise((resolve, reject) => {
writeFile(join(this._appRoot, 'ace-manifest.json'), JSON.stringify(manifest), (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}

/**
* Generates the manifest file for the given command paths
*/
public async generate (commandPaths: string[]) {
const manifest = commandPaths.reduce((manifest: ManifestNode, commandPath) => {
const command = this._getCommand(commandPath)

manifest[command.commandName] = {
commandPath: commandPath,
commandName: command.commandName,
description: command.description,
args: command.args,
flags: command.flags,
}

return manifest
}, {})

await this._writeManifest(manifest)
}

/**
* Load the manifest file from the disk. An exception is raised
* when `manifest` file is missing. So the consumer must ensure
* that file exists before calling this method.
*/
public load (): Promise<ManifestNode> {
return new Promise((resolve, reject) => {
readFile(join(this._appRoot, 'ace-manifest.json'), 'utf-8', (error, contents) => {
if (error) {
reject(error)
} else {
resolve(JSON.parse(contents))
}
})
})
}
}
47 changes: 47 additions & 0 deletions src/utils/validateCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* @adonisjs/ace
*
* (c) Harminder Virk <virk@adonisjs.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import { CommandConstructorContract, CommandArg } from '../Contracts'
import { CommandValidationException } from '../Exceptions/CommandValidationException'

/**
* Validates the command static properties to ensure that all the
* values are correctly defined for a command to be executed.
*/
export function validateCommand (command: CommandConstructorContract) {
/**
* Ensure command has a name
*/
if (!command.commandName) {
throw CommandValidationException.missingCommandName(command.name)
}

let optionalArg: CommandArg

command.args.forEach((arg, index) => {
/**
* Ensure optional arguments comes after required
* arguments
*/
if (optionalArg && arg.required) {
throw CommandValidationException.invalidOptionalArgOrder(optionalArg.name, arg.name)
}

/**
* Ensure spread arg is the last arg
*/
if (arg.type === 'spread' && command.args.length > index + 1) {
throw CommandValidationException.invalidSpreadArgOrder(arg.name)
}

if (!arg.required) {
optionalArg = arg
}
})
}
8 changes: 4 additions & 4 deletions test/kernel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test.group('Kernel | register', () => {

const kernel = new Kernel()
const fn = () => kernel.register([Greet])
assert.throw(fn, 'option argument {name} must be after required argument {age}')
assert.throw(fn, 'optional argument {name} must be after required argument {age}')
})

test('raise error when command name is missing', (assert) => {
Expand All @@ -36,7 +36,7 @@ test.group('Kernel | register', () => {

const kernel = new Kernel()
const fn = () => kernel.register([Greet])
assert.throw(fn, 'missing command name for Greet class')
assert.throw(fn, 'missing command name for {Greet} class')
})

test('raise error when spread argument isn\'t the last one', (assert) => {
Expand All @@ -52,7 +52,7 @@ test.group('Kernel | register', () => {

const kernel = new Kernel()
const fn = () => kernel.register([Greet])
assert.throw(fn, 'spread arguments must be last')
assert.throw(fn, 'spread argument {files} must be at last position')
})

test('return command suggestions for a given string', (assert) => {
Expand Down Expand Up @@ -107,7 +107,7 @@ test.group('Kernel | handle', () => {
try {
await kernel.handle(argv)
} catch ({ message }) {
assert.equal(message, 'missing required argument name')
assert.equal(message, 'missing required argument {name}')
}
})

Expand Down
Loading

0 comments on commit dae9a53

Please sign in to comment.