cli-kiss 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/Reader.ts CHANGED
@@ -7,216 +7,200 @@ import {
7
7
  } from "./Typo";
8
8
 
9
9
  /**
10
- * An opaque key that uniquely identifies a registered CLI option within a
11
- * {@link ReaderArgs} instance.
12
- *
13
- * Keys are returned by {@link ReaderArgs.registerOption} and passed back to
14
- * {@link ReaderArgs.getOptionValues} to retrieve the parsed values. The internal
15
- * representation is intentionally opaque — treat it as a handle, not a string.
10
+ * Opaque key returned by {@link ReaderArgs.registerOption}.
16
11
  */
17
12
  export type ReaderOptionKey = (string | { __brand: "ReaderOptionKey" }) & {
18
13
  __brand: "ReaderOptionKey";
19
14
  };
20
15
 
21
16
  /**
22
- * Interface for registering and querying CLI options during argument parsing.
23
- *
24
- * {@link ReaderArgs} implements both `ReaderOptions` and {@link ReaderPositionals}.
25
- * The two interfaces are exposed separately so that option and positional parsing logic
26
- * can depend only on the capability they need.
17
+ * Parsing behaviour for a registered option, passed to {@link ReaderArgs.registerOption}.
18
+ */
19
+ export type ReaderOptionParsing = {
20
+ consumeShortGroup: boolean;
21
+ consumeNextArg: (
22
+ inlined: string | null,
23
+ separated: Array<string>,
24
+ next: string | undefined,
25
+ ) => boolean;
26
+ };
27
+
28
+ /**
29
+ * Result of parsing an option, including its inlined value and any following separated values.
30
+ */
31
+ export type ReaderOptionValue = {
32
+ inlined: string | null;
33
+ separated: Array<string>;
34
+ };
35
+
36
+ /**
37
+ * Option registration/query interface. Subset of {@link ReaderArgs}.
27
38
  */
28
39
  export type ReaderOptions = {
29
40
  /**
30
- * Registers a new option so the parser can recognise it when scanning argument tokens.
41
+ * Registers an option; all `longs` and `shorts` share the same key.
31
42
  *
32
- * @param definition.longs - The long-form names (without `--`) for this option.
33
- * @param definition.shorts - The short-form names (without `-`) for this option.
34
- * @param definition.valued - When `true`, the option consumes the following token as
35
- * its value. When `false`, the option is a boolean flag.
36
- * @returns An opaque {@link ReaderOptionKey} used to retrieve parsed values later.
37
- * @throws `Error` if any of the given names has already been registered, or if a
38
- * short name overlaps (is a prefix of, or has as a prefix, another registered short).
43
+ * @param definition.longs - Long-form names (without `--`).
44
+ * @param definition.shorts - Short-form names (without `-`).
45
+ * @param definition.parsing - Parsing behaviour.
46
+ * @returns A {@link ReaderOptionKey} for later retrieval.
47
+ * @throws `Error` if a name is already registered or short names overlap.
39
48
  */
40
49
  registerOption(definition: {
41
50
  longs: Array<string>;
42
51
  shorts: Array<string>;
43
- valued: boolean;
52
+ parsing: ReaderOptionParsing;
44
53
  }): ReaderOptionKey;
45
54
  /**
46
- * Returns all values collected for the option identified by `key`.
47
- *
48
- * @param key - The key returned by a prior {@link ReaderOptions.registerOption} call.
49
- * @returns An array of raw string values, one per occurrence of the option on the
50
- * command line. Empty if the option was never provided.
51
- * @throws `Error` if `key` was not previously registered on this instance.
55
+ * Returns all values collected for `key`.
52
56
  */
53
- getOptionValues(key: ReaderOptionKey): Array<string>;
57
+ getOptionValues(key: ReaderOptionKey): Array<ReaderOptionValue>;
54
58
  };
55
59
 
56
60
  /**
57
- * Interface for consuming positional (non-option) argument tokens during parsing.
58
- *
59
- * {@link ReaderArgs} implements both {@link ReaderOptions} and `ReaderPositionals`.
61
+ * Positional consumption interface. Subset of {@link ReaderArgs}.
60
62
  */
