Skip to content

Commit

Permalink
feat: support eof in parameters (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
privatenumber authored Jan 29, 2022
1 parent 7e0003d commit 7e24ad3
Show file tree
Hide file tree
Showing 8 changed files with 327 additions and 27 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,38 @@ argv._.optionalParameter // => "b" (string | undefined)
argv._.optionalSpread // => ["c", "d"] (string[])
```

#### End-of-flags
End-of-flags (`--`) (aka _end-of-options_) allows users to pass in a subset of arguments. This is useful for passing in arguments that should be parsed separately from the rest of the arguments or passing in arguments that look like flags.

An example of this is [`npm run`](https://docs.npmjs.com/cli/v8/commands/npm-run-script):
```sh
$ npm run <script> -- <script arguments>
```
The `--` indicates that all arguments afterwards should be passed into the _script_ rather than _npm_.

All end-of-flag arguments will be accessible from `argv._['--']`.

Additionally, you can specify `--` in the `parameters` array to parse end-of-flags arguments.

Example:

```ts
const argv = cli({
name: 'npm-run',
parameters: [
'<script>',
'--',
'[arguments...]'
]
})

// $ npm-run echo -- hello world

argv._.script // => "echo" (string)
argv._.arguments // => ["hello", "world] (string[])
```


### Flags
Flags (aka Options) are key-value pairs passed into the script in the format `--flag-name <value>`.

Expand Down
18 changes: 18 additions & 0 deletions examples/npm/commands/run-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { command } from '../../../src';

export const runScript = command({
name: 'run-script',

alias: ['run', 'rum', 'urn'],

parameters: ['<command>', '--', '[args...]'],

help: {
description: 'Run a script',
},
}, (argv) => {
console.log('run', {
command: argv._.command,
args: argv._.args,
});
});
2 changes: 2 additions & 0 deletions examples/npm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@

import { cli } from '../../src';
import { install } from './commands/install';
import { runScript } from './commands/run-script';

const argv = cli({
name: 'npm',

commands: [
install,
runScript,
],
});

Expand Down
101 changes: 75 additions & 26 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ const { stringify } = JSON;

const specialCharactersPattern = /[|\\{}()[\]^$+*?.]/;

type ParsedParameter = {
name: string;
required: boolean;
spread: boolean;
};

function parseParameters(parameters: string[]) {
const parsedParameters: {
name: string;
required: boolean;
spread: boolean;
}[] = [];
const parsedParameters: ParsedParameter[] = [];

let hasOptional: string | undefined;
let hasSpread: string | undefined;
Expand Down Expand Up @@ -78,6 +80,38 @@ function parseParameters(parameters: string[]) {
return parsedParameters;
}

function mapParametersToArguments(
mapping: Record<string, string | string[]>,
parameters: ParsedParameter[],
cliArguments: string[],
showHelp: () => void,
) {
for (let i = 0; i < parameters.length; i += 1) {
const { name, required, spread } = parameters[i];
const camelCaseName = camelCase(name);
if (camelCaseName in mapping) {
throw new Error(`Invalid parameter: ${stringify(name)} is used more than once.`);
}

const value = spread ? cliArguments.slice(i) : cliArguments[i];

if (spread) {
i = parameters.length;
}

if (
required
&& (!value || (spread && value.length === 0))
) {
console.error(`Error: Missing required parameter ${stringify(name)}\n`);
showHelp();
return process.exit(1);
}

mapping[camelCaseName] = value;
}
}

function helpEnabled(help: false | undefined | HelpOptions): help is (HelpOptions | undefined) {
return (
// Default
Expand Down Expand Up @@ -165,28 +199,43 @@ function cliBase<
}

if (options.parameters) {
const parsedParameters = parseParameters(options.parameters);
const _ = parsed._ as (typeof parsed._ & Record<string, string | string[]>);

for (let i = 0; i < parsedParameters.length; i += 1) {
const { name, required, spread } = parsedParameters[i];
const value = spread ? parsed._.slice(i) : parsed._[i];

if (spread) {
i = parsedParameters.length;
}

if (
required
&& (!value || (spread && value.length === 0))
) {
console.error(`Error: Missing required parameter ${stringify(name)}\n`);
showHelp();
return process.exit(1);
}

_[camelCase(name)] = value;
let { parameters } = options;
let cliArguments = parsed._ as string[];
const hasEof = parameters.indexOf('--');
const eofParameters = parameters.slice(hasEof + 1);
const mapping: Record<string, string | string[]> = Object.create(null);

if (hasEof > -1 && eofParameters.length > 0) {
parameters = parameters.slice(0, hasEof);

const eofArguments = parsed._['--'];
cliArguments = cliArguments.slice(0, -eofArguments.length || undefined);

mapParametersToArguments(
mapping,
parseParameters(parameters),
cliArguments,
showHelp,
);
mapParametersToArguments(
mapping,
parseParameters(eofParameters),
eofArguments,
showHelp,
);
} else {
mapParametersToArguments(
mapping,
parseParameters(parameters),
cliArguments,
showHelp,
);
}

Object.assign(
parsed._,
mapping,
);
}

const parsedWithApi = {
Expand Down
14 changes: 13 additions & 1 deletion src/render-help/generate-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,19 @@ function getUsage(options: Options) {
options.parameters
&& options.parameters.length > 0
) {
usage.push(options.parameters.join(' '));
const { parameters } = options;
const hasEof = parameters.indexOf('--');
const hasRequiredParametersAfterEof = hasEof > -1 && parameters.slice(hasEof + 1).some(parameter => parameter.startsWith('<'));
usage.push(
parameters
.map((parameter) => {
if (parameter !== '--') {
return parameter;
}
return hasRequiredParametersAfterEof ? '--' : '[--]';
})
.join(' '),
);
}

if (usage.length > 1) {
Expand Down
27 changes: 27 additions & 0 deletions tests/__snapshots__/help.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ EXAMPLES:
Received value: 123"
`;

exports[`invalid usage missing required parameter 1`] = `
"Error: Missing required parameter \\"value-a\\"
"
`;

exports[`show help command help 1`] = `
"my-cli test
Expand Down Expand Up @@ -175,6 +180,28 @@ exports[`show help parameters with no name 1`] = `
"
`;
exports[`show help parameters with optional -- 1`] = `
"my-cli
USAGE:
my-cli [flags...] <arg-a> [arg-b] [--] [arg-c]
FLAGS:
-h, --help Show help
"
`;
exports[`show help parameters with required -- 1`] = `
"my-cli
USAGE:
my-cli [flags...] <arg-a> [arg-b] -- <arg-c>
FLAGS:
-h, --help Show help
"
`;
exports[`show help undefined flags 1`] = `
"FLAGS:
-h, --help Show help
Expand Down
108 changes: 108 additions & 0 deletions tests/arguments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,42 @@ describe('error handling', () => {
}).toThrow('Invalid parameter: "[value.a]". Invalid character found "."');
});

