Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: lahmatiy/clap
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 7fc074cde7eeb43353acad0f0f2b3f09f5c86fdc
Choose a base ref
...
head repository: lahmatiy/clap
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: d77cbe80a7c53c551e283e4cc9f4e02648d3130c
Choose a head ref

Commits on Dec 8, 2019

  1. Update deps, drop nodejs < 8.0 and refactoring

    - Added config argument for Command and create function
        * defaultHelp option to prevent adding --help option on command create
        * infoOptionAction option to override action when info option involved (output to stdout and exit(0) by default)
    - Added Command#infoOption() method
    lahmatiy committed Dec 8, 2019

    Unverified

    This user has not yet uploaded their public signing key.
    Copy the full SHA
    f40ec08 View commit details
  2. Copy the full SHA
    396dfe9 View commit details
  3. Copy the full SHA
    e11beaa View commit details
  4. Copy the full SHA
    1988cf2 View commit details
  5. Bump mocha

    lahmatiy committed Dec 8, 2019
    Copy the full SHA
    005c88c View commit details
  6. Copy the full SHA
    0e6e4ea View commit details
  7. Remove missed fs in test

    lahmatiy committed Dec 8, 2019
    Copy the full SHA
    efe9678 View commit details
  8. Remove some redundant code

    - Remove error() method
    lahmatiy committed Dec 8, 2019
    Copy the full SHA
    99c9d36 View commit details
  9. Copy the full SHA
    453b4c0 View commit details
  10. Remove confirm()

    lahmatiy committed Dec 8, 2019
    Copy the full SHA
    c0c8e2b View commit details
  11. Add some tests

    lahmatiy committed Dec 8, 2019
    Copy the full SHA
    2be718b View commit details

Commits on Dec 9, 2019

  1. 2.0.0

    lahmatiy committed Dec 9, 2019
    Copy the full SHA
    cd9d3b3 View commit details

Commits on Dec 16, 2019

  1. Copy the full SHA
    71e0fa3 View commit details
  2. Refactoring of help output

    lahmatiy committed Dec 16, 2019
    Copy the full SHA
    f2a505e View commit details
  3. 2.0.1

    lahmatiy committed Dec 16, 2019
    Copy the full SHA
    03d9704 View commit details

Commits on Dec 28, 2019

  1. Fixed Command#showHelp() (#12)

    * Fixed Command#showHelp() to output a help in stdout
    exdis authored and lahmatiy committed Dec 28, 2019
    Copy the full SHA
    7362b81 View commit details
  2. Restore Command#extend()

    lahmatiy committed Dec 28, 2019
    Copy the full SHA
    9432d88 View commit details
  3. Copy the full SHA
    a625b62 View commit details

Commits on Dec 29, 2019

  1. Internal refactoring

    lahmatiy committed Dec 29, 2019
    Copy the full SHA
    7629b26 View commit details
  2. Internal refactoring and related

    - rename Command#shortcut() -> shortcutOption()
    - rename Command#infoOption() -> actionOption()
    - add Command#hasCommand()
    lahmatiy committed Dec 29, 2019
    Copy the full SHA
    1be18fb View commit details
  3. Copy the full SHA
    9083287 View commit details
  4. Copy the full SHA
    c00586b View commit details
  5. Copy the full SHA
    62163cd View commit details
  6. Copy the full SHA
    fa70ada View commit details
  7. Copy the full SHA
    7f9fef4 View commit details
  8. Refactoring of parseArgv()

    lahmatiy committed Dec 29, 2019
    Copy the full SHA
    f4991db View commit details
  9. Copy the full SHA
    9e89400 View commit details

Commits on Dec 30, 2019

  1. Copy the full SHA
    3157a7d View commit details
  2. Refactoring

    lahmatiy committed Dec 30, 2019
    Copy the full SHA
    9542656 View commit details
  3. Tweak readme

    lahmatiy committed Dec 30, 2019
    Copy the full SHA
    00e96de View commit details
  4. Copy the full SHA
    fc8d489 View commit details
  5. Small fixes

    lahmatiy committed Dec 30, 2019
    Copy the full SHA
    d23a7af View commit details
  6. Remove removed methods

    lahmatiy committed Dec 30, 2019
    Copy the full SHA
    e8ed57b View commit details
  7. Copy the full SHA
    1d8f5e8 View commit details

Commits on Dec 31, 2019

  1. Copy the full SHA
    e591b3f View commit details

Commits on Jan 4, 2020

  1. Copy the full SHA
    0d250ad View commit details
  2. Rename defValue to default

    lahmatiy committed Jan 4, 2020
    Copy the full SHA
    1e0db9c View commit details
  3. Change min offset in help

    lahmatiy committed Jan 4, 2020
    Copy the full SHA
    9973491 View commit details
  4. Copy the full SHA
    9636a19 View commit details
  5. Copy the full SHA
    00c4208 View commit details

Commits on Jan 5, 2020

  1. Copy the full SHA
    5484e17 View commit details

Commits on Jan 9, 2020

  1. Refactoring

    lahmatiy committed Jan 9, 2020
    Copy the full SHA
    41bdd93 View commit details
  2. Copy the full SHA
    fdf8253 View commit details
  3. Refactoring of params

    lahmatiy committed Jan 9, 2020
    Copy the full SHA
    4604256 View commit details

Commits on Feb 14, 2020

  1. 3.0.0-beta.1

    lahmatiy committed Feb 14, 2020
    Copy the full SHA
    e056143 View commit details

Commits on Dec 12, 2021

  1. Update deps

    lahmatiy committed Dec 12, 2021
    Copy the full SHA
    bebe77b View commit details
  2. Copy the full SHA
    4152aef View commit details
  3. Copy the full SHA
    21e2702 View commit details
  4. Copy the full SHA
    2c9e06d View commit details
  5. Migrate to ESM

    lahmatiy committed Dec 12, 2021
    Copy the full SHA
    56597a9 View commit details
10 changes: 10 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# editorconfig.org
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
165 changes: 165 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
{
"env": {
"node": true,
"mocha": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
"no-duplicate-case": 2,
"no-undef": 2,
"no-unused-vars": [
2,
{
"vars": "all",
"args": "after-used"
}
],
"no-empty": [
2,
{
"allowEmptyCatch": true
}
],
"no-implicit-coercion": [
2,
{
"boolean": true,
"string": true,
"number": true
}
],
"no-with": 2,
"brace-style": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multiple-empty-lines": 2,
"no-multi-str": 2,
"dot-location": [
2,
"property"
],
"operator-linebreak": [
2,
"after",
{
"overrides": {
"?": "before",
":": "before"
}
}
],
"key-spacing": [
2,
{
"beforeColon": false,
"afterColon": true
}
],
"space-unary-ops": [
2,
{
"words": false,
"nonwords": false
}
],
"no-spaced-func": 2,
"space-before-function-paren": [
2,
{
"anonymous": "ignore",
"named": "never"
}
],
"array-bracket-spacing": [
2,
"never"
],
"space-in-parens": [
2,
"never"
],
"comma-dangle": [
2,
"never"
],
"no-trailing-spaces": 2,
"yoda": [
2,
"never"
],
"camelcase": [
2,
{
"properties": "never"
}
],
"comma-style": [
2,
"last"
],
"curly": [
2,
"all"
],
"dot-notation": 2,
"eol-last": 2,
"one-var": [
2,
"never"
],
"wrap-iife": 2,
"space-infix-ops": 2,
"keyword-spacing": [
2,
{
"overrides": {
"else": {
"before": true
},
"while": {
"before": true
},
"catch": {
"before": true
},
"finally": {
"before": true
}
}
}
],
"spaced-comment": [
2,
"always"
],
"space-before-blocks": [
2,
"always"
],
"semi": [
2,
"always"
],
"indent": [
2,
4,
{
"SwitchCase": 1
}
],
"linebreak-style": [
2,
"unix"
],
"quotes": [
2,
"single",
{
"avoidEscape": true
}
]
}
}
52 changes: 52 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Build

on:
push:
pull_request:

jobs:
lint-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node 16
uses: actions/setup-node@v2
with:
node-version: 16
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run coverage
env:
REPORTER: "min"
- name: Send coverage to Coveralls
uses: coverallsapp/github-action@1.1.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

run-tests:
needs: lint-coverage
runs-on: ubuntu-latest

strategy:
matrix:
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
node_version:
- 12.20.0
- 14.13.0
- 16