61
63
  export type ReaderPositionals = {
62
64
  /**
63
- * Consumes and returns the next positional token from the argument list, skipping
64
- * any option tokens (which are parsed as side-effects).
65
+ * Returns the next positional token, parsing intervening options as side-effects.
65
66
  *
66
- * @returns The next positional string value, or `undefined` if no more positionals
67
- * are available.
68
- * @throws {@link TypoError} if an unrecognised option token is encountered while
69
- * scanning for the next positional.
67
+ * @returns The next positional, or `undefined` when exhausted.
68
+ * @throws {@link TypoError} on an unrecognised option.
70
69
  */
71
70
  consumePositional(): string | undefined;
72
71
  };
73
72
 
74
73
  /**
75
- * The core argument parser for `cli-kiss`. Parses a flat array of raw CLI tokens into
76
- * named options and positional values.
77
- *
78
- * Options must be registered with {@link ReaderArgs.registerOption} **before**
79
- * {@link ReaderArgs.consumePositional} is called, because the parser needs to know
80
- * whether each token is an option name, an option value, or a bare positional.
74
+ * Core argument parser: converts raw CLI tokens into named options and positionals.
75
+ * Options must be registered before {@link ReaderArgs.consumePositional} is called.
81
76
  *
82
- * **Supported argument syntax:**
83
- * - Long options: `--name`, `--name value`, `--name=value`
84
- * - Short options: `-n`, `-n value`, `-n=value`, `-nvalue`, `-abc` (stacked flags)
85
- * - End-of-options separator: `--` — all subsequent tokens are treated as positionals.
77
+ * Supported syntax: `--name`, `--name value`, `--name=value`,
78
+ * `-n`, `-n value`, `-nvalue`, `-abc` (stacked), `--` (end-of-options).
86
79
  *
87
- * In most cases you do not need to use `ReaderArgs` directly; it is created internally
88
- * by {@link runAndExit}. It is exposed for advanced use cases such as building
89
- * custom runners.
80
+ * Created internally by {@link runAndExit}; exposed for advanced / custom runners.
90
81
  */
