Skip to content

Commit

Permalink
feat: positional parameters and extract helper (#5)
Browse files Browse the repository at this point in the history
* refactor: add shared type for `ParsedString`

* feat: add support for boolean negations (BREAKING CHANGE)

* feat: add helper to "extract" result fo parsing

* chore: add @types/node to dev dependencies

* BREAKING CHANGE: params now parsed in opposite order

* feat: add positional parameters

* docs: add examples and use cases

* test: fix expections
  • Loading branch information
ForbesLindesay authored Jul 7, 2020
1 parent 3f4dd9b commit 64eec76
Show file tree
Hide file tree
Showing 8 changed files with 809 additions and 75 deletions.
624 changes: 565 additions & 59 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"devDependencies": {
"@forbeslindesay/tsconfig": "^2.0.0",
"@types/jest": "^25.2.1",
"@types/node": "^14.0.18",
"husky": "^4.2.5",
"jest": "^25.3.0",
"lint-staged": "^10.1.3",
Expand Down
131 changes: 131 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ test('parse empty array', () => {
| {
valid: false;
reason: string;
extract: () => never;
}
| {
valid: true;
Expand All @@ -46,11 +47,19 @@ test('parse empty array', () => {
verified: boolean;
force: boolean;
}>;
extract: () => Partial<{
help: boolean;
logLevel: 'debug' | 'info' | 'warn' | 'error';
name: string;
verified: boolean;
force: boolean;
}>;
}
>
>();

expect(result).toEqual({
extract: expect.any(Function),
valid: true,
rest: [],
parsed: {},
Expand All @@ -69,6 +78,7 @@ test('parse some valid args then some not valid args', () => {
]);

expect(result).toEqual({
extract: expect.any(Function),
valid: true,
rest: ['oops', '--name', 'Forbes Lindesay'],
parsed: {
Expand All @@ -89,7 +99,128 @@ test('parse duplicate key', () => {
]);

expect(result).toEqual({
extract: expect.any(Function),
valid: false,
reason: 'You have specified more than one value for --logLevel',
});
});

test('positional', () => {
const positional = startChain()
.addParam(param.string(['--input'], 'input'))
.addParam(param.string(['--output'], 'output'))
.addParam(param.positionalString('value'));
const result = parse(positional, ['--input', 'a', '--output', 'b', 'val']);

ta.assert<
ta.Equal<
typeof result,
| {
valid: false;
reason: string;
extract: () => never;
}
| {
valid: true;
rest: string[];
parsed: Partial<{
input: string;
output: string;
value: string;
}>;
extract: () => Partial<{
input: string;
output: string;
value: string;
}>;
}
>
>();

expect(result).toEqual({
extract: expect.any(Function),
valid: true,
rest: [],
parsed: {
input: 'a',
output: 'b',
value: 'val',
},
});
expect(parse(positional, ['--input', 'a', 'val', '--output', 'b'])).toEqual({
extract: expect.any(Function),
valid: true,
rest: [],
parsed: {
input: 'a',
output: 'b',
value: 'val',
},
});
expect(parse(positional, ['val', '--input', 'a', '--output', 'b'])).toEqual({
extract: expect.any(Function),
valid: true,
rest: [],
parsed: {
input: 'a',
output: 'b',
value: 'val',
},
});
expect(parse(positional, ['--val', '--input', 'a', '--output', 'b'])).toEqual(
{
extract: expect.any(Function),
valid: true,
rest: ['--val', '--input', 'a', '--output', 'b'],
parsed: {},
},
);
});
test('multiple positional', () => {
const positional = startChain()
.addParam(param.positionalString('input'))
.addParam(param.positionalString('output'))
.addParam(param.positionalString('value'));
const result = parse(positional, ['a', 'b', 'val']);

ta.assert<
ta.Equal<
typeof result,
| {
valid: false;
reason: string;
extract: () => never;
}
| {
valid: true;
rest: string[];
parsed: Partial<{
input: string;
output: string;
value: string;
}>;
extract: () => Partial<{
input: string;
output: string;
value: string;
}>;
}
>
>();

expect(result).toEqual({
extract: expect.any(Function),
valid: true,
rest: [],
parsed: {
input: 'a',
output: 'b',
value: 'val',
},
});
expect(result.extract()).toEqual({
input: 'a',
output: 'b',
value: 'val',
});
});
2 changes: 1 addition & 1 deletion src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function startChain<T>(
addParam: <S>(childParam: ParameterReducer<S>): Chain<T & S> =>
startChain(
(input, parsed) =>
childParam(input, parsed) || (param(input, parsed) as any),
(param(input, parsed) as any) || childParam(input, parsed),
),
},
);
Expand Down
23 changes: 21 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,37 @@ import * as param from './parameters';
import {ParameterReducer} from './types';

export {param};
export {ParameterReducerResult, ParameterReducer} from './types';
export {ParameterReducerResult, ParameterReducer, ParsedString} from './types';
export {Chain, startChain} from './chain';
export {valid, invalid} from './helpers';

function extractReason(this: {reason: string}): never {
console.error(`🚨 ${this.reason}`);
process.exit(1);
}

function extractResult<T>(this: {rest: string[]; parsed: T}): T {
if (this.rest.length) {
console.error(`🚨 Unrecognized option ${this.rest[0]}.`);
process.exit(1);
}
return this.parsed;
}

export function parse<T>(
parameters: ParameterReducer<T>,
input: string[],
):
| {
valid: false;
reason: string;
extract: () => never;
}
| {
valid: true;
rest: string[];
parsed: Partial<T>;
extract: () => Partial<T>;
} {
let rest = input;
let parsed = {};
Expand All @@ -30,12 +45,16 @@ export function parse<T>(
rest = result.rest;
parsed = result.parsed;
} else {
return result;
return {
...result,
extract: extractReason,
};
}
}
return {
valid: true,
rest,
parsed,
extract: extractResult,
};
}
94 changes: 81 additions & 13 deletions src/parameters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ParameterReducer} from '.';
import {ParameterReducer, ParsedString} from '.';
import {valid, invalid} from './helpers';

export function flag<TName extends string>(
Expand All @@ -8,6 +8,7 @@ export function flag<TName extends string>(
const shorthands = new Set(
keys.filter((k) => /^\-[a-z]$/i.test(k)).map((k) => k[1]),
);
const negations = keys.map((key) => key.replace(/^\-\-?/, '--no-'));
return (input, parsed) => {
for (const key of keys) {
if (input[0] === key) {
Expand All @@ -17,6 +18,14 @@ export function flag<TName extends string>(
return valid(parsed, name, true, input.slice(1));
}
}
for (const key of negations) {
if (input[0] === key) {
if ((parsed as any)[name] !== undefined) {
return invalid(`You have specified more than one value for ${key}`);
}
return valid(parsed, name, false, input.slice(1));
}
}
if (shorthands.size && /^\-[a-z]+$/i.test(input[0])) {
for (const s of input[0].substr(1).split('')) {
if (shorthands.has(s)) {
Expand All @@ -37,12 +46,7 @@ export function flag<TName extends string>(
export function parsedString<TName extends string, TParsed>(
keys: string[],
name: TName,
parse: (
value: string,
key: string,
) =>
| {readonly valid: true; readonly value: TParsed}
| {readonly valid: false; readonly reason: string},
parse: (value: string, key: string) => ParsedString<TParsed>,
): ParameterReducer<{[name in TName]: TParsed}> {
return (input, parsed) => {
for (const key of keys) {
Expand All @@ -65,12 +69,7 @@ export function parsedString<TName extends string, TParsed>(
export function parsedStringList<TName extends string, TParsed>(
keys: string[],
name: TName,
parse: (
value: string,
key: string,
) =>
| {readonly valid: true; readonly value: TParsed}
| {readonly valid: false; readonly reason: string},
parse: (value: string, key: string) => ParsedString<TParsed>,
): ParameterReducer<{[name in TName]: TParsed[]}> {
return (input, parsed) => {
for (const key of keys) {
Expand Down Expand Up @@ -124,3 +123,72 @@ export function integer<TName extends string>(
return valid(value);
});
}

export function parsedPositionalString<TName extends string, TParsed>(
name: TName,
parse: (value: string) => undefined | ParsedString<TParsed>,
): ParameterReducer<{[name in TName]: TParsed}> {
return (input, parsed) => {
if ((parsed as any)[name] !== undefined) {
return undefined;
}
const result = parse(input[0]);
if (!result?.valid) return result;
return valid(parsed, name, result.value, input.slice(1));
};
}

export function positionalString<TName extends string>(name: TName) {
return parsedPositionalString(name, (value) => {
if (value[0] === '-') return undefined;
return valid(value);
});
}

export function parsedPositionalStringList<TName extends string, TParsed>(
name: TName,
parse: (value: string) => undefined | ParsedString<TParsed>,
options: {eager?: boolean} = {},
): ParameterReducer<{[name in TName]: TParsed[]}> {
return (input, parsed) => {
if (options.eager) {
const results = [];
let i = 0;
for (; i < input.length; i++) {
const result = parse(input[i]);
if (!result) break;
if (!result.valid) return result;
results.push(input[i]);
}
if (i === 0) return undefined;
return valid(
parsed,
name,
[...((parsed as any)[name] || []), ...results],
input.slice(i),
);
}
const result = parse(input[0]);
if (!result?.valid) return result;
return valid(
parsed,
name,
[...((parsed as any)[name] || []), result.value],
input.slice(1),
);
};
}

export function positionalStringList<TName extends string>(
name: TName,
options: {eager?: boolean} = {},
) {
return parsedPositionalStringList(
name,
(value) => {
if (value[0] === '-') return undefined;
return valid(value);
},
options,
);
}
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ export type ParameterReducer<TParsed> = <TAlreadyParsed>(
input: string[],
parsed: TAlreadyParsed,
) => ParameterReducerResult<TParsed, TAlreadyParsed>;

export type ParsedString<TParsed> =
| {readonly valid: true; readonly value: TParsed}
| {readonly valid: false; readonly reason: string};
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,11 @@
jest-diff "^25.2.1"
pretty-format "^25.2.1"

"@types/node@^14.0.18":
version "14.0.18"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.18.tgz#5111b2285659442f9f95697386a2b42b875bd7e9"
integrity sha512-0Z3nS5acM0cIV4JPzrj9g/GH0Et5vmADWtip3YOXOp1NpOLU8V3KoZDc8ny9c1pe/YSYYzQkAWob6dyV/EWg4g==

"@types/parse-json@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
Expand Down

0 comments on commit 64eec76

Please sign in to comment.