steps:
- uses: actions/checkout@v2
- name: Setup node ${{ matrix.node_version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node_version }}
cache: "npm"
- run: npm ci
- run: npm run test
env:
REPORTER: "min"
- run: npm run esm-to-cjs-and-test
env:
REPORTER: "min"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
node_modules
cjs
cjs-test
coverage
3 changes: 0 additions & 3 deletions .travis.yml

This file was deleted.

97 changes: 97 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
## 3.0.0 (December 12, 2021)

- Allowed args after and between options
- Replaced `chalk` with `ansi-colors`
- Package
- Changed supported versions of Node.js to `^12.20.0`, `^14.13.0` and `>=15.0.0`
- Converted to ESM. CommonJS is supported as well (dual module)

## 3.0.0-beta.1 (February 14, 2020)

- Restored wrongly removed `Command#extend()`
- Changed `Command`'s constructor and `Command#command(method)` to take `usage` only (i.e. `command('name [param]')` instead `command('name', '[param]')`)
- Added `Command#clone()` method
- Added `Command#getCommand(name)` and `Command#getCommands()` methods
- Added `Command#getOption(name)` and `Command#getOptions()` methods
- Added `Command#messageRef()` and `Option#messageRef()` methods
- Added `Command#createOptionValues(values)` method
- Added `Command#help()` method similar to `Command#version()`, use `Command#help(false)` to disable default help action option
- Fixed `Command#showHelp()`, it's now logs help message in console instead of returning it
- Renamed `Command#showHelp()` into `Command#outputHelp()`
- Changed `Command` to store params info (as `Command#params`) even if no params
- Removed `Command#infoOption()` method, use `action` in option's config instead, i.e. `option(usage, description, { action: ... })`
- Removed `Command#infoOptionAction` and `infoOptionAction` option for `Command` constructor as well
- Removed `Command#shortcut()` method, use `shortcut` in option's config instead, i.e. `option(usage, description, { shortcut: ... })`
- Changed `Command#command()` to raise an exception when subcommand name already in use
- Removed `Command#setOptions()` method
- Removed `Command#setOption()` method
- Removed `Command#hasOptions()` method
- Removed `Command#hasOption()` method
- Removed `Command#hasCommands()` method
- Removed `Command#normalize()` method (use `createOptionValues()` instead)
- Changed `Option` to store params info as `Option#params`, it always an object even if no params
- Added `Option#names()` method
- Removed name validation for subcommands
- Allowed a number for options's short name
- Changed `argv` parse handlers to [`init()``applyConfig()``prepareContext()`]+ → `action()`
- Changed exports
- Added `getCommandHelp()` function
- Added `Params` class
- Removed `Argument` class
- Removed `color` option

## 2.0.1 (December 16, 2019)

- Fixed multiline description output in help

## 2.0.0 (December 8, 2019)

- Dropped support for Node < 8
- Bumped deps to latest versions
- Added config argument for Command and create function
- `defaultHelp` option to prevent adding `--`help option on command create
- `infoOptionAction` option to override action when info option involved (output to stdout and `exit(0)` by default)
- Added `Command#infoOption()` method
- Fixed failure on argv parsing when all types of values are passed (i.e. args & options & literal args)
- Renamed `create()` method to `command()`
- Removed `error()` method
- Removed `confirm()` method

## 1.2.3 (September 20, 2017)

- Rolled back passing params to `args()` back as array

## 1.2.2 (September 18, 2017)

- Fixed context passed to `Command#args()`, now it's a command as expected (#10)
- Fixed consuming of literal arguments that wrongly concating with other arguments (i.e. anything going after `--` concats with arguments before `--`)

## 1.2.1 (September 18, 2017)

