Skip to content

Commit d34c4f2

Browse files
authoredNov 7, 2022
fix(cli): refactor CLI argument parser (#3765)
This commit refactors the argument parser we use for our CLI module. Doing so fixes an issue with certain flags supported by Jest which can be passed multiple times. For instance, in the following example: ``` jest --coverage --reporters="default" --reporters="jest-junit" ``` all of the values for the `--reporters` flag ("default" and "jest-junit") should be collected into an array of values, instead of simply recording whichever value is farther to the right (Stencil's behavior before this commit). To support passing such arguments to the `stencil test` subcommand this commit adds a new recursive-descent parser in `src/cli/parse-flags.ts` to replace the somewhat ad-hoc approach that was there previously. It parses the following grammar: ``` CLIArguments → "" | CLITerm ( " " CLITerm )* ; CLITerm → EqualsArg | AliasEqualsArg | AliasArg | NegativeDashArg | NegativeArg | SimpleArg ; EqualsArg → "--" ArgName "=" CLIValue ; AliasEqualsArg → "-" AliasName "=" CLIValue ; AliasArg → "-" AliasName ( " " CLIValue )? ; NegativeDashArg → "--no-" ArgName ; NegativeArg → "--no" ArgName ; SimpleArg → "--" ArgName ( " " CLIValue )? ; ArgName → /^[a-zA-Z-]+$/ ; AliasName → /^[a-z]{1}$/ ; CLIValue → '"' /^[a-zA-Z0-9]+$/ '"' | /^[a-zA-Z0-9]+$/ ; ``` The regexes are a little fuzzy, but this is sort of an informal presentation, and additionally there are other constraints implemented in the code which handles all of these different terms. Refactoring this to use a proper parser (albeit a pretty simple one) allows our implementation to much more clearly conform to this defined grammar, and should hopefully both help us avoid other bugs in the future and be easier to maintain. See #3712 for more details on the issues with the `--reporters` flag in particular.
1 parent aa7c214 commit d34c4f2

File tree

6 files changed

+530
-213
lines changed

6 files changed

+530
-213
lines changed
 

‎src/cli/config-flags.ts

+44-23
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { LogLevel, TaskCommand } from '@stencil/core/declarations';
33
/**
44
* All the Boolean options supported by the Stencil CLI
55
*/
6-
export const BOOLEAN_CLI_ARGS = [
6+
export const BOOLEAN_CLI_FLAGS = [
77
'build',
88
'cache',
99
'checkVersion',
@@ -88,7 +88,7 @@ export const BOOLEAN_CLI_ARGS = [
8888
/**
8989
* All the Number options supported by the Stencil CLI
9090
*/
91-
export const NUMBER_CLI_ARGS = [
91+
export const NUMBER_CLI_FLAGS = [
9292
'port',
9393
// JEST CLI ARGS
9494
'maxConcurrency',
@@ -98,7 +98,7 @@ export const NUMBER_CLI_ARGS = [
9898
/**
9999
* All the String options supported by the Stencil CLI
100100
*/
101-
export const STRING_CLI_ARGS = [
101+
export const STRING_CLI_FLAGS = [
102102
'address',
103103
'config',
104104
'docsApi',
@@ -138,8 +138,9 @@ export const STRING_CLI_ARGS = [
138138
'testURL',
139139
'timers',
140140
'transform',
141+
] as const;
141142

142-
// ARRAY ARGS
143+
export const STRING_ARRAY_CLI_FLAGS = [
143144
'collectCoverageOnlyFrom',
144145
'coveragePathIgnorePatterns',
145146
'coverageReporters',
@@ -169,7 +170,7 @@ export const STRING_CLI_ARGS = [
169170
* `maxWorkers` is an argument which is used both by Stencil _and_ by Jest,
170171
* which means that we need to support parsing both string and number values.
171172
*/
172-
export const STRING_NUMBER_CLI_ARGS = ['maxWorkers'] as const;
173+
export const STRING_NUMBER_CLI_FLAGS = ['maxWorkers'] as const;
173174

174175
/**
175176
* All the LogLevel-type options supported by the Stencil CLI
@@ -178,7 +179,7 @@ export const STRING_NUMBER_CLI_ARGS = ['maxWorkers'] as const;
178179
* but this approach lets us make sure that we're handling all
179180
* our arguments in a type-safe way.
180181
*/
181-
export const LOG_LEVEL_CLI_ARGS = ['logLevel'] as const;
182+
export const LOG_LEVEL_CLI_FLAGS = ['logLevel'] as const;
182183

183184
/**
184185
* A type which gives the members of a `ReadonlyArray<string>` as
@@ -187,26 +188,39 @@ export const LOG_LEVEL_CLI_ARGS = ['logLevel'] as const;
187188
*/
188189
type ArrayValuesAsUnion<T extends ReadonlyArray<string>> = T[number];
189190

190-
export type BooleanCLIArg = ArrayValuesAsUnion<typeof BOOLEAN_CLI_ARGS>;
191-
export type StringCLIArg = ArrayValuesAsUnion<typeof STRING_CLI_ARGS>;
192-
export type NumberCLIArg = ArrayValuesAsUnion<typeof NUMBER_CLI_ARGS>;
193-
export type StringNumberCLIArg = ArrayValuesAsUnion<typeof STRING_NUMBER_CLI_ARGS>;
194-
export type LogCLIArg = ArrayValuesAsUnion<typeof LOG_LEVEL_CLI_ARGS>;
191+
export type BooleanCLIFlag = ArrayValuesAsUnion<typeof BOOLEAN_CLI_FLAGS>;
192+
export type StringCLIFlag = ArrayValuesAsUnion<typeof STRING_CLI_FLAGS>;
193+
export type StringArrayCLIFlag = ArrayValuesAsUnion<typeof STRING_ARRAY_CLI_FLAGS>;
194+
export type NumberCLIFlag = ArrayValuesAsUnion<typeof NUMBER_CLI_FLAGS>;
195+
export type StringNumberCLIFlag = ArrayValuesAsUnion<typeof STRING_NUMBER_CLI_FLAGS>;
196+
export type LogCLIFlag = ArrayValuesAsUnion<typeof LOG_LEVEL_CLI_FLAGS>;
195197

196-
type KnownCLIArg = BooleanCLIArg | StringCLIArg | NumberCLIArg | StringNumberCLIArg | LogCLIArg;
198+
export type KnownCLIFlag =
199+
| BooleanCLIFlag
200+
| StringCLIFlag
201+
| StringArrayCLIFlag
202+
| NumberCLIFlag
203+
| StringNumberCLIFlag
204+
| LogCLIFlag;
197205

198-
type AliasMap = Partial<Record<KnownCLIArg, string>>;
206+
type AliasMap = Partial<Record<string, KnownCLIFlag>>;
199207

200208
/**
201209
* For a small subset of CLI options we support a short alias e.g. `'h'` for `'help'`
202210
*/
203-
export const CLI_ARG_ALIASES: AliasMap = {
204-
config: 'c',
205-
help: 'h',
206-
port: 'p',
207-
version: 'v',
211+
export const CLI_FLAG_ALIASES: AliasMap = {
212+
c: 'config',
213+
h: 'help',
214+
p: 'port',
215+
v: 'version',
208216
};
209217

218+
/**
219+
* A regular expression which can be used to match a CLI flag for one of our
220+
* short aliases.
221+
*/
222+
export const CLI_FLAG_REGEX = new RegExp(`^-[chpv]{1}$`);
223+
210224
/**
211225
* Given two types `K` and `T` where `K` extends `ReadonlyArray<string>`,
212226
* construct a type which maps the strings in `K` as keys to values of type `T`.
@@ -223,31 +237,37 @@ type ObjectFromKeys<K extends ReadonlyArray<string>, T> = {
223237
* Type containing the possible Boolean configuration flags, to be included
224238
* in ConfigFlags, below
225239
*/
226-
type BooleanConfigFlags = ObjectFromKeys<typeof BOOLEAN_CLI_ARGS, boolean>;
240+
type BooleanConfigFlags = ObjectFromKeys<typeof BOOLEAN_CLI_FLAGS, boolean>;
227241

228242
/**
229243
* Type containing the possible String configuration flags, to be included
230244
* in ConfigFlags, below
231245
*/
232-
type StringConfigFlags = ObjectFromKeys<typeof STRING_CLI_ARGS, string>;
246+
type StringConfigFlags = ObjectFromKeys<typeof STRING_CLI_FLAGS, string>;
247+
248+
/**
249+
* Type containing the possible String Array configuration flags. This is
250+
* one of the 'constituent types' for `ConfigFlags`.
251+
*/
252+
type StringArrayConfigFlags = ObjectFromKeys<typeof STRING_ARRAY_CLI_FLAGS, string[]>;
233253

234254
/**
235255
* Type containing the possible numeric configuration flags, to be included
236256
* in ConfigFlags, below
237257
*/
238-
type NumberConfigFlags = ObjectFromKeys<typeof NUMBER_CLI_ARGS, number>;
258+
type NumberConfigFlags = ObjectFromKeys<typeof NUMBER_CLI_FLAGS, number>;
239259

240260
/**
241261
* Type containing the configuration flags which may be set to either string
242262
* or number values.
243263
*/
244-
type StringNumberConfigFlags = ObjectFromKeys<typeof STRING_NUMBER_CLI_ARGS, string | number>;
264+
type StringNumberConfigFlags = ObjectFromKeys<typeof STRING_NUMBER_CLI_FLAGS, string | number>;
245265

246266
/**
247267
* Type containing the possible LogLevel configuration flags, to be included
248268
* in ConfigFlags, below
249269
*/
250-
type LogLevelFlags = ObjectFromKeys<typeof LOG_LEVEL_CLI_ARGS, LogLevel>;
270+
type LogLevelFlags = ObjectFromKeys<typeof LOG_LEVEL_CLI_FLAGS, LogLevel>;
251271

252272
/**
253273
* The configuration flags which can be set by the user on the command line.
@@ -266,6 +286,7 @@ type LogLevelFlags = ObjectFromKeys<typeof LOG_LEVEL_CLI_ARGS, LogLevel>;
266286
export interface ConfigFlags
267287
extends BooleanConfigFlags,
268288
StringConfigFlags,
289+
StringArrayConfigFlags,
269290
NumberConfigFlags,
270291
StringNumberConfigFlags,
271292
LogLevelFlags {

‎src/cli/parse-flags.ts

+322-177
Large diffs are not rendered by default.

‎src/cli/test/parse-flags.spec.ts

+141-11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { toDashCase } from '@utils';
22

33
import { LogLevel } from '../../declarations';
4-
import { BOOLEAN_CLI_ARGS, NUMBER_CLI_ARGS, STRING_CLI_ARGS } from '../config-flags';
5-
import { parseEqualsArg, parseFlags } from '../parse-flags';
4+
import {
5+
BOOLEAN_CLI_FLAGS,
6+
NUMBER_CLI_FLAGS,
7+
STRING_ARRAY_CLI_FLAGS,
8+
STRING_CLI_FLAGS,
9+
StringArrayCLIFlag,
10+
} from '../config-flags';
11+
import { Empty, parseEqualsArg, parseFlags } from '../parse-flags';
612

713
describe('parseFlags', () => {
814
it('should get known and unknown args', () => {
@@ -54,7 +60,7 @@ describe('parseFlags', () => {
5460
* will result in a value like `[true, true]` being set on ConfigFlags, which
5561
* will cause these tests to start failing.
5662
*/
57-
describe.each(BOOLEAN_CLI_ARGS)('should parse boolean flag %s', (cliArg) => {
63+
describe.each(BOOLEAN_CLI_FLAGS)('should parse boolean flag %s', (cliArg) => {
5864
it('should parse arg', () => {
5965
const flags = parseFlags([`--${cliArg}`]);
6066
expect(flags.knownArgs).toEqual([`--${cliArg}`]);
@@ -75,14 +81,14 @@ describe('parseFlags', () => {
7581
expect(flags[cliArg]).toBe(false);
7682
});
7783

78-
it('should set value to null if not present', () => {
84+
it('should not set value if not present', () => {
7985
const flags = parseFlags([]);
8086
expect(flags.knownArgs).toEqual([]);
81-
expect(flags[cliArg]).toBe(null);
87+
expect(flags[cliArg]).toBe(undefined);
8288
});
8389
});
8490

85-
describe.each(STRING_CLI_ARGS)('should parse string flag %s', (cliArg) => {
91+
describe.each(STRING_CLI_FLAGS)('should parse string flag %s', (cliArg) => {
8692
it(`should parse "--${cliArg} value"`, () => {
8793
const flags = parseFlags([`--${cliArg}`, 'test-value']);
8894
expect(flags.knownArgs).toEqual([`--${cliArg}`, 'test-value']);
@@ -112,7 +118,7 @@ describe('parseFlags', () => {
112118
});
113119
});
114120

115-
it.each(NUMBER_CLI_ARGS)('should parse number flag %s', (cliArg) => {
121+
it.each(NUMBER_CLI_FLAGS)('should parse number flag %s', (cliArg) => {
116122
const flags = parseFlags([`--${cliArg}`, '42']);
117123
expect(flags.knownArgs).toEqual([`--${cliArg}`, '42']);
118124
expect(flags.unknownArgs).toEqual([]);
@@ -194,7 +200,7 @@ describe('parseFlags', () => {
194200

195201
it('should not parse --max-workers', () => {
196202
const flags = parseFlags([]);
197-
expect(flags.maxWorkers).toBe(null);
203+
expect(flags.maxWorkers).toBe(undefined);
198204
});
199205
});
200206

@@ -246,12 +252,136 @@ describe('parseFlags', () => {
246252
['--fooBar=baz', '--fooBar', 'baz'],
247253
['--foo-bar=4', '--foo-bar', '4'],
248254
['--fooBar=twenty=3*4', '--fooBar', 'twenty=3*4'],
249-
['--fooBar', '--fooBar', ''],
250-
['--foo-bar', '--foo-bar', ''],
255+
['--fooBar', '--fooBar', Empty],
256+
['--foo-bar', '--foo-bar', Empty],
257+
['--foo-bar=""', '--foo-bar', '""'],
251258
])('should parse %s correctly', (testArg, expectedArg, expectedValue) => {
252259
const [arg, value] = parseEqualsArg(testArg);
253260
expect(arg).toBe(expectedArg);
254-
expect(value).toBe(expectedValue);
261+
expect(value).toEqual(expectedValue);
262+
});
263+
});
264+
265+
describe.each(STRING_ARRAY_CLI_FLAGS)('should parse string flag %s', (cliArg: StringArrayCLIFlag) => {
266+
it(`should parse single value: "--${cliArg} test-value"`, () => {
267+
const flags = parseFlags([`--${cliArg}`, 'test-value']);
268+
expect(flags.knownArgs).toEqual([`--${cliArg}`, 'test-value']);
269+
expect(flags.unknownArgs).toEqual([]);
270+
expect(flags[cliArg]).toEqual(['test-value']);
271+
});
272+
273+
it(`should parse multiple values: "--${cliArg} test-value"`, () => {
274+
const flags = parseFlags([`--${cliArg}`, 'test-value', `--${cliArg}`, 'second-test-value']);
275+
expect(flags.knownArgs).toEqual([`--${cliArg}`, 'test-value', `--${cliArg}`, 'second-test-value']);
276+
expect(flags.unknownArgs).toEqual([]);
277+
expect(flags[cliArg]).toEqual(['test-value', 'second-test-value']);
278+
});
279+
280+
it(`should parse "--${cliArg}=value"`, () => {
281+
const flags = parseFlags([`--${cliArg}=path/to/file.js`]);
282+
expect(flags.knownArgs).toEqual([`--${cliArg}`, 'path/to/file.js']);
283+
expect(flags.unknownArgs).toEqual([]);
284+
expect(flags[cliArg]).toEqual(['path/to/file.js']);
285+
});
286+
287+
it(`should parse multiple values: "--${cliArg}=test-value"`, () => {
288+
const flags = parseFlags([`--${cliArg}=test-value`, `--${cliArg}=second-test-value`]);
289+
expect(flags.knownArgs).toEqual([`--${cliArg}`, 'test-value', `--${cliArg}`, 'second-test-value']);
290+
expect(flags.unknownArgs).toEqual([]);
291+
expect(flags[cliArg]).toEqual(['test-value', 'second-test-value']);
292+
});
293+
294+
it(`should parse "--${toDashCase(cliArg)} value"`, () => {
295+
const flags = parseFlags([`--${toDashCase(cliArg)}`, 'path/to/file.js']);
296+
expect(flags.knownArgs).toEqual([`--${toDashCase(cliArg)}`, 'path/to/file.js']);
297+
expect(flags.unknownArgs).toEqual([]);
298+
expect(flags[cliArg]).toEqual(['path/to/file.js']);
299+
});
300+
301+
it(`should parse multiple values: "--${toDashCase(cliArg)} test-value"`, () => {
302+
const flags = parseFlags([
303+
`--${toDashCase(cliArg)}`,
304+
'test-value',
305+
`--${toDashCase(cliArg)}`,
306+
'second-test-value',
307+
]);
308+
expect(flags.knownArgs).toEqual([
309+
`--${toDashCase(cliArg)}`,
310+
'test-value',
311+
`--${toDashCase(cliArg)}`,
312+
'second-test-value',
313+
]);
314+
expect(flags.unknownArgs).toEqual([]);
315+
expect(flags[cliArg]).toEqual(['test-value', 'second-test-value']);
316+
});
317+
318+
it(`should parse "--${toDashCase(cliArg)}=value"`, () => {
319+
const flags = parseFlags([`--${toDashCase(cliArg)}=path/to/file.js`]);
320+
expect(flags.knownArgs).toEqual([`--${toDashCase(cliArg)}`, 'path/to/file.js']);
321+
expect(flags.unknownArgs).toEqual([]);
322+
expect(flags[cliArg]).toEqual(['path/to/file.js']);
323+
});
324+
325+
it(`should parse multiple values: "--${toDashCase(cliArg)}=test-value"`, () => {
326+
const flags = parseFlags([`--${toDashCase(cliArg)}=test-value`, `--${toDashCase(cliArg)}=second-test-value`]);
327+
expect(flags.knownArgs).toEqual([
328+
`--${toDashCase(cliArg)}`,
329+
'test-value',
330+
`--${toDashCase(cliArg)}`,
331+
'second-test-value',
332+
]);
333+
expect(flags.unknownArgs).toEqual([]);
334+
expect(flags[cliArg]).toEqual(['test-value', 'second-test-value']);
335+
});
336+
});
337+
338+
describe('error reporting', () => {
339+
it('should throw if you pass no argument to a string flag', () => {
340+
expect(() => {
341+
parseFlags(['--cacheDirectory', '--someOtherFlag']);
342+
}).toThrow('when parsing CLI flag "--cacheDirectory": expected a string argument but received nothing');
343+
});
344+
345+
it('should throw if you pass no argument to a string array flag', () => {
346+
expect(() => {
347+
parseFlags(['--reporters', '--someOtherFlag']);
348+
}).toThrow('when parsing CLI flag "--reporters": expected a string argument but received nothing');
349+
});
350+
351+
it('should throw if you pass no argument to a number flag', () => {
352+
expect(() => {
353+
parseFlags(['--port', '--someOtherFlag']);
354+
}).toThrow('when parsing CLI flag "--port": expected a number argument but received nothing');
355+
});
356+
357+
it('should throw if you pass a non-number argument to a number flag', () => {
358+
expect(() => {
359+
parseFlags(['--port', 'stringy']);
360+
}).toThrow('when parsing CLI flag "--port": expected a number but received "stringy"');
361+
});
362+
363+
it('should throw if you pass a bad number argument to a number flag', () => {
364+
expect(() => {
365+
parseFlags(['--port=NaN']);
366+
}).toThrow('when parsing CLI flag "--port": expected a number but received "NaN"');
367+
});
368+
369+
it('should throw if you pass no argument to a string/number flag', () => {
370+
expect(() => {
371+
parseFlags(['--maxWorkers']);
372+
}).toThrow('when parsing CLI flag "--maxWorkers": expected a string or a number but received nothing');
373+
});
374+
375+
it('should throw if you pass an invalid log level for --logLevel', () => {
376+
expect(() => {
377+
parseFlags(['--logLevel', 'potato']);
378+
}).toThrow('when parsing CLI flag "--logLevel": expected to receive a valid log level but received "potato"');
379+
});
380+
381+
it('should throw if you pass no argument to --logLevel', () => {
382+
expect(() => {
383+
parseFlags(['--logLevel']);
384+
}).toThrow('when parsing CLI flag "--logLevel": expected to receive a valid log level but received nothing');
255385
});
256386
});
257387
});

‎src/utils/helpers.ts

+11
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ export const dashToPascalCase = (str: string): string =>
2828
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
2929
.join('');
3030

31+
/**
32+
* Convert a string to 'camelCase'
33+
*
34+
* @param str the string to convert
35+
* @returns the converted string
36+
*/
37+
export const toCamelCase = (str: string) => {
38+
const pascalCase = dashToPascalCase(str);
39+
return pascalCase.charAt(0).toLowerCase() + pascalCase.slice(1);
40+
};
41+
3142
export const toTitleCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
3243

3344
export const noop = (): any => {

‎src/utils/test/helpers.spec.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { dashToPascalCase, isDef, isPromise, toDashCase } from '../helpers';
1+
import { dashToPascalCase, isDef, isPromise, toCamelCase, toDashCase } from '../helpers';
22

33
describe('util helpers', () => {
44
describe('isPromise', () => {
@@ -45,6 +45,16 @@ describe('util helpers', () => {
4545
});
4646
});
4747

48+
describe('toCamelCase', () => {
49+
it.each([
50+
['my-3d-component', 'my3dComponent'],
51+
['madison-wisconsin', 'madisonWisconsin'],
52+
['wisconsin', 'wisconsin'],
53+
])('%s => %s', (input: string, exp: string) => {
54+
expect(toCamelCase(input)).toBe(exp);
55+
});
56+
});
57+
4858
describe('toDashCase', () => {
4959
it('My3dComponent => my-3d-component', () => {
5060
expect(toDashCase('My3dComponent')).toBe('my-3d-component');

‎src/utils/util.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,4 @@ const SKIP_DEPS = ['@stencil/core'];
172172
export const readOnlyArrayHasStringMember = <T extends string>(
173173
readOnlyArray: ReadonlyArray<T>,
174174
maybeMember: T | string
175-
): boolean => readOnlyArray.includes(maybeMember as typeof readOnlyArray[number]);
175+
): maybeMember is T => readOnlyArray.includes(maybeMember as typeof readOnlyArray[number]);

0 commit comments

Comments
 (0)
Please sign in to comment.