Skip to content

Commit 1d8f5e8

Browse files
committedDec 30, 2019
Internal refactoring, add Command#getOption(s)/getCommand(s) methods
1 parent e8ed57b commit 1d8f5e8

12 files changed

+126
-161
lines changed
 

‎CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
- Restored wrongly removed `Command#extend()`
44
- Added `Command#clone()` method
5-
- Added `Command#hasCommand()` method
5+
- Added `Command#hasCommand()`, `Command#getCommand(name)` and `Command#getCommands()` methods
6+
- Added `Command#getOption(name)` and `Command#getOptions()` methods
67
- Added `Command#messageRef()` and `Option#messageRef()` methods
78
- Changed `Command` to store params info (as `Command#params`) even if no params
89
- Renamed `Command#infoOption()` method into `actionOption()`
@@ -20,6 +21,7 @@
2021
- Removed `Command#setOption()` method
2122
- Removed `Command#normalize()` method (use `createOptionValues()` instead)
2223
- Changed `Option` to store params info as `Option#params`, it always an object even if no params
24+
- Allowed a number for options's short name
2325
- Changed exports
2426
- Added `getCommandHelp()` function
2527
- Added `Params` class

‎README.md

+15-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44

55
# Clap.js
66

7-
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.
7+
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.
8+
9+
Inspired by [commander.js](https://github.com/tj/commander.js)
10+
11+
Features:
12+
13+
- TBD
814

915
## Usage
1016

@@ -60,24 +66,28 @@ myCommand
6066
.shortcutOption(usage, description, handler, ...options)
6167
.command(nameOrCommand, params, config)
6268
.extend(fn, ...options)
63-
.clone(deep)
6469
.end()
6570
66-
// argv processing handlers
71+
// argv processing pipeline handlers
6772
.init(command, context)
6873
.prepare(context)
6974
.action(context)
7075
71-
// run
76+
// parse/run methods
7277
.parse(argv, suggest)
7378
.run(argv)
7479
75-
// utils
80+
// misc
81+
.clone(deep)
7682
.createOptionValues()
7783
.hasCommand(name)
7884
.hasCommands()
85+
.getCommand(name)
86+
.getCommands()
7987
.hasOption(name)
8088
.hasOptions()
89+
.getOption()
90+
.getOptions()
8191
.outputHelp()
8292
```
8393

‎lib/command.js

+53-44
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,10 @@ const Option = require('./option');
55

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

13-
function assertAlreadyInUse(dict, name, subject) {
14-
if (has(dict, name)) {
15-
throw new Error(
16-
`${subject}${name} already in use by ${dict[name].messageRef()}`
17-
);
18-
}
19-
}
20-
2112
const handlers = ['init', 'prepare', 'action'].reduce((res, name) => {
2213
res.initial[name] = name === 'action' ? self : noop;
2314
res.setters[name] = function(fn) {
@@ -34,10 +25,8 @@ module.exports = class Command {
3425

3526
this.name = name;
3627
this.params = new Params(params || '', `"${this.name}" command definition`);
37-
this.commands = {};
38-
this.options = {};
39-
this.short = {};
40-
this.long = {};
28+
this.options = new Map();
29+
this.commands = new Map();
4130
this.meta = {
4231
description: '',
4332
version: ''
@@ -69,21 +58,23 @@ module.exports = class Command {
6958
}
7059
option(usage, description, ...optionOpts) {
7160
const option = new Option(usage, description, ...optionOpts);
61+
const nameType = ['Long option', 'Option', 'Short option'];
62+
const names = option.short
63+
? [option.long, option.name, option.short]
64+
: [option.long, option.name];
65+
66+
names.forEach((name, idx) => {
67+
if (this.hasOption(name)) {
68+
throw new Error(
69+
`${nameType[idx]} name "${name}" already in use by ${this.getOption(name).messageRef()}`
70+
);
71+
}
72+
});
7273

73-
// short
74-
if (option.short) {
75-
assertAlreadyInUse(this.short, option.short, 'Short option name -');
76-
this.short[option.short] = option;
74+
for (const name of names) {
75+
this.options.set(name, option);
7776
}
7877

79-
// long
80-
assertAlreadyInUse(this.long, option.long, 'Long option name --');
81-
this.long[option.long] = option;
82-
83-
// camel
84-
assertAlreadyInUse(this.options, option.camelName, 'Option name ');
85-
this.options[option.camelName] = option;
86-
8778
return this;
8879
}
8980
actionOption(usage, description, action) {
@@ -107,15 +98,19 @@ module.exports = class Command {
10798
subcommand = new Command(name, params, config);
10899
}
109100

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

114105
// search for existing one
115-
assertAlreadyInUse(this.commands, name, 'Subcommand name ');
106+
if (this.hasCommand(name)) {
107+
throw new Error(
108+
`Subcommand name "${name}" already in use by ${this.getCommand(name).messageRef()}`
109+
);
110+
}
116111

117112
// attach subcommand
118-
this.commands[name] = subcommand;
113+
this.commands.set(name, subcommand);
119114
lastCommandHost.set(subcommand, this);
120115

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

137132
for (const [key, value] of Object.entries(this)) {
138133
clone[key] = value && typeof value === 'object'
139-
? Object.assign(Object.create(Object.getPrototypeOf(value)), value)
134+
? (value instanceof Map
135+
? new Map(value)
136+
: Object.assign(Object.create(Object.getPrototypeOf(value)), value))
140137
: value;
141138
}
142139

143140
if (deep) {
144-
for (const [name, subcommand] of Object.entries(this.commands)) {
145-
this.commands[name] = subcommand.clone(deep);
141+
for (const [name, subcommand] of clone.commands.entries()) {
142+
clone.commands.set(name, subcommand.clone(deep));
146143
}
147144
}
148145

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

152149
// values
153150
createOptionValues(values) {
154-
const { options } = this;
155151
const storage = Object.create(null);
156152

157-
for (const [key, option] of Object.entries(this.options)) {
158-
if (typeof option.defValue !== 'undefined') {
159-
storage[key] = option.normalize(option.defValue);
153+
for (const { name, normalize, defValue } of this.getOptions()) {
154+
if (typeof defValue !== 'undefined') {
155+
storage[name] = normalize(defValue);
160156
}
161157
}
162158

163159
return Object.assign(new Proxy(storage, {
164-
set(obj, key, value, reciever) {
165-
if (!has(options, key)) {
160+
set: (obj, key, value, reciever) => {
161+
const option = this.getOption(key);
162+
163+
if (!option) {
166164
return true; // throw new Error(`Unknown option: "${key}"`);
167165
}
168166

169-
const option = options[key];
170-
const oldValue = obj[key];
167+
const oldValue = obj[option.name];
171168
const newValue = option.params.maxCount ? option.normalize(value, oldValue) : Boolean(value);
172-
const retValue = Reflect.set(obj, key, newValue);
169+
const retValue = Reflect.set(obj, option.name, newValue);
173170

174171
if (option.shortcut) {
175172
Object.assign(reciever, option.shortcut.call(null, newValue, oldValue));
@@ -219,16 +216,28 @@ module.exports = class Command {
219216
return `${this.usage}${this.params.args.map(arg => ` ${arg.name}`)}`;
220217
}
221218
hasOption(name) {
222-
return has(this.options, name);
219+
return this.options.has(name);
223220
}
224221
hasOptions() {
225-
return Object.keys(this.options).length > 0;
222+
return this.options.size > 0;
223+
}
224+
getOption(name) {
225+
return this.options.get(name) || null;
226+
}
227+
getOptions() {
228+
return [...new Set(this.options.values())];
226229
}
227230
hasCommand(name) {
228-
return has(this.commands, name);
231+
return this.commands.has(name);
229232
}
230233
hasCommands() {
231-
return Object.keys(this.commands).length > 0;
234+
return this.commands.size > 0;
235+
}
236+
getCommand(name) {
237+
return this.commands.get(name) || null;
238+
}
239+
getCommands() {
240+
return [...this.commands.values()];
232241
}
233242
outputHelp(commandPath) {
234243
console.log(getCommandHelp(this, Array.isArray(commandPath) ? commandPath.slice(0, -1) : null));

‎lib/help.js

+5-9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const MAX_LINE_WIDTH = process.stdout.columns || 200;
22
const MIN_OFFSET = 25;
33
const reAstral = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
44
const ansiRegex = /\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[m|K]/g;
5+
const byName = (a, b) => a.name > b.name || -(a.name < b.name);
56
let chalk;
67

78
function initChalk() {
@@ -59,18 +60,12 @@ function args(command) {
5960
.join(' ');
6061
}
6162

62-
function valuesSortedByKey(dict) {
63-
return Object.keys(dict)
64-
.sort()
65-
.map(key => dict[key]);
66-
}
67-
6863
function commandsHelp(command) {
6964
if (!command.hasCommands()) {
7065
return '';
7166
}
7267

73-
const lines = valuesSortedByKey(command.commands).map(subcommand => ({
68+
const lines = command.getCommands().sort(byName).map(subcommand => ({
7469
name: chalk.green(subcommand.name) + chalk.gray(
7570
(subcommand.params.maxCount ? ' ' + args(subcommand) : '')
7671
),
@@ -98,8 +93,9 @@ function optionsHelp(command) {
9893
return '';
9994
}
10095

101-
const hasShortOptions = Object.keys(command.short).length > 0;
102-
const lines = valuesSortedByKey(command.long).map(option => ({
96+
const options = command.getOptions().sort(byName);
97+
const hasShortOptions = options.some(option => option.short);
98+
const lines = options.map(option => ({
10399
name: option.usage
104100
.replace(/^(?:-., |)/, (m) =>
105101
m || (hasShortOptions ? ' ' : '')

‎lib/index.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
const path = require('path');
12
const Params = require('./params');
23
const Option = require('./option');
34
const Command = require('./command');
45
const Error = require('./parse-argv-error');
56
const getCommandHelp = require('./help');
67

8+
function nameFromProcessArgv() {
9+
return path.basename(process.argv[1], path.extname(process.argv[1]));
10+
}
11+
712
module.exports = {
813
Error,
914
Params,
@@ -12,7 +17,7 @@ module.exports = {
1217

1318
getCommandHelp,
1419
command: function(name, params, config) {
15-
name = name || require('path').basename(process.argv[1]) || 'command';
20+
name = name || nameFromProcessArgv() || 'command';
1621

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

‎lib/option.js

+8-33
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const Params = require('./params');
2-
const camelize = name => name.replace(/-(.)/g, (m, ch) => ch.toUpperCase());
2+
const camelcase = name => name.replace(/-(.)/g, (m, ch) => ch.toUpperCase());
33
const ensureFunction = (fn, fallback) => typeof fn === 'function' ? fn : fallback;
44
const self = value => value;
55

@@ -20,46 +20,22 @@ module.exports = class Option {
2020
}
2121

2222
static parseUsage(usage) {
23-
let short;
24-
let name;
25-
let long;
26-
let defValue;
27-
let params;
28-
let left = usage.trim()
29-
// short usage
30-
// -x
31-
.replace(/^-([a-zA-Z])(?:\s*,\s*|\s+)/, (_, m) => {
32-
short = m;
33-
34-
return '';
35-
})
36-
// long usage
37-
// --flag
38-
// --no-flag - invert value if flag is boolean
39-
.replace(/^--([a-zA-Z][a-zA-Z0-9\-\_]+)\s*/, (_, m) => {
40-
long = m;
41-
name = m.replace(/(^|-)no-/, '$1');
42-
defValue = name !== long;
43-
44-
return '';
45-
});
23+
const [m, short, long = ''] = usage.trim()
24+
.match(/^(?:(-[a-z\d])(?:\s*,\s*|\s+))?(--[a-z][a-z\d\-\_]*)?\s*/i) || [];
4625

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

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

5334
if (params.maxCount > 0) {
54-
left = '';
55-
name = long;
35+
name = long.slice(2);
5636
defValue = undefined;
5737
}
5838

59-
if (left) {
60-
throw new Error('Bad usage for option: ' + usage);
61-
}
62-
6339
return { short, long, name, params, defValue };
6440
}
6541

@@ -69,8 +45,7 @@ module.exports = class Option {
6945
// names
7046
this.short = short;
7147
this.long = long;
72-
this.name = name || long;
73-
this.camelName = camelize(this.name);
48+
this.name = camelcase(name);
7449

7550
// meta
7651
this.usage = usage.trim();

‎lib/params.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ module.exports = class Params {
1212

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

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

‎lib/parse-argv.js

+11-30
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,11 @@ const CliError = require('./parse-argv-error');
22

33
function findVariants(command, entry) {
44
return [
5-
...Object.keys(command.long).map(name => '--' + name),
6-
...Object.keys(command.commands)
5+
...command.getOptions().map(option => option.long),
6+
...command.commands.keys()
77
].filter(item => item.startsWith(entry)).sort();
88
}
99

10-
function getOwnValue(dict, key) {
11-
return hasOwnProperty.call(dict, key)
12-
? dict[key]
13-
: null;
14-
}
15-
1610
function consumeOptionParams(option, rawOptions, argv, index, suggestPoint) {
1711
const tokens = [];
1812
let value;
@@ -87,9 +81,9 @@ module.exports = function parseArgv(command, argv, context, suggestMode) {
8781
}
8882

8983
if (token[0] === '-') {
90-
if (token[1] === '-') {
84+
if (token[1] === '-' || token.length === 2) {
9185
// long option
92-
const option = getOwnValue(command.long, token.substr(2));
86+
const option = command.getOption(token);
9387

9488
if (option === null) {
9589
throw new CliError(`Unknown option: ${token}`);
@@ -100,22 +94,22 @@ module.exports = function parseArgv(command, argv, context, suggestMode) {
10094
if (i === suggestPoint) {
10195
return [];
10296
}
103-
} else if (token.length > 2) {
97+
} else {
10498
// short options sequence
105-
if (!/^-[a-zA-Z]+$/.test(token)) {
99+
if (!/^-[a-zA-Z0-9]+$/.test(token)) {
106100
throw new CliError(`Bad short option sequence: ${token}`);
107101
}
108102

109103
for (let j = 1; j < token.length; j++) {
110-
const option = getOwnValue(command.short, token[j]);
104+
const option = command.getOption(`-${token[j]}`);
111105

112106
if (option === null) {
113-
throw new CliError(`Unknown short option: -${token[j]}`);
107+
throw new CliError(`Unknown option "${token[j]}" in short option sequence: ${token}`);
114108
}
115109

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

@@ -124,22 +118,9 @@ module.exports = function parseArgv(command, argv, context, suggestMode) {
124118
value: !option.defValue
125119
});
126120
}
127-
} else {
128-
// short option
129-
const option = getOwnValue(command.short, token.slice(1));
130-
131-
if (option === null) {
132-
throw new CliError(`Unknown short option: ${token}`);
133-
}
134-
135-
// process option params
136-
i = consumeOptionParams(option, rawOptions, argv, i + 1, suggestPoint);
137-
if (i === suggestPoint) {
138-
return [];
139-
}
140121
}
141122
} else {
142-
const subcommand = getOwnValue(command.commands, token);
123+
const subcommand = command.getCommand(token);
143124

144125
if (subcommand !== null &&
145126
context.args.length >= command.params.minCount) {
@@ -179,7 +160,7 @@ module.exports = function parseArgv(command, argv, context, suggestMode) {
179160
// apply options
180161
context.options = command.createOptionValues();
181162
for (const { option, value } of rawOptions) {
182-
context.options[option.camelName] = value;
163+
context.options[option.name] = value;
183164
}
184165

185166
// run prepare handler

‎test/command-clone.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ describe('Command#clone()', () => {
4949
it('should be deep equal if add the same option to nested command with deep cloning', () => {
5050
clone = command.clone(true);
5151

52-
command.commands.foo.option('--extra', 'zzz');
52+
command.getCommand('foo').option('--extra', 'zzz');
5353
assert.notDeepEqual(clone, command);
5454

55-
clone.commands.foo.option('--extra', 'zzz');
55+
clone.getCommand('foo').option('--extra', 'zzz');
5656
assert.deepEqual(clone, command);
5757
});
5858

‎test/names.js

+8-24
Original file line numberDiff line numberDiff line change
@@ -6,52 +6,36 @@ describe('names', function() {
66
const command = cli.command()
77
.option('--bool');
88

9-
assert('bool' in command.options);
10-
assert('bool' in command.long);
9+
assert([...command.options.keys()], ['--bool', 'bool']);
10+
assert(command.hasOption('--bool'));
1111
assert(command.hasOption('bool'));
12-
13-
assert('no-bool' in command.options === false);
14-
assert('no-bool' in command.long === false);
15-
assert(command.hasOption('no-bool') === false);
1612
});
1713

1814
it('inverted bool option should be in values and options as normal name and as is in long', function() {
1915
const command = cli.command()
2016
.option('--no-bool');
2117

22-
assert('bool' in command.options);
23-
assert('no-bool' in command.long);
18+
assert([...command.options.keys()], ['--no-bool', 'bool']);
19+
assert(command.hasOption('--no-bool'));
2420
assert(command.hasOption('bool'));
25-
26-
assert('no-bool' in command.options === false);
27-
assert('no-bool' in command.long === true);
28-
assert(command.hasOption('no-bool') === false);
2921
});
3022

3123
it('dasherized option should store as camelName in options', function() {
3224
const command = cli.command()
3325
.option('--bool-option');
3426

35-
assert('boolOption' in command.options);
36-
assert('bool-option' in command.long);
27+
assert([...command.options.keys()], ['--bool-option', 'boolOption']);
28+
assert(command.hasOption('--bool-option'));
3729
assert(command.hasOption('boolOption'));
38-
39-
assert('bool-option' in command.options === false);
40-
assert('boolOption' in command.long === false);
41-
assert(command.hasOption('bool-option') === false);
4230
});
4331

4432
it('non-bool option should have name as is', function() {
4533
const command = cli.command()
4634
.option('--no-bool <arg>');
4735

48-
assert('noBool' in command.options);
49-
assert('no-bool' in command.long);
36+
assert([...command.options.keys()], ['--no-bool', 'noBool']);
37+
assert(command.hasOption('--no-bool'));
5038
assert(command.hasOption('noBool'));
51-
52-
assert('bool' in command.options === false);
53-
assert('bool' in command.long === false);
54-
assert(command.hasOption('bool') === false);
5539
});
5640

5741
it('should be exception if no long form', function() {

‎test/option-bool.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('boolean options', function() {
1616
() => cli.command()
1717
.option('--no-bool')
1818
.option('--bool'),
19-
/Option name bool already in use by --no-bool/
19+
/Option name "bool" already in use by --no-bool/
2020
);
2121
});
2222

@@ -62,7 +62,7 @@ describe('boolean options', function() {
6262
() => cli.command()
6363
.option('--bool')
6464
.option('--no-bool'),
65-
/Option name bool already in use by --bool/
65+
/Option name "bool" already in use by --bool/
6666
);
6767
});
6868

‎test/option-short.js

+11-8
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ describe('short options', function() {
66
const command = cli.command('test')
77
.option('-f, --foo', 'Foo')
88
.option('-b, --bar', 'Bar')
9-
.option('-x, --baz', 'Baz');
9+
.option('-x, --baz', 'Baz')
10+
.option('-0, --zero', 'Zero');
1011

1112
[
12-
{ test: '-f', expected: { foo: true, bar: false, baz: false } },
13-
{ test: '-fb', expected: { foo: true, bar: true, baz: false } },
14-
{ test: '-fbx', expected: { foo: true, bar: true, baz: true } },
15-
{ test: '-xfbfx', expected: { foo: true, bar: true, baz: true } }
13+
{ test: '-f', expected: { foo: true, bar: false, baz: false, zero: false } },
14+
{ test: '-0', expected: { foo: false, bar: false, baz: false, zero: true } },
15+
{ test: '-fb', expected: { foo: true, bar: true, baz: false, zero: false } },
16+
{ test: '-f0b', expected: { foo: true, bar: true, baz: false, zero: true } },
17+
{ test: '-fbx', expected: { foo: true, bar: true, baz: true, zero: false } },
18+
{ test: '-xfbfx', expected: { foo: true, bar: true, baz: true, zero: false } }
1619
].forEach(testcase =>
1720
it(testcase.test, () => {
1821
const actual = command.run([testcase.test]);
@@ -30,7 +33,7 @@ describe('short options', function() {
3033
it(test, () =>
3134
assert.throws(
3235
() => command.run(['-fz']),
33-
/Unknown short option: -z/
36+
/Unknown option "z" in short option sequence: -fz/
3437
)
3538
);
3639
});
@@ -43,11 +46,11 @@ describe('short options', function() {
4346

4447
assert.throws(
4548
() => command.run(['-fb']),
46-
/Non-boolean option -b can't be used in short option sequence: -fb/
49+
/Non-boolean option "-b" can't be used in short option sequence: -fb/
4750
);
4851
assert.throws(
4952
() => command.run(['-bf']),
50-
/Non-boolean option -b can't be used in short option sequence: -bf/
53+
/Non-boolean option "-b" can't be used in short option sequence: -bf/
5154
);
5255
});
5356
});

0 commit comments

Comments
 (0)
Please sign in to comment.