- Fixed multi value option processing (@tyanas & @smelukov, #9)

## 1.2.0 (June 13, 2017)

- Improved multi value option processing (@smelukov, #7)

## 1.1.3 (March 16, 2017)

- Fixed `Command#normalize()` issue when set a value for option with argument and no default value

## 1.1.2 (December 3, 2016)

- Fix exception on `Command#normalize()`

## 1.1.1 (May 10, 2016)

- Fix `chalk` version

## 1.1.0 (March 19, 2016)

- `Command#extend()` accepts parameters for passed function now
- Implement `Command#end()` method to return to parent command definition
- Fix suggestion bugs and add tests

## 1.0.0 (Oct 12, 2014)

- Initial release
38 changes: 0 additions & 38 deletions HISTORY.md

This file was deleted.

2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (C) 2014-2016 by Roman Dvornov <rdvornov@gmail.com>
Copyright (C) 2014-2021 by Roman Dvornov <rdvornov@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
132 changes: 127 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,133 @@
[![NPM version](https://img.shields.io/npm/v/clap.svg)](https://www.npmjs.com/package/clap)
[![Dependency Status](https://img.shields.io/david/lahmatiy/clap.svg)](https://david-dm.org/lahmatiy/clap)
[![Build Status](https://travis-ci.org/lahmatiy/clap.svg?branch=master)](https://travis-ci.org/lahmatiy/clap)
[![Build Status](https://github.com/lahmatiy/clap/actions/workflows/build.yml/badge.svg)](https://github.com/lahmatiy/clap/actions/workflows/build.yml)
[![Coverage Status](https://coveralls.io/repos/github/lahmatiy/clap/badge.svg?branch=master)](https://coveralls.io/github/lahmatiy/clap?branch=master)

# Clap.js

Argument parser for command-line interfaces. It primary target to large tool sets that provides a lot of subcommands. Support for argument coercion and completion makes task run much easer, even if you doesn't use CLI.
A library for node.js to build command-line interfaces (CLI). With its help, making a simple CLI application is a trivial task. It equally excels in complex tools with a lot of subcommands and specific features. This library supports argument coercion and completion suggestion — typing the commands is much easier.

Inspired by TJ Holowaychuk [Commander](https://github.com/visionmedia/commander.js).
Inspired by [commander.js](https://github.com/tj/commander.js)

[TODO: Complete readme]
Features:

- TBD

## Usage

```
npm install clap
```

```js
const cli = require('clap');

const myCommand = cli.command('my-command [optional-arg]')
.description('Optional description')
.version('1.2.3')
.option('-b, --bool', 'Bollean option')
.option('--foo <foo>', 'Option with required argument')
.option('--bar [bar]', 'Option with optional argument')
.option('--baz [value]', 'Option with optional argument and normalize function',
value => Number(value),
123 // 123 is default
)
.action(function({ options, args, literalArgs }) {
// options is an object with collected values
// args goes before options
// literal args goes after "--"
});

myCommand.run(); // the same as "myCommnad.run(process.argv.slice(2))"
myCommand.run(['--foo', '123', '-b'])

// sub-commands
myCommand
.command('nested')
.option('-q, --quz', 'Some parameter', 'Default value')
// ...
.end()
.command('another-command')
// ...
.command('level3-command')
//...
```

## API

### Command

```
.command()
// definition
.description(value)
.version(value, usage, description, action)
.help(usage, description, action)
.option(usage, description, ...options)
.command(usageOrCommand)
.extend(fn, ...options)
.end()
// argv processing pipeline handler setters
.init(command, context)
.applyConfig(context)
.prerareContenxt(context)
.action(context)
// main methods
.parse(argv, suggest)
.run(argv)
// misc
.clone(deep)
.createOptionValues()
.getCommand(name)
.getCommands()
.getOption(name)
.getOptions()
.outputHelp()
```

### .option(usage, description, ...options)

There are two usage:

```
.option(usage, description, normalize, value)
.option(usage, description, options)
```

Where `options`:

```
{
default: any, // default value
normalize: (value, oldValue) => { ... }, // any value for option is passing through this function and its result stores as option value
shortcut: (value, oldValue) => { ... }, // for shortcut options, the handler is executed after the value is set, and its result (an object) is used as a source of values for other options
action: () => { ... }, // for an action option, which breaks regular args processing and preform and action (e.g. show help or version)
config: boolean // mark option is about config and should be applied before `applyConfig()`
}
```

### Argv processing

- `init(command, context)` // before arguments parsing
- invoke action option and exit if any
- apply **config** options
- `applyConfig(context)`
- apply all the rest options
- `prepareContext(context)` // after arguments parsing
- switch to next command -> command is prescending
- `init(command, context)`
- invoke action option and exit if any
- apply **config** options
- `applyConfig(context)`
- apply all the rest options
- `prepareContext(context)` // after arguments parsing
- switch to next command
- ...
- `action(context)` -> command is target
- `action(context)` -> command is target

## License

MIT
950 changes: 0 additions & 950 deletions index.js

This file was deleted.

240 changes: 240 additions & 0 deletions lib/command.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import getCommandHelp from './help.js';
import parseArgv from './parse-argv.js';
import Params from './params.js';
import Option from './option.js';

const noop = () => {}; // nothing todo
const self = value => value;
const defaultHelpAction = (instance, _, { commandPath }) => instance.outputHelp(commandPath);
const defaultVersionAction = instance => console.log(instance.meta.version);
const lastCommandHost = new WeakMap();
const lastAddedOption = new WeakMap();

export default class Command {
constructor(usage = '') {
const [name, params] = usage.trim().split(/(\s+.*)$/);

this.name = name;
this.params = new Params(params, `"${name}" command definition`);
this.options = new Map();
this.commands = new Map();
this.meta = {
description: '',
version: '',
help: null
};

this.handlers = {
init: noop,
applyConfig: noop,
finishContext: noop,
action: self
};

this.help();
}

// handlers
init(fn) {
this.handlers.init = fn.bind(null);
return this;
}
applyConfig(fn) {
this.handlers.applyConfig = fn.bind(null);
return this;
}
finishContext(fn) {
this.handlers.finishContext = fn.bind(null);
return this;
}
action(fn) {
this.handlers.action = fn.bind(null);
return this;
}

// definition chaining
extend(fn, ...args) {
fn(this, ...args);
return this;
}
description(description) {
this.meta.description = description;

return this;
}
version(version, usage, description, action) {
this.meta.version = version;
this.option(
usage || '-v, --version',
description || 'Output version',
{ action: action || defaultVersionAction }
);

return this;
}
help(usage, description, action) {
if (this.meta.help) {
this.meta.help.names().forEach(name => this.options.delete(name));
this.meta.help = null;
}

if (usage !== false) {
this.option(
usage || '-h, --help',
description || 'Output usage information',
{ action: action || defaultHelpAction }
);
this.meta.help = lastAddedOption.get(this);
}

return this;
}
option(usage, description, ...optionOpts) {
const option = new Option(usage, description, ...optionOpts);
const nameType = ['Long option', 'Short option', 'Option'];
const names = option.names();

names.forEach((name, idx) => {
if (this.options.has(name)) {
throw new Error(
`${nameType[names.length === 2 ? idx * 2 : idx]} name "${name}" already in use by ${this.getOption(name).messageRef()}`
);
}
});

for (const name of names) {
this.options.set(name, option);
}

lastAddedOption.set(this, option);

return this;
}
command(usageOrCommand) {
const subcommand = typeof usageOrCommand === 'string'
? new Command(usageOrCommand)
: usageOrCommand;
const name = subcommand.name;

// search for existing one
if (this.commands.has(name)) {
throw new Error(
`Subcommand name "${name}" already in use by ${this.getCommand(name).messageRef()}`
);
}

// attach subcommand
this.commands.set(name, subcommand);
lastCommandHost.set(subcommand, this);

return subcommand;
}
end() {
const host = lastCommandHost.get(this) || null;
lastCommandHost.delete(this);
return host;
}

// parse & run
parse(argv, suggest) {
let chunk = {
context: {
commandPath: [],
options: null,
args: null,
literalArgs: null
},
next: {
command: this,
argv: argv || process.argv.slice(2)
}
};

do {
chunk = parseArgv(
chunk.next.command,
chunk.next.argv,
chunk.context,
suggest
);
} while (chunk.next);

return chunk;
}
run(argv) {
const chunk = this.parse(argv);

if (typeof chunk.action === 'function') {
return chunk.action.call(null, chunk.context);
}
}

clone(deep) {
const clone = Object.create(Object.getPrototypeOf(this));

for (const [key, value] of Object.entries(this)) {
clone[key] = value && typeof value === 'object'
? (value instanceof Map
? new Map(value)
: Object.assign(Object.create(Object.getPrototypeOf(value)), value))
: value;
}

if (deep) {
for (const [name, subcommand] of clone.commands.entries()) {
clone.commands.set(name, subcommand.clone(deep));
}
}

return clone;
}
createOptionValues(values) {
const storage = Object.create(null);

for (const { name, normalize, default: value } of this.getOptions()) {
if (typeof value !== 'undefined') {
storage[name] = normalize(value);
}
}

return Object.assign(new Proxy(storage, {
set: (obj, key, value, reciever) => {
const option = this.getOption(key);

if (!option) {
return true; // throw new Error(`Unknown option: "${key}"`);
}

const oldValue = obj[option.name];
const newValue = option.normalize(value, oldValue);
const retValue = Reflect.set(obj, option.name, newValue);

if (option.shortcut) {
Object.assign(reciever, option.shortcut.call(null, newValue, oldValue));
}

return retValue;
}
}), values);
}

// misc
messageRef() {
return `${this.usage}${this.params.args.map(arg => ` ${arg.name}`)}`;
}
getOption(name) {
return this.options.get(name) || null;
}
getOptions() {
return [...new Set(this.options.values())];
}
getCommand(name) {
return this.commands.get(name) || null;
}
getCommands() {
return [...this.commands.values()];
}
outputHelp(commandPath) {
console.log(getCommandHelp(this, Array.isArray(commandPath) ? commandPath.slice(0, -1) : null));
}
};
133 changes: 133 additions & 0 deletions lib/help.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import colors from 'ansi-colors';

const MAX_LINE_WIDTH = process.stdout.columns || 200;
const MIN_OFFSET = 20;
const reAstral = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
const ansiRegex = /\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[m|K]/g;
const byName = (a, b) => a.name > b.name || -(a.name < b.name);

function stringLength(str) {
return str
.replace(ansiRegex, '')
.replace(reAstral, ' ')
.length;
}

function pad(width, str) {
// str.padEnd(width + str.length - stringLength(str))
return str + ' '.repeat(width - stringLength(str));
}

function breakByLines(str, offset) {
const words = str.split(' ');
const maxWidth = MAX_LINE_WIDTH - offset || 0;
const lines = [];
let line = '';

while (words.length) {
const word = words.shift();

if (!line || (line.length + word.length + 1) < maxWidth) {
line += (line ? ' ' : '') + word;
} else {
lines.push(line);
words.unshift(word);
line = '';
}
}

lines.push(line);

return lines
.map((line, idx) => (idx && offset ? pad(offset, '') : '') + line)
.join('\n');
}

function args(params, fn = s => s) {
if (params.args.length === 0) {
return '';
}

return ' ' + fn(
params.args
.map(({ name, required }) => required ? '<' + name + '>' : '[' + name + ']')
.join(' ')
);
}

function formatLines(lines) {
const maxNameLength = Math.max(MIN_OFFSET, ...lines.map(line => stringLength(line.name)));

return lines.map(line => (
' ' + pad(maxNameLength, line.name) +
' ' + breakByLines(line.description, maxNameLength + 8)
));
}

function commandsHelp(command) {
if (command.commands.size === 0) {
return '';
}

const lines = command.getCommands().sort(byName).map(({ name, meta, params }) => ({
description: meta.description,
name: colors.green(name) + args(params, colors.gray)
}));

return [
'',
'Commands:',
'',
...formatLines(lines),
''
].join('\n');
}

function optionsHelp(command) {
if (command.options.size === 0) {
return '';
}

const options = command.getOptions().sort(byName);
const shortPlaceholder = options.some(option => option.short) ? ' ' : '';
const lines = options.map(({ short, long, params, description }) => ({
description,
name: [
short ? colors.yellow(short) + ', ' : shortPlaceholder,
colors.yellow(long),
args(params)
].join('')
}));

// Prepend the help information
return [
'',
'Options:',
'',
...formatLines(lines),
''
].join('\n');
}

/**
* Return program help documentation.
*
* @return {String}
* @api private
*/
export default function getCommandHelp(command, commandPath) {
commandPath = Array.isArray(commandPath) && commandPath.length
? commandPath.concat(command.name).join(' ')
: command.name;

return [
(command.meta.description ? command.meta.description + '\n\n' : '') +
'Usage:\n\n' +
' ' + colors.cyan(commandPath) +
args(command.params, colors.magenta) +
(command.options.size !== 0 ? ' [' + colors.yellow('options') + ']' : '') +
(command.commands.size !== 0 ? ' [' + colors.green('command') + ']' : ''),
commandsHelp(command) +
optionsHelp(command)
].join('\n');
};
26 changes: 26 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { basename, extname } from 'path';
import Params from './params.js';
import Option from './option.js';
import Command from './command.js';
import Error from './parse-argv-error.js';
import getCommandHelp from './help.js';

function nameFromProcessArgv() {
return basename(process.argv[1], extname(process.argv[1]));
}

function command(name, params, config) {
name = name || nameFromProcessArgv() || 'command';

return new Command(name, params, config);
}

export {
Error,
Params,
Command,
Option,

getCommandHelp,
command
};
72 changes: 72 additions & 0 deletions lib/option.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Params from './params.js';

const camelcase = name => name.replace(/-(.)/g, (m, ch) => ch.toUpperCase());
const ensureFunction = (fn, fallback) => typeof fn === 'function' ? fn : fallback;
const self = value => value;

export default class Option {
static normalizeOptions(opt1, opt2) {
const raw = typeof opt1 === 'function'
? { normalize: opt1, default: opt2 }
: opt1 && typeof opt1 === 'object'
? opt1
: { default: opt1 };

return {
default: raw.default,
normalize: ensureFunction(raw.normalize, self),
shortcut: ensureFunction(raw.shortcut),
action: ensureFunction(raw.action),
config: Boolean(raw.config)
};
}

static parseUsage(usage) {
const [m, short, long = ''] = usage.trim()
.match(/^(?:(-[a-z\d])(?:\s*,\s*|\s+))?(--[a-z][a-z\d\-\_]*)\s*/i) || [];

if (!m) {
throw new Error(`Usage has no long name: ${usage}`);
}

let params = new Params(usage.slice(m.length), `option usage: ${usage}`);

return { short, long, params };
}

constructor(usage, description, ...rawOptions) {
const { short, long, params } = Option.parseUsage(usage);
const options = Option.normalizeOptions(...rawOptions);

const isBool = params.maxCount === 0 && !options.action;
let name = camelcase(long.replace(isBool ? /^--(no-)?/ : /^--/, '')); // --no-flag - invert value if flag is boolean

if (options.action) {
options.default = undefined;
} else if (isBool) {
options.normalize = Boolean;
options.default = long.startsWith('--no-');
}

// names
this.short = short;
this.long = long;
this.name = name;

// meta
this.usage = usage.trim();
this.description = description || '';

// attributes
this.params = params;
Object.assign(this, options);
}

messageRef() {
return `${this.usage} ${this.description}`;
}

names() {
return [this.long, this.short, this.name].filter(Boolean);
}
};
29 changes: 29 additions & 0 deletions lib/params.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export default class Params {
constructor(params = '', context) {
// params = ..<required> ..[optional]
// <foo> - required
// [foo] - optional
let left = params.trim();
let m;

this.args = [];

while (m = left.match(/^<([^>]+)>\s*/)) {
left = left.slice(m[0].length);
this.args.push({ name: m[1], required: true });
}

this.minCount = this.args.length;

while (m = left.match(/^\[([^\]]+)\]\s*/)) {
left = left.slice(m[0].length);
this.args.push({ name: m[1], required: false });
}

this.maxCount = this.args.length;

if (left) {
throw new Error(`Bad parameters description "${params.trim()}" in ${context}`);
}
}
};
7 changes: 7 additions & 0 deletions lib/parse-argv-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default class CliError extends Error {
constructor(...args) {
super(...args);
this.name = 'CliError';
this.clap = true;
}
};
188 changes: 188 additions & 0 deletions lib/parse-argv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import CliError from './parse-argv-error.js';

function findVariants(command, entry) {
return [
...command.getOptions().map(option => option.long),
...command.commands.keys()
].filter(item => item.startsWith(entry)).sort();
}

function consumeOptionParams(option, rawOptions, argv, index, suggestPoint) {
const tokens = [];
let value;

if (option.params.maxCount) {
for (let j = 0; j < option.params.maxCount; j++) {
const token = argv[index + j];

// TODO: suggestions for option params
if (index + j === suggestPoint) {
return suggestPoint;
}

if (!token || token[0] === '-') {
break;
}

tokens.push(token);
}

if (tokens.length < option.params.minCount) {
throw new CliError(
`Option ${argv[index - 1]} should be used with at least ${option.params.minCount} argument(s)\n` +
`Usage: ${option.usage}`
);
}

value = option.params.maxCount === 1 ? tokens[0] : tokens;
} else {
value = !option.default;
}

rawOptions.push({
option,
value
});

return index + tokens.length - 1;
}

export default function parseArgv(command, argv, context, suggestMode) {
const suggestPoint = suggestMode ? argv.length - 1 : -1;
const rawOptions = [];
const result = {
context,
action: null,
next: null
};

command = command.clone();
context.commandPath.push(command.name);
context.options = Object.freeze(command.createOptionValues());
context.args = [];
context.literalArgs = null;

command.handlers.init(command, context);

for (var i = 0; i < argv.length; i++) {
const token = argv[i];

if (i === suggestPoint) {
return findVariants(command, token); // returns long option & command list
}

if (token === '--') {
if (suggestPoint > i) {
return [];
}

context.literalArgs = argv.slice(i + 1);
break;
}

if (token[0] === '-') {
if (token[1] === '-' || token.length === 2) {
// long option
const option = command.getOption(token);

if (option === null) {
throw new CliError(`Unknown option: ${token}`);
}

// process option params
i = consumeOptionParams(option, rawOptions, argv, i + 1, suggestPoint);
if (i === suggestPoint) {
return [];
}
} else {
// short options sequence
if (!/^-[a-zA-Z0-9]+$/.test(token)) {
throw new CliError(`Bad short option sequence: ${token}`);
}

for (let j = 1; j < token.length; j++) {
const option = command.getOption(`-${token[j]}`);

if (option === null) {
throw new CliError(`Unknown option "${token[j]}" in short option sequence: ${token}`);
}

if (option.params.maxCount > 0) {
throw new CliError(
`Non-boolean option "-${token[j]}" can\'t be used in short option sequence: ${token}`
);
}

rawOptions.push({
option,
value: !option.default
});
}
}
} else {
const subcommand = command.getCommand(token);

if (subcommand !== null &&
context.args.length >= command.params.minCount) {
// set next command and rest argv
result.next = {
command: subcommand,
argv: argv.slice(i + 1)
};
break;
} else {
if (context.args.length >= command.params.maxCount) {
throw new CliError(`Unknown command: ${token}`);
}

context.args.push(token);
}
}
}

// final checks
if (suggestMode && !result.next) {
return findVariants(command, '');
} else if (context.args.length < command.params.minCount) {
throw new CliError(`Missed required argument(s) for command "${command.name}"`);
}

// create new option values storage
context.options = command.createOptionValues();

// process action option
const actionOption = rawOptions.find(({ option }) => option.action);
if (actionOption) {
const { option, value } = actionOption;
result.action = () => option.action(command, value, context);
result.next = null;
return result;
}

// apply config options
for (const { option, value } of rawOptions) {
if (option.config) {
context.options[option.name] = value;
}
}

// run apply config handler
command.handlers.applyConfig(context);

// apply regular options
for (const { option, value } of rawOptions) {
if (!option.config) {
context.options[option.name] = value;
}
}

// run context finish handler
command.handlers.finishContext(context);

// set action if no rest argv
if (!result.next) {
result.action = command.handlers.action;
}

return result;
};
3,536 changes: 3,536 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

43 changes: 31 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
@@ -3,34 +3,53 @@
"title": "Command line argument parser",
"description": "Command line argument parser",
"author": "Roman Dvornov <rdvornov@gmail.com>",
"repository": "lahmatiy/clap",
"license": "MIT",
"version": "1.2.3",
"version": "3.0.0",
"keywords": [
"cli",
"command",
"option",
"argument",
"completion"
],
"homepage": "https://github.com/lahmatiy/clap",
"repository": "lahmatiy/clap",
"main": "index.js",
"type": "module",
"main": "lib/index.js",
"exports": {
".": {
"import": "./lib/index.js",
"require": "./cjs/index.cjs"
},
"./package.json": "./package.json"
},
"files": [
"index.js",
"HISTORY.md",
"LICENSE",
"README.md"
"cjs",
"lib"
],
"engines": {
"node": ">=0.10.0"
"node": "^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
},
"dependencies": {
"chalk": "^1.1.3"
"ansi-colors": "^4.1.1"
},
"devDependencies": {
"mocha": "^2.4.5"
"c8": "^7.10.0",
"eslint": "^8.4.1",
"mocha": "^9.1.3",
"rollup": "^2.61.1",
"test-console": "^1.1.0"
},
"scripts": {
"test": "mocha test -R spec"
"lint": "eslint lib test",
"lint-and-test": "npm run lint && npm test",
"test": "mocha --reporter ${REPORTER:-progress}",
"test:cjs": "mocha cjs-test --reporter ${REPORTER:-progress}",
"build": "npm run esm-to-cjs",
"build-and-test": "npm run esm-to-cjs-and-test",
"esm-to-cjs": "node scripts/esm-to-cjs",
"esm-to-cjs-and-test": "npm run esm-to-cjs && npm run test:cjs",
"coverage": "c8 --reporter=lcovonly npm test",
"prepublishOnly": "npm run lint-and-test && npm run build-and-test"
}
}
76 changes: 76 additions & 0 deletions scripts/esm-to-cjs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import fs from 'fs';
import path from 'path';
import { rollup } from 'rollup';

const external = [
'fs',
'path',
'assert',
'test-console',
'ansi-colors',
'clap'
];

function removeCreateRequire(id) {
return fs.readFileSync(id, 'utf8')
.replace(/import .+ from 'module';/, '')
.replace(/const require = .+;/, '');
}

function replaceContent(map) {
return {
name: 'file-content-replacement',
load(id) {
const key = path.relative('', id);

if (map.hasOwnProperty(key)) {
return map[key](id);
}
}
};
}

function readDir(dir) {
return fs.readdirSync(dir)
.filter(fn => fn.endsWith('.js'))
.map(fn => `${dir}/${fn}`);
}

async function build(outputDir, ...entryPoints) {
const startTime = Date.now();

console.log();
console.log(`Convert ESM to CommonJS (output: ${outputDir})`);

const res = await rollup({
external,
input: entryPoints,
plugins: [
replaceContent({
'lib/version.js': removeCreateRequire
})
]
});
await res.write({
dir: outputDir,
entryFileNames: '[name].cjs',
format: 'cjs',
exports: 'auto',
preserveModules: true,
interop: false,
esModule: false,
generatedCode: {
constBindings: true
}
});
await res.close();

console.log(`Done in ${Date.now() - startTime}ms`);
}

async function buildAll() {
await build('./cjs', 'lib/index.js');
await build('./cjs-test', ...readDir('test'));
}

buildAll();
125 changes: 0 additions & 125 deletions test/bool-option.js

This file was deleted.

27 changes: 27 additions & 0 deletions test/command-action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { equal, deepEqual, strictEqual } from 'assert';
import * as clap from 'clap';

describe('action()', () => {
it('should have an expected input', () => {
const calls = [];
const command = clap.command('test [foo]')
.option('--bar', 'bar option')
.action(function(...args) {
calls.push({
this: this,
arguments: args
});
});

command.run(['abc', '--', 'rest', 'args']);

equal(calls.length, 1);
strictEqual(calls[0].this, null);
deepEqual(calls[0].arguments, [{
commandPath: ['test'],
options: { bar: false },
args: ['abc'],
literalArgs: ['rest', 'args']
}]);
});
});
197 changes: 197 additions & 0 deletions test/command-args-and-options.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { deepStrictEqual, throws, equal } from 'assert';
import * as clap from 'clap';

describe('command run', () => {
describe('args and options', () => {
let command;

beforeEach(() => {
command = clap.command('test [foo]')
.option('--foo', 'Foo')
.option('--bar <number>', 'Bar', Number);
});

it('no arguments', () => {
const actual = command.run([]);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: false
},
args: [],
literalArgs: null
});
});

it('args', () => {
const actual = command.run(['qux']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: false
},
args: ['qux'],
literalArgs: null
});
});

it('options', () => {
const actual = command.run(['--foo', '--bar', '123']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: true,
bar: 123
},
args: [],
literalArgs: null
});
});

it('literal args', () => {
const actual = command.run(['--', '--one', '--two', '123']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: false
},
args: [],
literalArgs: ['--one', '--two', '123']
});
});

it('args & options', () => {
const actual = command.run(['qux', '--foo', '--bar', '123']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: true,
bar: 123
},
args: ['qux'],
literalArgs: null
});
});

it('args & options before', () => {
const actual = command.run(['--foo', '--bar', '123', 'qux']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: true,
bar: 123
},
args: ['qux'],
literalArgs: null
});
});

it('args & literal args', () => {
const actual = command.run(['qux', '--', '--one', '--two', '123']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: false
},
args: ['qux'],
literalArgs: ['--one', '--two', '123']
});
});

it('options & literal args', () => {
const actual = command.run(['--foo', '--bar', '123', '--', '--one', '--two', '123']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: true,
bar: 123
},
args: [],
literalArgs: ['--one', '--two', '123']
});
});