91
82
  export class ReaderArgs {
92
83
  #args: ReadonlyArray<string>;
93
84
  #parsedIndex: number;
94
85
  #parsedDouble: boolean;
95
- #keyByLong: Map<string, ReaderOptionKey>;
96
- #keyByShort: Map<string, ReaderOptionKey>;
97
- #valuedByKey: Map<ReaderOptionKey, boolean>;
98
- #resultByKey: Map<ReaderOptionKey, Array<string>>;
86
+ #optionContextByLong: Map<string, ReaderOptionContext>;
87
+ #optionContextByShort: Map<string, ReaderOptionContext>;
88
+ #optionContextByKey: Map<ReaderOptionKey, ReaderOptionContext>;
99
89
 
100
90
  /**
101
- * @param args - The raw command-line tokens to parse. Typically `process.argv.slice(2)`.
102
- * The array is not modified; a read cursor is maintained internally.
91
+ * @param args - Raw CLI tokens (e.g. `process.argv.slice(2)`). Not mutated.
103
92
  */
104
93
  constructor(args: ReadonlyArray<string>) {
105
94
  this.#args = args;
106
95
  this.#parsedIndex = 0;
107
96
  this.#parsedDouble = false;
108
- this.#keyByLong = new Map();
109
- this.#keyByShort = new Map();
110
- this.#valuedByKey = new Map();
111
- this.#resultByKey = new Map();
97
+ this.#optionContextByLong = new Map();
98
+ this.#optionContextByShort = new Map();
99
+ this.#optionContextByKey = new Map();
112
100
  }
113
101
 
114
102
  /**
115
- * Registers a CLI option so the parser can recognise it.
116
- *
117
- * All `longs` and `shorts` are associated with the same returned key. Calling
118
- * `getOptionValues(key)` after parsing will return values collected under any of the
119
- * registered names.
120
- *
121
- * Short names support stacking (e.g. `-abc` is parsed as `-a -b -c`) and inline
122
- * values (e.g. `-nvalue`). Short names must not be a prefix of, nor have as a prefix,
123
- * any other registered short name — the parser uses prefix matching to parse stacked
124
- * shorts, so overlapping prefixes would be ambiguous.
103
+ * Registers an option; all `longs` and `shorts` share the same key.
104
+ * Short names must not be prefixes of one another.
125
105
  *
126
106
  * @param definition.longs - Long-form names (without `--`).
127
107
  * @param definition.shorts - Short-form names (without `-`).
128
- * @param definition.valued - `true` if the option consumes a value; `false` for flags.
129
- * @returns An opaque {@link ReaderOptionKey} to pass to {@link ReaderArgs.getOptionValues}.
130
- * @throws `Error` if any name is already registered or if two short names overlap.
108
+ * @param definition.parsing - Parsing behaviour.
109
+ * @returns A {@link ReaderOptionKey} for {@link ReaderArgs.getOptionValues}.
110
+ * @throws `Error` if any name is already registered or short names overlap.
131
111
  */
132
112
  registerOption(definition: {
133
113
  longs: Array<string>;
134
114
  shorts: Array<string>;
135
- valued: boolean;
115
+ parsing: ReaderOptionParsing;
136
116
  }) {
137
117
  const key = [
138
118
  ...definition.longs.map((long) => `--${long}`),
139
119
  ...definition.shorts.map((short) => `-${short}`),
140
120
  ].join(", ") as ReaderOptionKey;
141
121
  for (const long of definition.longs) {
142
- if (this.#keyByLong.has(long)) {
122
+ if (!this.#isValidOptionName(long)) {
123
+ throw new Error(`Invalid option name: --${long}`);
124
+ }
125
+ if (this.#optionContextByLong.has(long)) {
143
126
  throw new Error(`Option already registered: --${long}`);
144
127
  }
145
- this.#keyByLong.set(long, key);
146
128
  }
147
129
  for (const short of definition.shorts) {
148
- if (this.#keyByShort.has(short)) {
130
+ if (!this.#isValidOptionName(short)) {
131
+ throw new Error(`Invalid option name: -${short}`);
132
+ }
133
+ if (this.#optionContextByShort.has(short)) {
149
134
  throw new Error(`Option already registered: -${short}`);
150
135
  }
151
136
  for (let i = 0; i < short.length; i++) {
152
137
  const shortSlice = short.slice(0, i);
153
- if (this.#keyByShort.has(shortSlice)) {
138
+ if (this.#optionContextByShort.has(shortSlice)) {
154
139
  throw new Error(
155
140
  `Option -${short} overlap with shorter option: -${shortSlice}`,
156
141
  );
157
142
  }
158
143
  }
159
- for (const shortOther of this.#keyByShort.keys()) {
144
+ for (const shortOther of this.#optionContextByShort.keys()) {
160
145
  if (shortOther.startsWith(short)) {
161
146
  throw new Error(
162
147
  `Option -${short} overlap with longer option: -${shortOther}`,
163
148
  );
164
149
  }
165
150
  }
166
- this.#keyByShort.set(short, key);
167
151
  }
168
- this.#valuedByKey.set(key, definition.valued);
169
- this.#resultByKey.set(key, new Array<string>());
152
+ const optionContext = {
153
+ parsing: definition.parsing,
154
+ results: new Array<ReaderOptionValue>(),
155
+ };
156
+ for (const long of definition.longs) {
157
+ this.#optionContextByLong.set(long, optionContext);
158
+ }
159
+ for (const short of definition.shorts) {
160
+ this.#optionContextByShort.set(short, optionContext);
161
+ }
162
+ this.#optionContextByKey.set(key, optionContext);
170
163
  return key;
171
164
  }
172
165
 
173
166
  /**
174
- * Returns all raw string values collected for the given option key.
167
+ * Returns all values collected for `key`.
175
168
  *
176
- * @param key - A key previously returned by {@link ReaderArgs.registerOption}.
177
- * @returns An array of string values, one per occurrence on the command line. For
178
- * flags this will be `["true"]` per occurrence; for valued options it will be the
179
- * literal value strings.
180
- * @throws `Error` if `key` was not registered on this instance.
169
+ * @param key - Key from {@link ReaderArgs.registerOption}.
170
+ * @returns One entry per occurrence.
171
+ * @throws `Error` if `key` was not registered.
181
172
  */
182
- getOptionValues(key: ReaderOptionKey): Array<string> {
183
- const optionResult = this.#resultByKey.get(key);
184
- if (optionResult === undefined) {
173
+ getOptionValues(key: ReaderOptionKey): Array<ReaderOptionValue> {
174
+ const optionContext = this.#optionContextByKey.get(key);
175
+ if (optionContext === undefined) {
185
176
  throw new Error(`Unregistered option: ${key}`);
186
177
  }
187
- return optionResult;
178
+ return optionContext.results;
188
179
  }
189
180
 
190
181
  /**
191
- * Scans forward through the argument list and returns the next bare positional token,
192
- * consuming and parsing any intervening option tokens as side-effects.
193
- *
194
- * Option tokens encountered during the scan are recorded in the internal results map
195
- * (equivalent to recording their values against their key). Any unrecognised option token
196
- * causes a {@link TypoError} to be thrown immediately.
182
+ * Returns the next positional token; parses intervening options as a side-effect.
183
+ * All tokens after `--` are treated as positionals.
197
184
  *
198
- * After `--` is encountered, all remaining tokens are treated as positionals.
199
- *
200
- * @returns The next positional string, or `undefined` when the argument list is
201
- * exhausted.
202
- * @throws {@link TypoError} if an unrecognised option (long or short) is encountered.
185
+ * @returns The next positional, or `undefined` when exhausted.
186
+ * @throws {@link TypoError} on an unrecognised option.
203
187
  */
204
188
  consumePositional(): string | undefined {
205
189
  while (true) {
206
190
  const arg = this.#consumeArg();
207
- if (arg === null) {
191
+ if (arg === undefined) {
208
192
  return undefined;
209
193
  }
210
- if (this.#processedAsPositional(arg)) {
194
+ if (!this.#tryConsumeAsOption(arg)) {
211
195
  return arg;
212
196
  }
213
197
  }
214
198
  }
215
199
 
216
- #consumeArg(): string | null {
200
+ #consumeArg(): string | undefined {
217
201
  const arg = this.#args[this.#parsedIndex];
218
202
  if (arg === undefined) {
219
- return null;
203
+ return undefined;
220
204
  }
221
205
  this.#parsedIndex++;
222
206
  if (!this.#parsedDouble) {
@@ -228,9 +212,9 @@ export class ReaderArgs {
228
212
  return arg;
229
213
  }
230
214
 
231
- #processedAsPositional(arg: string): boolean {
215
+ #tryConsumeAsOption(arg: string): boolean {
232
216
  if (this.#parsedDouble) {
233
- return true;
217
+ return false;
234
218
  }
235
219
  if (arg.startsWith("--")) {
236
220
  const valueIndexStart = arg.indexOf("=");
@@ -242,80 +226,90 @@ export class ReaderArgs {
242
226
  arg.slice(valueIndexStart + 1),
243
227
  );
244
228
  }
245
- return false;
229
+ return true;
246
230
  }
247
231
  if (arg.startsWith("-")) {
248
232
  let shortIndexStart = 1;
249
233
  let shortIndexEnd = 2;
250
234
  while (shortIndexEnd <= arg.length) {
251
- const result = this.#tryConsumeOptionShort(
252
- arg.slice(shortIndexStart, shortIndexEnd),
253
- arg.slice(shortIndexEnd),
254
- );
255
- if (result === true) {
256
- return false;
257
- }
258
- if (result === false) {
235
+ const short = arg.slice(shortIndexStart, shortIndexEnd);
236
+ const optionContext = this.#optionContextByShort.get(short);
237
+ if (optionContext !== undefined) {
238
+ const rest = arg.slice(shortIndexEnd);
239
+ if (this.#tryConsumeOptionShort(optionContext, short, rest)) {
240
+ return true;
241
+ }
259
242
  shortIndexStart = shortIndexEnd;
260
243
  }
261
244
  shortIndexEnd++;
262
245
  }
263
246
  throw new TypoError(
264
247
  new TypoText(
265
- new TypoString(`-${arg.slice(shortIndexStart)}`, typoStyleConstants),
266
- new TypoString(`: Unexpected unknown option`),
248
+ new TypoString(`Unexpected unknown option(s): `),
249
+ new TypoString(`-${arg.slice(shortIndexStart)}`, typoStyleQuote),
267
250
  ),
268
251
  );
269
252
  }
270
- return true;
253
+ return false;
271
254
  }
272
255
 
273
- #consumeOptionLong(long: string, direct: string | null): void {
256
+ #consumeOptionLong(long: string, inlined: string | null): void {
274
257
  const constant = `--${long}`;
275
- const key = this.#keyByLong.get(long);
276
- if (key !== undefined) {
277
- if (direct !== null) {
278
- return this.#acknowledgeOption(key, direct);
279
- }
280
- const valued = this.#valuedByKey.get(key);
281
- if (valued) {
282
- return this.#acknowledgeOption(key, this.#consumeOptionValue(constant));
283
- }
284
- return this.#acknowledgeOption(key, "true");
258
+ const optionContext = this.#optionContextByLong.get(long);
259
+ if (optionContext !== undefined) {
260
+ return this.#consumeOptionValues(optionContext, constant, inlined);
285
261
  }
286
262
  throw new TypoError(
287
263
  new TypoText(
288
- new TypoString(constant, typoStyleConstants),
289
- new TypoString(`: Unexpected unknown option`),
264
+ new TypoString(`Unexpected unknown option: `),
265
+ new TypoString(constant, typoStyleQuote),
290
266
  ),
291
267
  );
292
268
  }
293
269
 
294
- #tryConsumeOptionShort(short: string, rest: string): boolean | null {
295
- const key = this.#keyByShort.get(short);
296
- if (key !== undefined) {
297
- if (rest.startsWith("=")) {
298
- this.#acknowledgeOption(key, rest.slice(1));
299
- return true;
300
- }
301
- const valued = this.#valuedByKey.get(key);
302
- if (valued) {
303
- if (rest === "") {
304
- this.#acknowledgeOption(key, this.#consumeOptionValue(`-${short}`));
305
- } else {
306
- this.#acknowledgeOption(key, rest);
307
- }
308
- return true;
309
- }
310
- this.#acknowledgeOption(key, "true");
311
- return rest === "";
270
+ #tryConsumeOptionShort(
271
+ optionContext: ReaderOptionContext,
272
+ short: string,
273
+ rest: string,
274
+ ): boolean {
275
+ const constant = `-${short}`;
276
+ if (rest.startsWith("=")) {
277
+ this.#consumeOptionValues(optionContext, constant, rest.slice(1));
278
+ return true;
279
+ }
280
+ if (rest.length === 0) {
281
+ this.#consumeOptionValues(optionContext, constant, null);
282
+ return true;
312
283
  }
313
- return null;
284
+ if (optionContext.parsing.consumeShortGroup) {
285
+ this.#consumeOptionValues(optionContext, constant, rest);
286
+ return true;
287
+ }
288
+ this.#consumeOptionValues(optionContext, constant, null);
289
+ return false;
314
290
  }
315
291
 
316
- #consumeOptionValue(constant: string) {
292
+ #consumeOptionValues(
293
+ optionContext: ReaderOptionContext,
294
+ constant: string,
295
+ inlined: string | null,
296
+ ) {
297
+ const separated = new Array<string>();
298
+ while (
299
+ optionContext.parsing.consumeNextArg(
300
+ inlined,
301
+ separated,
302
+ this.#args[this.#parsedIndex],
303
+ )
304
+ ) {
305
+ separated.push(this.#consumeOptionValue(constant));
306
+ }
307
+ optionContext.results.push({ inlined, separated });
308
+ }
309
+
310
+ #consumeOptionValue(constant: string): string {
317
311
  const arg = this.#consumeArg();
318
- if (arg === null) {
312
+ if (arg === undefined) {
319
313
  throw new TypoError(
320
314
  new TypoText(
321
315
  new TypoString(constant, typoStyleConstants),
@@ -345,7 +339,12 @@ export class ReaderArgs {
345
339
  return arg;
346
340
  }
347
341
 
348
- #acknowledgeOption(key: ReaderOptionKey, value: string) {
349
- this.getOptionValues(key).push(value);
342
+ #isValidOptionName(name: string): boolean {
343
+ return name.length > 0 && !name.includes("=");
350
344
  }
351
345
  }
346
+
347
+ type ReaderOptionContext = {
348
+ parsing: ReaderOptionParsing;
349
+ results: Array<ReaderOptionValue>;
350
+ };