Skip to content

Commit

Permalink
Internal refactoring, add Command#getOption(s)/getCommand(s) methods
Browse files Browse the repository at this point in the history
  • Loading branch information
lahmatiy committed Dec 30, 2019
1 parent e8ed57b commit 1d8f5e8
Show file tree
Hide file tree
Showing 12 changed files with 126 additions and 161 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

- Restored wrongly removed `Command#extend()`
- Added `Command#clone()` method
- Added `Command#hasCommand()` method
- Added `Command#hasCommand()`, `Command#getCommand(name)` and `Command#getCommands()` methods
- Added `Command#getOption(name)` and `Command#getOptions()` methods
- Added `Command#messageRef()` and `Option#messageRef()` methods
- Changed `Command` to store params info (as `Command#params`) even if no params
- Renamed `Command#infoOption()` method into `actionOption()`
Expand All @@ -20,6 +21,7 @@
- Removed `Command#setOption()` 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
- Allowed a number for options's short name
- Changed exports
- Added `getCommandHelp()` function
- Added `Params` class
Expand Down
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

# 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). It make simple CLI a trivia task, and complex tools with a lot of subcommands and specific features possible. Support for argument coercion and completion suggestion makes typing commands much easer.

Inspired by [commander.js](https://github.com/tj/commander.js)

Features:

- TBD

## Usage

Expand Down Expand Up @@ -60,24 +66,28 @@ myCommand
.shortcutOption(usage, description, handler, ...options)
.command(nameOrCommand, params, config)
.extend(fn, ...options)
.clone(deep)
.end()
// argv processing handlers
// argv processing pipeline handlers
.init(command, context)
.prepare(context)
.action(context)
// run
// parse/run methods
.parse(argv, suggest)
.run(argv)
// utils
// misc
.clone(deep)
.createOptionValues()
.hasCommand(name)
.hasCommands()
.getCommand(name)
.getCommands()
.hasOption(name)
.hasOptions()
.getOption()
.getOptions()
.outputHelp()
```

Expand Down
97 changes: 53 additions & 44 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,10 @@ const Option = require('./option');

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

function assertAlreadyInUse(dict, name, subject) {
if (has(dict, name)) {
throw new Error(
`${subject}${name} already in use by ${dict[name].messageRef()}`
);
}
}

const handlers = ['init', 'prepare', 'action'].reduce((res, name) => {
res.initial[name] = name === 'action' ? self : noop;
res.setters[name] = function(fn) {
Expand All @@ -34,10 +25,8 @@ module.exports = class Command {

this.name = name;
this.params = new Params(params || '', `"${this.name}" command definition`);
this.commands = {};
this.options = {};
this.short = {};
this.long = {};
this.options = new Map();
this.commands = new Map();
this.meta = {
description: '',
version: ''
Expand Down Expand Up @@ -69,21 +58,23 @@ module.exports = class Command {
}
option(usage, description, ...optionOpts) {
const option = new Option(usage, description, ...optionOpts);
const nameType = ['Long option', 'Option', 'Short option'];
const names = option.short
? [option.long, option.name, option.short]
: [option.long, option.name];

names.forEach((name, idx) => {
if (this.hasOption(name)) {
throw new Error(
`${nameType[idx]} name "${name}" already in use by ${this.getOption(name).messageRef()}`
);
}
});

// short
if (option.short) {
assertAlreadyInUse(this.short, option.short, 'Short option name -');
this.short[option.short] = option;
for (const name of names) {
this.options.set(name, option);
}

// long
assertAlreadyInUse(this.long, option.long, 'Long option name --');
this.long[option.long] = option;

// camel
assertAlreadyInUse(this.options, option.camelName, 'Option name ');
this.options[option.camelName] = option;

return this;
}
actionOption(usage, description, action) {
Expand All @@ -107,15 +98,19 @@ module.exports = class Command {
subcommand = new Command(name, params, config);
}

if (!/^[a-zA-Z][a-zA-Z0-9\-\_]*$/.test(name)) {
if (!/^[a-z][a-z0-9\-\_]*$/i.test(name)) {
throw new Error(`Bad subcommand name: ${name}`);
}

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

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

return subcommand;
Expand All @@ -136,13 +131,15 @@ module.exports = class Command {

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

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

Expand All @@ -151,25 +148,25 @@ module.exports = class Command {

// values
createOptionValues(values) {
const { options } = this;
const storage = Object.create(null);

for (const [key, option] of Object.entries(this.options)) {
if (typeof option.defValue !== 'undefined') {
storage[key] = option.normalize(option.defValue);
for (const { name, normalize, defValue } of this.getOptions()) {
if (typeof defValue !== 'undefined') {
storage[name] = normalize(defValue);
}
}

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

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

const option = options[key];
const oldValue = obj[key];
const oldValue = obj[option.name];
const newValue = option.params.maxCount ? option.normalize(value, oldValue) : Boolean(value);
const retValue = Reflect.set(obj, key, newValue);
const retValue = Reflect.set(obj, option.name, newValue);

if (option.shortcut) {
Object.assign(reciever, option.shortcut.call(null, newValue, oldValue));
Expand Down Expand Up @@ -219,16 +216,28 @@ module.exports = class Command {
return `${this.usage}${this.params.args.map(arg => ` ${arg.name}`)}`;
}
hasOption(name) {
return has(this.options, name);
return this.options.has(name);
}
hasOptions() {
return Object.keys(this.options).length > 0;
return this.options.size > 0;
}
getOption(name) {
return this.options.get(name) || null;
}
getOptions() {
return [...new Set(this.options.values())];
}
hasCommand(name) {
return has(this.commands, name);
return this.commands.has(name);
}
hasCommands() {
return Object.keys(this.commands).length > 0;
return this.commands.size > 0;
}
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));
Expand Down
14 changes: 5 additions & 9 deletions lib/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const MAX_LINE_WIDTH = process.stdout.columns || 200;
const MIN_OFFSET = 25;
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);
let chalk;

function initChalk() {
Expand Down Expand Up @@ -59,18 +60,12 @@ function args(command) {
.join(' ');
}

function valuesSortedByKey(dict) {
return Object.keys(dict)
.sort()
.map(key => dict[key]);
}

function commandsHelp(command) {
if (!command.hasCommands()) {
return '';
}

const lines = valuesSortedByKey(command.commands).map(subcommand => ({
const lines = command.getCommands().sort(byName).map(subcommand => ({
name: chalk.green(subcommand.name) + chalk.gray(
(subcommand.params.maxCount ? ' ' + args(subcommand) : '')
),
Expand Down Expand Up @@ -98,8 +93,9 @@ function optionsHelp(command) {
return '';
}

const hasShortOptions = Object.keys(command.short).length > 0;
const lines = valuesSortedByKey(command.long).map(option => ({
const options = command.getOptions().sort(byName);
const hasShortOptions = options.some(option => option.short);
const lines = options.map(option => ({
name: option.usage
.replace(/^(?:-., |)/, (m) =>
m || (hasShortOptions ? ' ' : '')
Expand Down
7 changes: 6 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
const path = require('path');
const Params = require('./params');
const Option = require('./option');
const Command = require('./command');
const Error = require('./parse-argv-error');
const getCommandHelp = require('./help');

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

module.exports = {
Error,
Params,
Expand All @@ -12,7 +17,7 @@ module.exports = {

getCommandHelp,
command: function(name, params, config) {
name = name || require('path').basename(process.argv[1]) || 'command';
name = name || nameFromProcessArgv() || 'command';

return new Command(name, params, config);
}
Expand Down
41 changes: 8 additions & 33 deletions lib/option.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const Params = require('./params');
const camelize = name => name.replace(/-(.)/g, (m, ch) => ch.toUpperCase());
const camelcase = name => name.replace(/-(.)/g, (m, ch) => ch.toUpperCase());
const ensureFunction = (fn, fallback) => typeof fn === 'function' ? fn : fallback;
const self = value => value;

Expand All @@ -20,46 +20,22 @@ module.exports = class Option {
}

static parseUsage(usage) {
let short;
let name;
let long;
let defValue;
let params;
let left = usage.trim()
// short usage
// -x
.replace(/^-([a-zA-Z])(?:\s*,\s*|\s+)/, (_, m) => {
short = m;

return '';
})
// long usage
// --flag
// --no-flag - invert value if flag is boolean
.replace(/^--([a-zA-Z][a-zA-Z0-9\-\_]+)\s*/, (_, m) => {
long = m;
name = m.replace(/(^|-)no-/, '$1');
defValue = name !== long;

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

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

params = new Params(left, `option usage: ${usage}`);
let name = long.replace(/^--(no-)?/, ''); // --no-flag - invert value if flag is boolean
let defValue = /--no-/.test(long);
let params = new Params(usage.slice(m.length), `option usage: ${usage}`);

if (params.maxCount > 0) {
left = '';
name = long;
name = long.slice(2);
defValue = undefined;
}

if (left) {
throw new Error('Bad usage for option: ' + usage);
}

return { short, long, name, params, defValue };
}

Expand All @@ -69,8 +45,7 @@ module.exports = class Option {
// names
this.short = short;
this.long = long;
this.name = name || long;
this.camelName = camelize(this.name);
this.name = camelcase(name);

// meta
this.usage = usage.trim();
Expand Down
4 changes: 2 additions & 2 deletions lib/params.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = class Params {

do {
tmp = left;
left = left.replace(/^<([a-zA-Z][a-zA-Z0-9\-\_]*)>\s*/, (_, name) => {
left = left.replace(/^<([a-z][a-z0-9\-\_]*)>\s*/i, (_, name) => {
this.args.push({ name, required: true });
this.minCount++;
this.maxCount++;
Expand All @@ -23,7 +23,7 @@ module.exports = class Params {

do {
tmp = left;
left = left.replace(/^\[([a-zA-Z][a-zA-Z0-9\-\_]*)\]\s*/, (_, name) => {
left = left.replace(/^\[([a-z][a-z0-9\-\_]*)\]\s*/i, (_, name) => {
this.args.push({ name, required: false });
this.maxCount++;

Expand Down
Loading

0 comments on commit 1d8f5e8

Please sign in to comment.