it('args & options & literal args', () => {
const actual = command.run(['qux', '--foo', '--bar', '123', '--', '--one', '--two', '123']);

deepStrictEqual(actual, {
commandPath: ['test'],
options: {
__proto__: null,
foo: true,
bar: 123
},
args: ['qux'],
literalArgs: ['--one', '--two', '123']
});
});
});

describe('multi arg option', () => {
it('x', () => {
const command = clap.command()
.option('--option <arg1> [arg2]', 'description', function(value, oldValue) {
return (oldValue || []).concat(value);
});

deepStrictEqual(
command.run(['--option','foo', 'bar', '--option', 'baz']).options,
{
__proto__: null,
option: ['foo', 'bar', 'baz']
}
);
});
});

describe('required argument', () => {
let action;
const command = clap
.command('test <arg1>')
.action(() => action = '1')
.command('nested <arg2>')
.action(() => action = '2')
.end();

beforeEach(() => {
action = '';
});

it('should throw exception if no first argument', () => {
throws(
() => command.run([]),
/Missed required argument\(s\) for command "test"/
);
});
it('should throw exception if no second argument', () => {
throws(
() => command.run(['one', 'nested']),
/Missed required argument\(s\) for command "nested"/
);
});
it('should treat first argument as value', () => {
command.run(['nested']);
equal(action, '1');
});
it('should run nested action', () => {
command.run(['one', 'nested', 'two']);
equal(action, '2');
});
});
});
66 changes: 66 additions & 0 deletions test/command-clone.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { deepEqual, notStrictEqual, notDeepEqual } from 'assert';
import { command as cli } from 'clap';

