cli-kiss 0.2.5 → 0.2.7

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/Type.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { statSync } from "fs";
2
+ import { similaritySort } from "./Similarity";
2
3
  import {
3
4
  TypoError,
4
5
  TypoString,
@@ -50,24 +51,19 @@ export function typeBoolean(name?: string): Type<boolean> {
50
51
  return {
51
52
  content: name ?? "boolean",
52
53
  decoder(input: string) {
53
- const lower = input.toLowerCase();
54
- if (booleanValuesTrue.has(lower)) {
54
+ const lowerInput = input.toLowerCase();
55
+ if (typeBooleanValuesTrue.has(lowerInput)) {
55
56
  return true;
56
57
  }
57
- if (booleanValuesFalse.has(lower)) {
58
+ if (typeBooleanValuesFalse.has(lowerInput)) {
58
59
  return false;
59
60
  }
60
- throw new TypoError(
61
- new TypoText(
62
- new TypoString(`Not a boolean: `),
63
- new TypoString(`"${input}"`, typoStyleQuote),
64
- ),
65
- );
61
+ throwInvalidValue("a boolean", input);
66
62
  },
67
63
  };
68
64
  }
69
- const booleanValuesTrue = new Set(["true", "yes", "on", "1", "y", "t"]);
70
- const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
65
+ export const typeBooleanValuesTrue = new Set(["true", "yes", "on", "y"]);
66
+ export const typeBooleanValuesFalse = new Set(["false", "no", "off", "n"]);
71
67
 
72
68
  /**
73
69
  * Parses a date/time string via `Date.parse`.
@@ -91,12 +87,7 @@ export function typeDatetime(name?: string): Type<Date> {
91
87
  }
92
88
  return new Date(timestampMs);
93
89
  } catch {
94
- throw new TypoError(
95
- new TypoText(
96
- new TypoString(`Not a valid ISO_8601 datetime: `),
97
- new TypoString(`"${input}"`, typoStyleQuote),
98
- ),
99
- );
90
+ throwInvalidValue("a valid ISO_8601 datetime", input);
100
91
  }
101
92
  },
102
93
  };
@@ -109,7 +100,7 @@ export function typeDatetime(name?: string): Type<Date> {
109
100
  * ```ts
110
101
  * typeNumber("my-number").decoder("3.14") // → 3.14
111
102
  * typeNumber("my-number").decoder("-1") // → -1
112
- * typeNumber("my-number").decoder("hello") // throws TypoError
103
+ * typeNumber("my-number").decoder("hello") // throws
113
104
  * ```
114
105
  */
115
106
  export function typeNumber(name?: string): Type<number> {
@@ -123,12 +114,7 @@ export function typeNumber(name?: string): Type<number> {
123
114
  }
124
115
  return parsed;
125
116
  } catch {
126
- throw new TypoError(
127
- new TypoText(
128
- new TypoString(`Not a number: `),
129
- new TypoString(`"${input}"`, typoStyleQuote),
130
- ),
131
- );
117
+ throwInvalidValue("a number", input);
132
118
  }
133
119
  },
134
120
  };
@@ -141,8 +127,8 @@ export function typeNumber(name?: string): Type<number> {
141
127
  * @example
142
128
  * ```ts
143
129
  * typeInteger("my-integer").decoder("42") // → 42n
144
- * typeInteger("my-integer").decoder("3.14") // throws TypoError
145
- * typeInteger("my-integer").decoder("abc") // throws TypoError
130
+ * typeInteger("my-integer").decoder("3.14") // throws
131
+ * typeInteger("my-integer").decoder("abc") // throws
146
132
  * ```
147
133
  */
148
134
  export function typeInteger(name?: string): Type<bigint> {
@@ -152,12 +138,7 @@ export function typeInteger(name?: string): Type<bigint> {
152
138
  try {
153
139
  return BigInt(input);
154
140
  } catch {
155
- throw new TypoError(
156
- new TypoText(
157
- new TypoString(`Not an integer: `),
158
- new TypoString(`"${input}"`, typoStyleQuote),
159
- ),
160
- );
141
+ throwInvalidValue("an integer", input);
161
142
  }
162
143
  },
163
144
  };
@@ -170,7 +151,7 @@ export function typeInteger(name?: string): Type<bigint> {
170
151
  * @example
171
152
  * ```ts
172
153
  * typeUrl("my-url").decoder("https://example.com") // → URL { href: "https://example.com/", ... }
173
- * typeUrl("my-url").decoder("not-a-url") // throws TypoError
154
+ * typeUrl("my-url").decoder("not-a-url") // throws
174
155
  * ```
175
156
  */
176
157
  export function typeUrl(name?: string): Type<URL> {
@@ -180,12 +161,7 @@ export function typeUrl(name?: string): Type<URL> {
180
161
  try {
181
162
  return new URL(input);
182
163
  } catch {
183
- throw new TypoError(
184
- new TypoText(
185
- new TypoString(`Not an URL: `),
186
- new TypoString(`"${input}"`, typoStyleQuote),
187
- ),
188
- );
164
+ throwInvalidValue("an URL", input);
189
165
  }
190
166
  },
191
167
  };
@@ -296,7 +272,20 @@ export function typePath(
296
272
  throw new Error(`Path cannot contain null characters`);
297
273
  }
298
274
  if (checks?.checkSyncExistAs !== undefined) {
299
- const stats = statSync(input);
275
+ function safeStatSync(path: string) {
276
+ try {
277
+ return statSync(path);
278
+ } catch (error) {
279
+ throw new TypoError(
280
+ new TypoText(
281
+ new TypoString(`Path does not exist: `),
282
+ new TypoString(`"${path}"`, typoStyleQuote),
283
+ ),
284
+ error,
285
+ );
286
+ }
287
+ }
288
+ const stats = safeStatSync(input);
300
289
  const preview = stats.isDirectory()
301
290
  ? "directory"
302
291
  : stats.isFile()
@@ -305,7 +294,7 @@ export function typePath(
305
294
  if (checks.checkSyncExistAs === "file" && !stats.isFile()) {
306
295
  throw new TypoError(
307
296
  new TypoText(
308
- new TypoString(`Expected a 'file' but found '${preview}': `),
297
+ new TypoString(`Expected a file but found: ${preview}: `),
309
298
  new TypoString(`"${input}"`, typoStyleQuote),
310
299
  ),
311
300
  );
@@ -313,7 +302,7 @@ export function typePath(
313
302
  if (checks.checkSyncExistAs === "directory" && !stats.isDirectory()) {
314
303
  throw new TypoError(
315
304
  new TypoText(
316
- new TypoString(`Expected a 'directory' but found '${preview}': `),
305
+ new TypoString(`Expected a directory but found: ${preview}: `),
317
306
  new TypoString(`"${input}"`, typoStyleQuote),
318
307
  ),
319
308
  );
@@ -344,38 +333,37 @@ export function typeChoice<const Value extends string>(
344
333
  values: Array<Value>,
345
334
  caseSensitive: boolean = false,
346
335
  ): Type<Value> {
336
+ if (values.length === 0) {
337
+ throw new Error("At least one value is required");
338
+ }
347
339
  const normalize = caseSensitive
348
340
  ? (s: string) => s
349
341
  : (s: string) => s.toLowerCase();
350
- const valueMap = new Map(values.map((value) => [normalize(value), value]));
342
+ const valueByNormalizedKey = new Map(
343
+ values.map((value) => [normalize(value), value]),
344
+ );
351
345
  return {
352
346
  content: name,
353
347
  decoder(input: string) {
354
- const normalized = normalize(input);
355
- const original = valueMap.get(normalized);
356
- if (original !== undefined) {
357
- return original;
348
+ const normalizedKey = normalize(input);
349
+ const value = valueByNormalizedKey.get(normalizedKey);
350
+ if (value !== undefined) {
351
+ return value;
358
352
  }
359
- const valuesPreview = [];
360
- for (const value of values) {
361
- if (valuesPreview.length >= 5) {
362
- valuesPreview.push(new TypoString(`...`));
363
- break;
364
- }
365
- if (valuesPreview.length > 0) {
366
- valuesPreview.push(new TypoString(` | `));
367
- }
368
- valuesPreview.push(new TypoString(`"${value}"`, typoStyleQuote));
369
- }
370
- throw new TypoError(
371
- new TypoText(
372
- new TypoString(`Invalid value: `),
373
- new TypoString(`"${input}"`, typoStyleQuote),
374
- new TypoString(` (expected one of: `),
375
- ...valuesPreview,
376
- new TypoString(`)`),
377
- ),
378
- );
353
+ const text = new TypoText();
354
+ text.push(new TypoString(`Unknown value: `));
355
+ text.push(new TypoString(`"${input}"`, typoStyleQuote));
356
+ const suggestions = similaritySort(
357
+ normalizedKey,
358
+ [...valueByNormalizedKey.entries()].map(([normalizedKey, value]) => ({
359
+ key: normalizedKey,
360
+ value: new TypoString(`"${value}"`, typoStyleQuote),
361
+ })),
362
+ ).slice(0, 3);
363
+ text.push(new TypoString(`: did you mean: `));
364
+ text.push(TypoText.join(suggestions, new TypoString(`, `)));
365
+ text.push(new TypoString(` ?`));
366
+ throw new TypoError(text);
379
367
  },
380
368
  };
381
369
  }
@@ -474,3 +462,12 @@ export function typeList<Value>(
474
462
  },
475
463
  };
476
464
  }
465
+
466
+ function throwInvalidValue(kind: string, input: string): never {
467
+ throw new TypoError(
468
+ new TypoText(
469
+ new TypoString(`Not ${kind}: `),
470
+ new TypoString(`"${input}"`, typoStyleQuote),
471
+ ),
472
+ );
473
+ }
package/src/lib/Typo.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { typeBooleanValuesFalse } from "./Type";
2
+
1
3
  /**
2
4
  * Color names for terminal styling, used by {@link TypoStyle}.
3
5
  * `dark*` = standard ANSI (30–37); `bright*` = high-intensity (90–97).
@@ -123,12 +125,12 @@ export const typoStyleRegularWeaker: TypoStyle = {
123
125
  */
124
126
  export class TypoString {
125
127
  #value: string;
126
- #typoStyle: TypoStyle;
128
+ #typoStyle: TypoStyle | undefined;
127
129
  /**
128
130
  * @param value - Raw text content.
129
- * @param typoStyle - Style to apply when rendering. Defaults to `{}` (no style).
131
+ * @param typoStyle - Style to apply when rendering. Defaults to `undefined` (no style).
130
132
  */
131
- constructor(value: string, typoStyle: TypoStyle = {}) {
133
+ constructor(value: string, typoStyle?: TypoStyle) {
132
134
  this.#value = value;
133
135
  this.#typoStyle = typoStyle;
134
136
  }
@@ -148,6 +150,11 @@ export class TypoString {
148
150
  }
149
151
  }
150
152
 
153
+ /**
154
+ * A segment of styled text, a string, or an array of segments.
155
+ */
156
+ export type TypoSegment = TypoText | TypoString | string | Array<TypoSegment>;
157
+
151
158
  /**
152
159
  * Mutable sequence of {@link TypoString} segments.
153
160
  */
@@ -156,12 +163,10 @@ export class TypoText {
156
163
  /**
157
164
  * @param segments - Initial text segments
158
165
  */
159
- constructor(
160
- ...segments: Array<TypoText | Array<TypoString> | TypoString | string>
161
- ) {
166
+ constructor(...segments: TypoSegment[]) {
162
167
  this.#typoStrings = [];
163
- for (const typoPart of segments) {
164
- this.push(typoPart);
168
+ for (const segment of segments) {
169
+ this.push(segment);
165
170
  }
166
171
  }
167
172
  /**
@@ -169,14 +174,14 @@ export class TypoText {
169
174
  *
170
175
  * @param segment - Text segment(s) to append.
171
176
  */
172
- push(segment: TypoText | Array<TypoString> | TypoString | string) {
177
+ push(segment: TypoSegment) {
173
178
  if (typeof segment === "string") {
174
179
  this.#typoStrings.push(new TypoString(segment));
175
180
  } else if (segment instanceof TypoText) {
176
181
  this.#typoStrings.push(...segment.#typoStrings);
177
182
  } else if (Array.isArray(segment)) {
178
183
  for (const typoString of segment) {
179
- this.#typoStrings.push(typoString);
184
+ this.push(typoString);
180
185
  }
181
186
  } else {
182
187
  this.#typoStrings.push(segment);
@@ -209,6 +214,20 @@ export class TypoText {
209
214
  }
210
215
  return length;
211
216
  }
217
+ /**
218
+ * Joins multiple segments with a separator.
219
+ * @returns A new {@link TypoText} containing the joined segments.
220
+ */
221
+ static join(segments: Array<TypoSegment>, separator: TypoSegment): TypoText {
222
+ const result = new TypoText();
223
+ for (let index = 0; index < segments.length; index++) {
224
+ if (index > 0) {
225
+ result.push(separator);
226
+ }
227
+ result.push(segments[index]!);
228
+ }
229
+ return result;
230
+ }
212
231
  }
213
232
 
214
233
  /**
@@ -332,8 +351,8 @@ export class TypoError extends Error {
332
351
  * Controls ANSI terminal styling. Create via the static factory methods.
333
352
  */
334
353
  export class TypoSupport {
335
- #kind: "none" | "tty" | "mock";
336
- private constructor(kind: "none" | "tty" | "mock") {
354
+ #kind: TypoSupportKind;
355
+ private constructor(kind: TypoSupportKind) {
337
356
  this.#kind = kind;
338
357
  }
339
358
  /**
@@ -358,27 +377,38 @@ export class TypoSupport {
358
377
  * Auto-detects styling mode from the process environment on best-effort basis.
359
378
  */
360
379
  static inferFromEnv(): TypoSupport {
361
- if (!process || !process.env) {
380
+ /*
381
+ console.warn({
382
+ no: readEnvVar("NO_COLOR"),
383
+ force: readEnvVar("FORCE_COLOR"),
384
+ mock: readEnvVar("MOCK_COLOR"),
385
+ term: readEnvVar("TERM"),
386
+ tty: process.stdout.isTTY,
387
+ });
388
+ */
389
+ if (!process || !process.env || !process.stdout) {
362
390
  return TypoSupport.none();
363
391
  }
364
- function readEnvVar(name: string) {
365
- if (!(name in process.env)) {
366
- return undefined;
367
- }
368
- return process.env[name];
392
+ if (readEnvVar("NO_COLOR")) {
393
+ return TypoSupport.none();
369
394
  }
370
395
  const envForceColor = readEnvVar("FORCE_COLOR");
371
396
  if (envForceColor === "0") {
372
397
  return TypoSupport.none();
373
398
  }
374
399
  if (envForceColor !== undefined) {
375
- TypoSupport.tty();
400
+ if (!typeBooleanValuesFalse.has(envForceColor.toLowerCase())) {
401
+ return TypoSupport.tty();
402
+ }
403
+ }
404
+ if (readEnvVar("MOCK_COLOR")) {
405
+ return TypoSupport.mock();
376
406
  }
377
- if (readEnvVar("NO_COLOR") !== undefined) {
407
+ if (!process.stdout.isTTY) {
378
408
  return TypoSupport.none();
379
409
  }
380
- if (readEnvVar("MOCK_COLOR") !== undefined) {
381
- return TypoSupport.mock();
410
+ if (readEnvVar("TERM")?.toLowerCase() === "dumb") {
411
+ return TypoSupport.none();
382
412
  }
383
413
  return TypoSupport.tty();
384
414
  }
@@ -389,7 +419,10 @@ export class TypoSupport {
389
419
  * @param typoStyle - Style to apply.
390
420
  * @returns Styled string.
391
421
  */
392
- computeStyledString(value: string, typoStyle: TypoStyle): string {
422
+ computeStyledString(value: string, typoStyle: TypoStyle | undefined): string {
423
+ if (typoStyle === undefined) {
424
+ return value;
425
+ }
393
426
  let styledValue = value;
394
427
  if (typoStyle.case === "upper") {
395
428
  styledValue = styledValue.toUpperCase();
@@ -496,3 +529,12 @@ const ttyCodeBgColors: Record<TypoColor, string> = {
496
529
  brightCyan: "\x1b[106m",
497
530
  brightWhite: "\x1b[107m",
498
531
  };
532
+
533
+ function readEnvVar(name: string) {
534
+ if (!(name in process.env)) {
535
+ return undefined;
536
+ }
537
+ return process.env[name];
538
+ }
539
+
540
+ type TypoSupportKind = "none" | "tty" | "mock";
package/src/lib/Usage.ts CHANGED
@@ -116,16 +116,16 @@ export type UsageOption = {
116
116
  * <detail lines...>
117
117
  *
118
118
  * Positionals:
119
- * <LABEL> <description> (<hint>)
119
+ * <label> <description> (<hint>)
120
120
  *
121
121
  * Subcommands:
122
122
  * <name> <description> (<hint>)
123
123
  *
124
124
  * Options:
125
- * -s, --long <LABEL><annotation> <description> (<hint>)
125
+ * -s, --long <label><annotation> <description> (<hint>)
126
126
  *
127
127
  * Examples:
128
- * <description>
128
+ * <explanation>
129
129
  * <command line>
130
130
  *
131
131
  * ```
@@ -262,7 +262,7 @@ export function usageToStyledLines(params: {
262
262
  for (const commandArg of example.commandArgs) {
263
263
  commandLineText.push(textDelimiter(" "));
264
264
  if (typeof commandArg === "string") {
265
- commandLineText.push(commandArg);
265
+ commandLineText.push(new TypoString(commandArg));
266
266
  } else if ("positional" in commandArg) {
267
267
  commandLineText.push(textUserInput(commandArg.positional));
268
268
  } else if ("subcommand" in commandArg) {
@@ -47,7 +47,7 @@ const rootCommand = commandChained(
47
47
  string: optionSingleValue({
48
48
  long: "string-option",
49
49
  type: type(),
50
- defaultWhenNotDefined: () => undefined,
50
+ defaultIfNotSpecified: () => undefined,
51
51
  }),
52
52
  number: optionRepeatable({
53
53
  long: "number-option",
@@ -39,8 +39,8 @@ const rootCommand = commandChained<any, any, any>(
39
39
  long: "choice-option",
40
40
  type: typeChoice("choice", ["unset", "empty", "choice1", "choice2"]),
41
41
  description: "choice-option description",
42
- defaultWhenNotInlined: () => "empty",
43
- defaultWhenNotDefined: () => "unset",
42
+ valueIfNothingInlined: () => "empty",
43
+ defaultIfNotSpecified: () => "unset",
44
44
  }),
45
45
  booleanFlag: optionFlag({
46
46
  short: "b",
@@ -81,7 +81,7 @@ const rootCommand = commandChained<any, any, any>(
81
81
  short: "s",
82
82
  long: "string-option",
83
83
  type: type("cool-stuff"),
84
- defaultWhenNotDefined: () => undefined,
84
+ defaultIfNotSpecified: () => undefined,
85
85
  description: "string-option description",
86
86
  }),
87
87
  complexOption: optionRepeatable({
@@ -174,7 +174,7 @@ const rootCommand = commandChained<any, any, any>(
174
174
  duduValue: optionSingleValue({
175
175
  long: "dudu",
176
176
  type: type("dudu-value"),
177
- defaultWhenNotDefined: () => "duduDefault",
177
+ defaultIfNotSpecified: () => "duduDefault",
178
178
  hint: "Dudu option hint",
179
179
  description: "Dudu option description",
180
180
  }),
@@ -0,0 +1,34 @@
1
+ import { it } from "@jest/globals";
2
+ import { similaritySort } from "../src/lib/Similarity";
3
+
4
+ it("run", async function () {
5
+ expect(
6
+ orderBySimilarity("--inst", ["--flag", "--blah", "--install"]),
7
+ ).toStrictEqual(["--install", "--flag", "--blah"]);
8
+
9
+ expect(
10
+ orderBySimilarity("instlal", ["install", "dudu", "--blah"]),
11
+ ).toStrictEqual(["install", "--blah", "dudu"]);
12
+
13
+ expect(
14
+ orderBySimilarity("cat", ["cats", "catz", "cut", "kat", "hello", "world"]),
15
+ ).toStrictEqual(["cats", "catz", "cut", "kat", "hello", "world"]);
16
+
17
+ expect(orderBySimilarity("cat", ["cut", "kat"])).toStrictEqual([
18
+ "cut",
19
+ "kat",
20
+ ]);
21
+
22
+ expect(orderBySimilarity("acb", ["abc", "ac", "ab"])).toStrictEqual([
23
+ "abc",
24
+ "ac",
25
+ "ab",
26
+ ]);
27
+ });
28
+
29
+ function orderBySimilarity(reference: string, candidates: Array<string>) {
30
+ return similaritySort(
31
+ reference,
32
+ candidates.map((key) => ({ key, value: key })),
33
+ );
34
+ }