test('duplicate parameters', () => {
expect(() => {
const parsed = cli(
{
parameters: ['[value-a]', '[value-a]', '[value-a]'],
},
);

expect<string[]>(parsed._).toStrictEqual([]);
}).toThrow('Invalid parameter: "value-a" is used more than once');
});

test('duplicate parameters across --', () => {
expect(() => {
const parsed = cli(
{
parameters: ['[value-a]', '--', '[value-a]'],
},
);

expect<string[]>(parsed._).toStrictEqual([]);
}).toThrow('Invalid parameter: "value-a" is used more than once');
});

test('multiple --', () => {
expect(() => {
const parsed = cli(
{
parameters: ['[value-a]', '--', '[value-b]', '--', '[value-c]'],
},
);

expect<string[]>(parsed._).toStrictEqual([]);
}).toThrow('Invalid parameter: "--". Must be wrapped in <> (required parameter) or [] (optional parameter)');
});

test('optional parameter before required parameter', () => {
expect(() => {
const parsed = cli(
Expand Down Expand Up @@ -105,6 +141,19 @@ describe('error handling', () => {
expect(mockConsoleError).toHaveBeenCalledWith('Error: Missing required parameter "value-a"\n');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});

test('missing -- parameter', () => {
cli(
{
parameters: ['--', '<value-a>'],
},
undefined,
[],
);

expect(mockConsoleError).toHaveBeenCalledWith('Error: Missing required parameter "value-a"\n');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
});
});

Expand All @@ -130,6 +179,46 @@ describe('parses arguments', () => {
expect(callback).toHaveBeenCalled();
});

test('simple parsing across --', () => {
const callback = jest.fn();
const parsed = cli(
{
parameters: ['<value-a>', '[value-b]', '[value c]', '--', '<value-d>', '[value-e]', '[value f]'],
},
(callbackParsed) => {
expect<string>(callbackParsed._.valueA).toBe('valueA');
expect<string | undefined>(callbackParsed._.valueB).toBe('valueB');
expect<string | undefined>(callbackParsed._.valueD).toBe('valueD');
callback();
},
['valueA', 'valueB', '--', 'valueD'],
);

expect<string>(parsed._.valueA).toBe('valueA');
expect<string | undefined>(parsed._.valueB).toBe('valueB');
expect<string | undefined>(parsed._.valueD).toBe('valueD');
expect(callback).toHaveBeenCalled();
});

test('simple parsing with empty --', () => {
const callback = jest.fn();
const parsed = cli(
{
parameters: ['<value-a>', '[value-b]', '[value c]', '--', '[value-d]'],
},
(callbackParsed) => {
expect<string>(callbackParsed._.valueA).toBe('valueA');
expect<string | undefined>(callbackParsed._.valueB).toBe('valueB');
callback();
},
['valueA', 'valueB'],
);

expect<string>(parsed._.valueA).toBe('valueA');
expect<string | undefined>(parsed._.valueB).toBe('valueB');
expect(callback).toHaveBeenCalled();
});

test('spread', () => {
const callback = jest.fn();
const parsed = cli(
Expand All @@ -147,6 +236,25 @@ describe('parses arguments', () => {
expect(callback).toHaveBeenCalled();
});

test('spread with --', () => {
const callback = jest.fn();
const parsed = cli(
{
parameters: ['<value-a...>', '--', '<value-b...>'],
},
(callbackParsed) => {
expect<string[]>(callbackParsed._.valueA).toStrictEqual(['valueA', 'valueB']);
expect<string[]>(callbackParsed._.valueB).toStrictEqual(['valueC', 'valueD']);
callback();
},
['valueA', 'valueB', '--', 'valueC', 'valueD'],
);

expect<string[]>(parsed._.valueA).toStrictEqual(['valueA', 'valueB']);
expect<string[]>(parsed._.valueB).toStrictEqual(['valueC', 'valueD']);
expect(callback).toHaveBeenCalled();
});

test('command', () => {
const callback = jest.fn();

Expand Down
Loading

0 comments on commit 7e24ad3

Please sign in to comment.