describe('Command#clone()', () => {
let command;
let clone;

beforeEach(() => {
command = cli('test')
.description('test')
.option('--test-option', 'xxx')
.command('foo')
.option('--foo-option', 'yyy') // eslint-disable-line indent
.end();
clone = command.clone();
});

it('should be deep equal, but dictionaries should not be the same', () => {
deepEqual(clone, command);
notStrictEqual(clone.commands, command.commands);
notStrictEqual(clone.options, command.options);
});

it('should be deep equal if set the same version', () => {
command.version('1.1.1');
notDeepEqual(clone, command);

clone.version('1.1.1');
deepEqual(clone, command);
});

it('should be deep equal if add the same option', () => {
command.option('--extra', 'zzz');
notDeepEqual(clone, command);

clone.option('--extra', 'zzz');
deepEqual(clone, command);
});

it('should be deep equal if add the same subcommand', () => {
command.command('bar').option('--abc', 'aaa');
notDeepEqual(clone, command);

clone.command('bar').option('--abc', 'aaa');
deepEqual(clone.commands.bar, command.commands.bar);
});

it('should be deep equal if add the same option to nested command with deep cloning', () => {
clone = command.clone(true);

command.getCommand('foo').option('--extra', 'zzz');
notDeepEqual(clone, command);

clone.getCommand('foo').option('--extra', 'zzz');
deepEqual(clone, command);
});

it('should apply handlers as expected', () => {
const actual = clone
.run(['--test-option']);

deepEqual(actual.options, {
testOption: true
});
});
});
91 changes: 91 additions & 0 deletions test/command-createOptionValues.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { deepStrictEqual } from 'assert';
import * as clap from 'clap';

describe('createOptionValues()', () => {
it('boolean option', () => {
const command = clap.command()
.option('--option', 'description', Boolean);

deepStrictEqual(
command.createOptionValues({ option: 'bad value' }),
{
__proto__: null,
option: true
}
);
});

it('number option', () => {
const command = clap.command()
.option('--option <arg>', 'description', function(value) {
return isNaN(value) ? 0 : value;
}, 1);

deepStrictEqual(
command.createOptionValues({ option: 'bad value' }),
{
__proto__: null,
option: 0
}
);
});

it('multi arg option', () => {
const command = clap.command()
.option('--option <arg1> [arg2]', 'description', function(value, oldValue) {
return (oldValue || []).concat(value);
});

deepStrictEqual(
command.createOptionValues({ option: ['foo', 'bar'] }),
{
__proto__: null,
option: ['foo', 'bar']
}
);
});

it('option with no default value and argument should be set', () => {
const command = clap.command()
.option('--option <value>');

deepStrictEqual(
command.createOptionValues({ option: 'ok' }),
{
__proto__: null,
option: 'ok'
}
);
});

it('should ignore unknown keys', () => {
const command = clap.command()
.option('--option <value>');

deepStrictEqual(
command.createOptionValues({ foo: 'ok' }),
Object.create(null)
);
});

it('general test', () => {
const command = clap.command()
.option('--foo <value>', '', Number)
.option('--bar [value]')
.option('--with-default [x]', '', { default: 'default' })
.option('--bool');

deepStrictEqual(
command.createOptionValues({
foo: '123',
option: 'ok'
}),
{
__proto__: null,
foo: 123,
withDefault: 'default',
bool: false
}
);
});
});
27 changes: 27 additions & 0 deletions test/command-extend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { deepEqual } from 'assert';
import * as clap from 'clap';

describe('Command#extend()', () => {
it('basic', () => {
const invocations = [];
const extension = (...args) => {
invocations.push(args);
};

const command = clap.command('test')
.extend(extension, 1, 2);
const nested = command
.command('nested')
.extend(extension)
.extend(extension, 1, 2, 3, 4);

command.extend(extension, 2, 3);

deepEqual(invocations, [
[command, 1, 2],
[nested],
[nested, 1, 2, 3, 4],
[command, 2, 3]
]);
});
});
127 changes: 127 additions & 0 deletions test/command-help.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { strictEqual, equal } from 'assert';
import { stdout } from 'test-console';
import * as clap from 'clap';

describe('Command help', () => {
let inspect;
beforeEach(() => inspect = stdout.inspect());
afterEach(() => inspect.restore());

it('should remove default help when .help(false)', () => {
const command = clap.command('test').help(false);

strictEqual(command.getOption('help'), null);
});

it('should show help', () => {
clap.command('test', false).run(['--help']);

equal(inspect.output, [
'Usage:',
'',
' \u001b[36mtest\u001b[39m [\u001b[33moptions\u001b[39m]',
'',
'Options:',
'',
' \u001b[33m-h\u001b[39m, \u001b[33m--help\u001b[39m Output usage information',
'',
''
].join('\n'));
});

it('help with no short options', () => {
clap.command('test', false, { defaultHelp: false })
.help('--help')
.option('--foo', 'Foo')
.run(['--help']);

equal(inspect.output, [
'Usage:',
'',
' \u001b[36mtest\u001b[39m [\u001b[33moptions\u001b[39m]',
'',
'Options:',
'',
' \u001b[33m--foo\u001b[39m Foo',
' \u001b[33m--help\u001b[39m Output usage information',
'',
''
].join('\n'));
});

it('should show help all cases', () => {
clap.command('test [qux]')
.description('Test description')
.option('-f, --foo', 'Foo')
.option('--bar <baz>', 'Bar', 8080)
.command('nested')
.end()
.command('nested2')
.description('with description')
.end()
.run(['--help']);

equal(inspect.output, [
'Test description',
'',
'Usage:',
'',
' \u001b[36mtest\u001b[39m \u001b[35m[qux]\u001b[39m [\u001b[33moptions\u001b[39m] [\u001b[32mcommand\u001b[39m]',
'',
'Commands:',
'',
' \u001b[32mnested\u001b[39m ',
' \u001b[32mnested2\u001b[39m with description',
'',
'Options:',
'',
' \u001b[33m--bar\u001b[39m <baz> Bar',
' \u001b[33m-f\u001b[39m, \u001b[33m--foo\u001b[39m Foo',
' \u001b[33m-h\u001b[39m, \u001b[33m--help\u001b[39m Output usage information',
'',
''
].join('\n'));
});

it('should show help for nested command', () => {
clap.command('test [qux]')
.option('-f, --foo', 'Foo')
.command('nested [nested-arg]')
.option('--bar <baz>', 'Bar')
.end()
.run(['nested', '--help']);

equal(inspect.output, [
'Usage:',
'',
' \u001b[36mtest nested\u001b[39m \u001b[35m[nested-arg]\u001b[39m [\u001b[33moptions\u001b[39m]',
'',
'Options:',
'',
' \u001b[33m--bar\u001b[39m <baz> Bar',
' \u001b[33m-h\u001b[39m, \u001b[33m--help\u001b[39m Output usage information',
'',
''
].join('\n'));
});

it('should show help message when Command#outputHelp called', () => {
const command = clap.command('test [qux]')
.option('-f, --foo', 'Foo');

command.outputHelp();

equal(inspect.output, [
'Usage:',
'',
' \u001b[36mtest\u001b[39m \u001b[35m[qux]\u001b[39m [\u001b[33moptions\u001b[39m]',
'',
'Options:',
'',
' \u001b[33m-f\u001b[39m, \u001b[33m--foo\u001b[39m Foo',
' \u001b[33m-h\u001b[39m, \u001b[33m--help\u001b[39m Output usage information',
'',
''
].join('\n'));
});
});
34 changes: 34 additions & 0 deletions test/command-parse-handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { deepEqual } from 'assert';
import * as clap from 'clap';

describe('init()/applyConfig()/finishContext()', () => {
let command;
let calls;

beforeEach(() => {
calls = [];
command = clap.command('test [arg1]')
.init(() => calls.push('init'))
.applyConfig(() => calls.push('applyConfig'))
.finishContext(() => calls.push('finishContext'));
command.command('nested [arg2] [arg3]')
.init(() => calls.push('nested init'))
.applyConfig(() => calls.push('nested applyConfig'))
.finishContext(() => calls.push('nested finishContext'));
});

it('with no arguments should init/finishContext top level command only', () => {
command.run([]);
deepEqual(calls, ['init', 'applyConfig', 'finishContext']);
});

it('with one argument should init and finishContext top level command', () => {
command.run(['foo']);
deepEqual(calls, ['init', 'applyConfig', 'finishContext']);
});

it('with first argument as command should init/finishContext both commands', () => {
command.run(['nested']);
deepEqual(calls, ['init', 'applyConfig', 'finishContext', 'nested init', 'nested applyConfig', 'nested finishContext']);
});
});
34 changes: 34 additions & 0 deletions test/command-shortcut-option.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { deepEqual } from 'assert';
import * as clap from 'clap';

describe('Command#shortcutOption()', () => {
it('basic', () => {
const command = clap.command('test')
.option('--foo [foo]', 'Foo', Number)
.option('--no-bar', 'Bar')
.option('--baz [x]', 'Baz', {
normalize: x => x + x,
shortcut: function(x) {
return {
foo: x,
bar: Boolean(Number(x)),
qux: 'xxx'
};
}
});

deepEqual(command.run([]).options, {
bar: true
});
deepEqual(command.run(['--baz', '5']).options, {
bar: true,
baz: '55',
foo: 55
});
deepEqual(command.run(['--baz', '0']).options, {
bar: false,
baz: '00',
foo: 0
});
});
});
17 changes: 17 additions & 0 deletions test/command-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { equal } from 'assert';
import { stdout } from 'test-console';
import * as clap from 'clap';

describe('command run', () => {
let inspect;
beforeEach(() => inspect = stdout.inspect());
afterEach(() => inspect.restore());

it('should output version when specified', () => {
clap.command('test', false)
.version('1.2.3')
.run(['--version']);

equal(inspect.output, '1.2.3\n');
});
});
186 changes: 0 additions & 186 deletions test/command.js

This file was deleted.

122 changes: 46 additions & 76 deletions test/names.js
Original file line number Diff line number Diff line change
@@ -1,91 +1,61 @@
var assert = require('assert');
var cli = require('..');
import assert, { notStrictEqual, throws, strictEqual } from 'assert';
import * as clap from 'clap';

describe('names', function(){
var command;
describe('names', () => {
it('bool option should be in values, options and long', () => {
const command = clap.command()
.option('--bool');

beforeEach(function(){
command = cli.create();
});

it('bool option should be in values, options and long', function(){
command
.option('--bool');

assert('bool' in command.values);
assert('bool' in command.options);
assert('bool' in command.long);
assert(command.hasOption('bool'));

assert('no-bool' in command.values === false);
assert('no-bool' in command.options === false);
assert('no-bool' in command.long === false);
assert(command.hasOption('no-bool') === false);
});

it('inverted bool option should be in values and options as normal name and as is in long', function(){
command
.option('--no-bool');

assert('bool' in command.values);
assert('bool' in command.options);
assert('no-bool' in command.long);
assert(command.hasOption('bool'));

assert('no-bool' in command.values === false);
assert('no-bool' in command.options === false);
assert('no-bool' in command.long === true);
assert(command.hasOption('no-bool') === false);
});
assert([...command.options.keys()], ['--bool', 'bool']);
notStrictEqual(command.getOption('--bool'), null);
notStrictEqual(command.getOption('bool'), null);
});

it('dasherized option should store as camelName in options', function(){
command
.option('--bool-option');
it('inverted bool option should be in values and options as normal name and as is in long', () => {
const command = clap.command()
.option('--no-bool');

assert('boolOption' in command.values);
assert('boolOption' in command.options);
assert('bool-option' in command.long);
assert(command.hasOption('boolOption'));
assert([...command.options.keys()], ['--no-bool', 'bool']);
notStrictEqual(command.getOption('--no-bool'), null);
notStrictEqual(command.getOption('bool'), null);
});

assert('bool-option' in command.values === false);
assert('bool-option' in command.options === false);
assert('boolOption' in command.long === false);
assert(command.hasOption('bool-option') === false);
});
it('dasherized option should store as camelName in options', () => {
const command = clap.command()
.option('--bool-option');

it('non-bool option should have name as is', function(){
command
.option('--no-bool <arg>');
assert([...command.options.keys()], ['--bool-option', 'boolOption']);
notStrictEqual(command.getOption('--bool-option'), null);
notStrictEqual(command.getOption('boolOption'), null);
});

assert('noBool' in command.values === false);
assert('noBool' in command.options);
assert('no-bool' in command.long);
assert(command.hasOption('noBool'));
it('non-bool option should have name as is', () => {
const command = clap.command()
.option('--no-bool <arg>');

assert('bool' in command.values === false);
assert('bool' in command.options === false);
assert('bool' in command.long === false);
assert(command.hasOption('bool') === false);
});
assert([...command.options.keys()], ['--no-bool', 'noBool']);
notStrictEqual(command.getOption('--no-bool'), null);
notStrictEqual(command.getOption('noBool'), null);
});

it('should be exception if no long form', function(){
assert.throws(function(){
command
.option('-b');
it('should be exception if no long form', () => {
throws(
() => clap.command().option('-b'),
/Usage has no long name: -b/
);
});
});

it('#hasOption should not resolve option name by long form', function(){
command
.option('--long-form');
it('#getOption() should not resolve option name by long form', () => {
const command = clap.command()
.option('--long-form');

assert(command.hasOption('long-form') === false);
});
strictEqual(command.getOption('long-form'), null);
});

it('#hasOption should resolve option name by camelName', function(){
command
.option('--long-form');
it('#getOption() should resolve option name by camelName', () => {
const command = clap.command()
.option('--long-form');

assert(command.hasOption('longForm'));
});
notStrictEqual(command.getOption('longForm'), null);
});
});
42 changes: 0 additions & 42 deletions test/normalize.js

This file was deleted.

182 changes: 0 additions & 182 deletions test/one-arg-option.js

This file was deleted.

95 changes: 95 additions & 0 deletions test/option-bool.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { strictEqual, throws } from 'assert';
import * as clap from 'clap';

describe('boolean options', () => {
describe('positive', () => {
it('should be false by default', () => {
const command = clap.command()
.option('--bool');

const { options } = command.run([]);
strictEqual(options.bool, false);
});

it('should throw an exception if oposite option defined already', () => {
throws(
() => clap.command()
.option('--no-bool')
.option('--bool'),
/Option name "bool" already in use by --no-bool/
);
});

it('should be true if option present', () => {
const command = clap.command()
.option('--bool');

const { options } = command.run(['--bool']);
strictEqual(options.bool, true);
});

it('should throw an exception for inverted option', () => {
const command = clap.command()
.option('--bool');

throws(
() => command.run(['--no-bool']),
/Unknown option: --no-bool/
);
});

it('normalize function result should be ignored', () => {
const command = clap.command()
.option('--bool', 'description', () => false);

const { options } = command.run(['--bool']);
strictEqual(options.bool, true);
});
});


describe('negative', () => {
it('should be true by default', () => {
const command = clap.command()
.option('--no-bool');

const { options } = command.run([]);
strictEqual(options.bool, true);
});

it('should throw an exception if oposite option defined already', () => {
throws(
() => clap.command()
.option('--bool')
.option('--no-bool'),
/Option name "bool" already in use by --bool/
);
});

it('should be false if option present', () => {
const command = clap.command()
.option('--no-bool');

const { options } = command.run(['--no-bool']);
strictEqual(options.bool, false);
});

it('should throw an exception for non-inverted option', () => {
const command = clap.command()
.option('--no-bool');

throws(
() => command.run(['--bool']),
/Unknown option: --bool/
);
});

it('normalize function result should be ignored', () => {
const command = clap.command()
.option('--no-bool', 'description', () => true);

const { options } = command.run(['--no-bool']);
strictEqual(options.bool, false);
});
});
});
197 changes: 197 additions & 0 deletions test/option-one-arg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { deepEqual, notStrictEqual, strictEqual, throws, deepStrictEqual, doesNotThrow } from 'assert';
import * as clap from 'clap';

describe('one arg options', () => {
describe('required param', () => {
it('should not be in values by default', () => {
const command = clap.command()
.option('--option <arg>');

const { options } = command.run([]);
deepEqual(options, Object.create(null));
notStrictEqual(command.getOption('option'), null);
});

it('should store default value', () => {
const command = clap.command()
.option('--option <arg>', 'description', 123);

const { options } = command.run([]);
strictEqual(options.option, 123);
});

it('default value should be wrapped by normalize function', () => {
const command = clap.command()
.option('--option <arg>', 'description', value => value * 2, 123);

const { options } = command.run([]);
strictEqual(options.option, 246);
});

it('should not be in values when normalize function preset but no default value', () => {
const command = clap.command()
.option('--option <arg>', 'description', () => {
return 123;
});

const { options } = command.run([]);
deepEqual(options, Object.create(null));
});

it('should read only one argument', () => {
let ok = false;
let values;

const command = clap.command()
.option('--option <arg>', 'description')
.finishContext(({ options }) => values = options)
.command('test')
.action(() => ok = true)
.end();

command.run(['--option', '1', 'test']);
strictEqual(values.option, '1');
strictEqual(ok, true);
});

it('should ignore commands', () => {
let ok = true;
const command = clap.command()
.option('--option <arg>', 'description')
.command('test')
.action(() => ok = false)
.end();

const { options } = command.run(['--option', 'test']);
strictEqual(ok, true);
strictEqual(options.option, 'test');
});

it('should be exception if arg is not specified (no more arguments)', () => {
const command = clap.command()
.option('--option <arg>', 'description');

throws(
() => command.run(['--option']),
/Option --option should be used with at least 1 argument\(s\)/
);
});

it('should be exception if arg is not specified (another option next)', () => {
const command = clap.command()
.option('--test')
.option('--option <arg>', 'description');

throws(
() => command.run(['--option', '--test']),
/Option --option should be used with at least 1 argument\(s\)/
);
});

it('#setValue should normalizenew value', () => {
const command = clap.command()
.option('--option <arg>', 'description', value => value * 2);

const { options } = command.run([]);
options.option = 123;
strictEqual(options.option, 246);
});
});

describe('optional param', () => {
it('should not be in values by default', () => {
const command = clap.command()
.option('--option [arg]');

const { options } = command.run([]);
deepEqual(options, Object.create(null));
notStrictEqual(command.getOption('option'), null);
});

it('should store default value', () => {
const command = clap.command()
.option('--option [arg]', 'description', 123);

const actual = command.run([]);
strictEqual(actual.options.option, 123);
});

it('default value should be wrapped by normalize function', () => {
const command = clap.command()
.option('--option [arg]', 'description', function(value) {
return value * 2;
}, 123);

const actual = command.run([]);
strictEqual(actual.options.option, 246);
});

it('should not be in values when normalize function preset but no default value', () => {
const command = clap.command()
.option('--option [arg]', 'description', () => {
return 123;
});

const actual = command.run([]);
deepStrictEqual(actual.options, Object.create(null));
});

it('should read only one argument', () => {
let ok = false;
let values;

const command = clap.command()
.option('--option [arg]', 'description')
.finishContext(({ options }) => values = options)
.command('test')
.action(() => ok = true)
.end();

command.run(['--option', '1', 'test']);
strictEqual(ok, true);
strictEqual(values.option, '1');
});

it('should ignore commands', () => {
let ok = true;

const command = clap.command()
.option('--option [arg]', 'description')
.command('test')
.action(() => ok = false)
.end();

const { options } = command.run(['--option', 'test']);
strictEqual(ok, true);
strictEqual(options.option, 'test');
});

it('should not be exception if arg is not specified (no more arguments)', () => {
const command = clap.command()
.option('--option [arg]', 'description');

doesNotThrow(() => {
command.run(['--option']);
});
});

it('should not be exception if arg is not specified (another option next)', () => {
const command = clap.command()
.option('--test')
.option('--option [arg]', 'description');

doesNotThrow(() => {
command.run(['--option', '--test']);
});
});

it('set value to options should normalize new value', () => {
const command = clap.command()
.option('--option [arg]', 'description', value => value * 2);

const { options } = command.run([]);
options.option = 123;
strictEqual(options.option, 246);
});
});
});
56 changes: 56 additions & 0 deletions test/option-short.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { deepEqual, throws } from 'assert';
import * as clap from 'clap';

describe('short options', function() {
describe('sequence of boolean options', function() {
const command = clap.command('test')
.option('-f, --foo', 'Foo')
.option('-b, --bar', 'Bar')
.option('-x, --baz', 'Baz')
.option('-0, --zero', 'Zero');

[
{ test: '-f', expected: { foo: true, bar: false, baz: false, zero: false } },
{ test: '-0', expected: { foo: false, bar: false, baz: false, zero: true } },
{ test: '-fb', expected: { foo: true, bar: true, baz: false, zero: false } },
{ test: '-f0b', expected: { foo: true, bar: true, baz: false, zero: true } },
{ test: '-fbx', expected: { foo: true, bar: true, baz: true, zero: false } },
{ test: '-xfbfx', expected: { foo: true, bar: true, baz: true, zero: false } }
].forEach(testcase =>
it(testcase.test, () => {
const actual = command.run([testcase.test]);
deepEqual(testcase.expected, actual.options);
})
);
});

describe('should throws when unknown short', function() {
const command = clap.command('test')
.option('-f, --foo', 'Foo')
.option('-b, --bar', 'Bar');

['-z', '-fz', '-fbz'].forEach((test) => {
it(test, () =>
throws(
() => command.run(['-fz']),
/Unknown option "z" in short option sequence: -fz/
)
);
});
});

it('should throws when non-boolean in sequence', function() {
const command = clap.command('test')
.option('-f, --foo', 'Foo')
.option('-b, --bar <asd>', 'Bar');

throws(
() => command.run(['-fb']),
/Non-boolean option "-b" can't be used in short option sequence: -fb/
);
throws(
() => command.run(['-bf']),
/Non-boolean option "-b" can't be used in short option sequence: -bf/
);
});
});
151 changes: 75 additions & 76 deletions test/suggest.js
Original file line number Diff line number Diff line change
@@ -1,92 +1,91 @@
var assert = require('assert');
var cli = require('..');
import { deepEqual } from 'assert';
import * as clap from 'clap';

describe('suggest', function(){
function getSuggestions(startWith) {
return all.filter(function(name) {
return name.substr(0, startWith.length) === startWith;
});
}
describe('suggest', () => {
function getSuggestions(startWith) {
return all.filter(name => name.startsWith(startWith)).sort();
}

var all = [
"--help",
"--foo",
"--bar",
"--required-arg",
"--optional-arg",
"foo",
"bar"
];
const all = [
'--help',
'--foo',
'--bar',
'--required-arg',
'--optional-arg',
'foo',
'bar'
];

var command = cli
.create('test', '[arg1]')
.option('-f, --foo', 'foo')
.option('-b, --bar', 'bar')
.option('--required-arg <arg>', 'option with required argument')
.option('--optional-arg <arg>', 'option with optional argument')
.command('foo')
.option('--foo', 'nested option 1')
.option('--bar', 'nested option 2')
.option('--baz', 'nested option 3')
.end()
.command('bar', '[arg2]')
.command('baz').end()
.command('qux').end()
.option('--test', 'test option')
.end();
/* eslint-disable indent */
const command = clap.command('test [arg1]')
.option('-f, --foo', 'foo')
.option('-b, --bar', 'bar')
.option('--required-arg <arg>', 'option with required argument')
.option('--optional-arg <arg>', 'option with optional argument')
.command('foo')
.option('--foo', 'nested option 1')
.option('--bar', 'nested option 2')
.option('--baz', 'nested option 3')
.end()
.command('bar [arg2]')
.command('baz').end()
.command('qux').end()
.option('--test', 'test option')
.end();
/* eslint-enable indent */

it('should suggest commands and options when no input', function() {
assert.deepEqual(command.parse([], true), getSuggestions(''));
});
it('should suggest commands and options when no input', () => {
deepEqual(command.parse([], true), getSuggestions(''));
});

it('should suggest options names when one dash', function() {
assert.deepEqual(command.parse(['-'], true), getSuggestions('-'));
});
it('should suggest options names when one dash', () => {
deepEqual(command.parse(['-'], true), getSuggestions('-'));
});

it('should suggest options names when double dash', function() {
assert.deepEqual(command.parse(['--'], true), getSuggestions('--'));
});
it('should suggest options names when double dash', () => {
deepEqual(command.parse(['--'], true), getSuggestions('--'));
});

it('should suggest matched options', function() {
assert.deepEqual(command.parse(['--b'], true), getSuggestions('--b'));
});
it('should suggest matched options', () => {
deepEqual(command.parse(['--b'], true), getSuggestions('--b'));
});

it('should suggest matched commands', function() {
assert.deepEqual(command.parse(['b'], true), getSuggestions('bar'));
});
it('should suggest matched commands', () => {
deepEqual(command.parse(['b'], true), getSuggestions('bar'));
});

it('should suggest nothing when no matches', function() {
assert.deepEqual(command.parse(['--miss'], true), []);
});
it('should suggest nothing when no matches', () => {
deepEqual(command.parse(['--miss'], true), []);
});

it('should suggest matched commands and options of subcommands when no input', function() {
assert.deepEqual(command.parse(['bar', ''], true), ['--help', 'baz', 'qux', '--test']);
});
it('should suggest matched commands and options of subcommands when no input', () => {
deepEqual(command.parse(['bar', ''], true), ['--help', '--test', 'baz', 'qux']);
});

it('should suggest matched commands of subcommands', function() {
assert.deepEqual(command.parse(['bar', 'b'], true), ['baz']);
});
it('should suggest matched commands of subcommands', () => {
deepEqual(command.parse(['bar', 'b'], true), ['baz']);
});

it('should suggest options of subcommands', function() {
assert.deepEqual(command.parse(['foo', '-'], true), ['--help', '--foo', '--bar', '--baz']);
assert.deepEqual(command.parse(['foo', '--'], true), ['--help', '--foo', '--bar', '--baz']);
});
it('should suggest options of subcommands', () => {
deepEqual(command.parse(['foo', '-'], true), ['--bar', '--baz', '--foo', '--help']);
deepEqual(command.parse(['foo', '--'], true), ['--bar', '--baz', '--foo', '--help']);
});

it('should suggest matched options of subcommands', function() {
assert.deepEqual(command.parse(['foo', '--b'], true), ['--bar', '--baz']);
});
it('should suggest matched options of subcommands', () => {
deepEqual(command.parse(['foo', '--b'], true), ['--bar', '--baz']);
});

it('should suggest nothing for option arguments', function() {
assert.deepEqual(command.parse(['--required-arg', ''], true), []);
assert.deepEqual(command.parse(['--required-arg', 'a'], true), []);
assert.deepEqual(command.parse(['--optional-arg', ''], true), []);
assert.deepEqual(command.parse(['--optional-arg', 'a'], true), []);
});
it('should suggest nothing for option arguments', () => {
deepEqual(command.parse(['--required-arg', ''], true), []);
deepEqual(command.parse(['--required-arg', 'a'], true), []);
deepEqual(command.parse(['--optional-arg', ''], true), []);
deepEqual(command.parse(['--optional-arg', 'a'], true), []);
});

it('should suggest nothing after double dash', function() {
assert.deepEqual(command.parse(['--', ''], true), []);
assert.deepEqual(command.parse(['--', 'a'], true), []);
assert.deepEqual(command.parse(['--', '-'], true), []);
assert.deepEqual(command.parse(['--', '--'], true), []);
});
it('should suggest nothing after double dash', () => {
deepEqual(command.parse(['--', ''], true), []);
deepEqual(command.parse(['--', 'a'], true), []);
deepEqual(command.parse(['--', '-'], true), []);
deepEqual(command.parse(['--', '--'], true), []);
});
});