feat(cli): interactive yes/no and suggestion selection for cli commands
MODIFY
internal/cli-flags/Parser.test.ts
internal/cli-flags/Parser.test.ts
@@ -22,15 +22,18 @@ async function testParser<T>(
preInit,
postInit,
options: opts,
+ defaultReporter,
}: {
defineFlags: (consumer: Consumer) => T;
args: string[];
preInit?: (parser: Parser<T>) => void;
postInit?: (parser: Parser<T>, flags: T) => void;
options?: Partial<ParserOptions<T>>;
+ defaultReporter?: Reporter;
},
) {
- const reporter = new Reporter("ParserTest");
+ const reporter =
+ defaultReporter ?? new Reporter("ParserTest", {stdin: process.stdin});
const stream = reporter.attachCaptureStream();
const parser = new Parser({
@@ -371,11 +374,16 @@ test(
);
test(
- "command required with wrong command",
+ "command required with wrong command but only one generated suggestion",
async (t) => {
+ const reporter = new Reporter("ParserTest", {stdin: process.stdin});
+ reporter.radioConfirm = function() {
+ return Promise.resolve(true);
+ };
await testParser(
t,
{
+ defaultReporter: reporter,
options: {
commandRequired: true,
},
@@ -395,7 +403,42 @@ test(
);
test(
- "command required with wrong command and suggestion",
+ "command required with wrong command but multiple generated suggestions",
+ async (t) => {
+ const reporter = new Reporter("ParserTest", {stdin: process.stdin});
+ reporter.select = function() {
+ const res = new Set(["foo1"]);
+ // rome-ignore lint/ts/noExplicitAny: TS generics constraint :(
+ return Promise.resolve(res as any);
+ };
+ await testParser(
+ t,
+ {
+ defaultReporter: reporter,
+ options: {
+ commandRequired: true,
+ },
+ defineFlags: () => {
+ return {};
+ },
+ args: ["foo"],
+ preInit(p) {
+ p.addCommand({
+ name: "foo1",
+ callback() {},
+ });
+ p.addCommand({
+ name: "foo2",
+ callback() {},
+ });
+ },
+ },
+ );
+ },
+);
+
+test(
+ "command required with wrong command but provided suggestion",
async (t) => {
await testParser(
t,
@@ -403,8 +446,8 @@ test(
options: {
commandRequired: true,
commandSuggestions: {
- foo: {
- commandName: "foobar",
+ losg: {
+ commandName: "logs",
description: markup`A much cooler command`,
},
},
@@ -412,7 +455,7 @@ test(
defineFlags: () => {
return {};
},
- args: ["foo"],
+ args: ["losg"],
},
);
},

MODIFY
internal/cli-flags/Parser.ts
internal/cli-flags/Parser.ts
* LICENSE file in the root directory of this source tree.
*/
-import {Reporter} from "@internal/cli-reporter";
+import {Reporter, SelectOptions} from "@internal/cli-reporter";
import {SerializeCLIOptions, serializeCLIFlags} from "./serializeCLIFlags";
import {
ConsumePath,
@@ -16,7 +16,6 @@ import {
} from "@internal/consume";
import {
dedent,
- findClosestStringMatch,
naturalCompare,
toCamelCase,
toKebabCase,
@@ -43,6 +42,7 @@ import {RSERObject} from "@internal/binary-transport";
import {ExtendedMap} from "@internal/collections";
import {markupToPlainText} from "@internal/cli-layout";
import {safeProcessExit} from "@internal/resources";
+import {findClosestMatchingStrings} from "@internal/string-utils/findClosestMatchingStrings";
export type Examples = {
description: StaticMarkup;
@@ -118,6 +118,7 @@ type SupportedCompletionShells = "bash" | "fish" | "zsh";
export default class Parser<T> {
constructor(opts: ParserOptions<T>) {
+ this.rawArgs = opts.args;
this.reporter = opts.reporter;
this.opts = opts;
@@ -132,13 +133,14 @@ export default class Parser<T> {
this.flagToArgIndex = new ExtendedMap("flagToArgIndex");
this.flagToArgOffset = 0;
- this.consumeRawArgs(opts.args);
+ this.consumeRawArgs(this.rawArgs);
this.commands = new Map();
this.ranCommand = undefined;
this.currentCommand = undefined;
}
+ private rawArgs: string[];
private reporter: Reporter;
private opts: ParserOptions<T>;
private incorrectCaseFlags: Set<string>;
@@ -570,7 +572,7 @@ export default class Parser<T> {
this.currentCommand = undefined;
if (this.opts.commandRequired && !shouldShowHelp) {
- this.commandRequired(definedCommand !== undefined, flagsConsumer);
+ await this.commandRequired(definedCommand !== undefined, flagsConsumer);
}
return rootFlags;
@@ -1102,7 +1104,46 @@ export default class Parser<T> {
);
}
- private commandRequired(
+ private async reinit(args: string[]) {
+ this.shorthandFlags = new Set();
+ this.incorrectCaseFlags = new Set();
+ this.declaredFlags = new Map();
+ this.defaultFlags = new Map();
+ this.flags = new Map();
+ this.args = args;
+ this.flagToArgIndex = new ExtendedMap("flagToArgIndex");
+ this.flagToArgOffset = 0;
+ this.ranCommand = undefined;
+ this.currentCommand = undefined;
+
+ this.consumeRawArgs(this.rawArgs);
+ await this.init();
+ }
+
+ private showDiagnostics(
+ opts: Parameters<typeof descriptions.FLAGS.UNKNOWN_COMMAND>[0],
+ commandName: string,
+ displayArgs: string[],
+ defaultFlags: Dict<FlagValue>,
+ rawFlags: Dict<FlagValue>,
+ ) {
+ const diag: Diagnostic = {
+ description: descriptions.FLAGS.UNKNOWN_COMMAND(opts),
+ location: serializeCLIFlags(
+ {
+ ...this.getSerializeOptions(),
+ commandName,
+ args: displayArgs,
+ defaultFlags,
+ flags: rawFlags,
+ },
+ "command",
+ ),
+ };
+ throw new DiagnosticsError("Unknown command", [diag]);
+ }
+
+ private async commandRequired(
foundCommand: boolean,
{defaultFlags, rawFlags}: FlagsConsumer,
) {
@@ -1141,41 +1182,62 @@ export default class Parser<T> {
// If we don't have a suggestion then try to find another closest one
if (opts.suggestedName === undefined) {
- opts.suggestedName = findClosestStringMatch(
+ const suggestions = findClosestMatchingStrings(
commandName,
Array.from(this.commands.keys()),
+ 0.3,
);
+
+ if (suggestions.length > 1) {
+ let options: SelectOptions = {};
+
+ for (const suggestion of suggestions) {
+ options[suggestion] = {label: markup`${suggestion}`};
}
- // Set suggestedCommand
- if (opts.suggestedName !== undefined) {
- opts.suggestedCommand = serializeCLIFlags(
+ const selected = await this.reporter.select(
+ markup`Unknown command <emphasis>${commandName}</emphasis>. Found matching commands`,
{
- ...this.getSerializeOptions(),
- commandName: opts.suggestedName,
- args: displayArgs,
- defaultFlags,
- flags: rawFlags,
+ radio: true,
+ options,
},
- "none",
- ).sourceText;
- }
+ );
- const diag: Diagnostic = {
- description: descriptions.FLAGS.UNKNOWN_COMMAND(opts),
- location: serializeCLIFlags(
- {
- ...this.getSerializeOptions(),
+ const args = Array.from(selected)[0].split(" ");
+ await this.reinit(args);
+ } else if (suggestions.length === 1) {
+ const yes = await this.reporter.radioConfirm(
+ markup`Did you mean ${suggestions[0]}`,
+ );
+ if (yes) {
+ await this.reinit(suggestions);
+ } else {
+ this.showDiagnostics(
+ opts,
commandName,
- args: displayArgs,
+ displayArgs,
defaultFlags,
- flags: rawFlags,
- },
- "command",
- ),
- };
-
- throw new DiagnosticsError("Unknown command", [diag]);
+ rawFlags,
+ );
+ }
+ } else {
+ this.showDiagnostics(
+ opts,
+ commandName,
+ displayArgs,
+ defaultFlags,
+ rawFlags,
+ );
+ }
+ } else {
+ this.showDiagnostics(
+ opts,
+ commandName,
+ displayArgs,
+ defaultFlags,
+ rawFlags,
+ );
+ }
}
private getSerializeOptions(): Pick<

MODIFY
internal/cli-reporter/Reporter.ts
internal/cli-reporter/Reporter.ts
@@ -154,6 +154,7 @@ export default class Reporter implements ReporterNamespace {
cwd: CWD_PATH,
home: HOME_PATH,
},
+ stdin: process.stdin,
},
);

ADD
internal/string-utils/findClosesMatchingStrings.test.ts
+import {test} from "rome";
+import {findClosestMatchingStrings} from "./findClosestMatchingStrings";
+
+test(
+ "findClosestMatchingStrings",
+ (t) => {
+ t.looksLike(
+ findClosestMatchingStrings("french", ["quebec", "123", "france", "frenc"]),
+ ["frenc"],
+ );
+ t.looksLike(
+ findClosestMatchingStrings("iphone", ["ipod", "iphone 5s", "iphones x"]),
+ ["iphone 5s", "iphones x"],
+ );
+
+ t.looksLike(
+ findClosestMatchingStrings(
+ "iphone",
+ ["ipod", "iphone 5s", "iphones x"],
+ 0.9,
+ ),
+ [],
+ );
+
+ t.looksLike(
+ findClosestMatchingStrings(
+ "french",
+ ["quebec", "123", "france", "frenc"],
+ 0.9,
+ ),
+ [],
+ );
+ t.looksLike(
+ findClosestMatchingStrings(
+ "iphone",
+ ["ipod", "iphone 5s", "iphones x"],
+ 0.9,
+ ),
+ [],
+ );
+ },
+);

ADD
internal/string-utils/findClosestMatchingStrings.ts
+import {orderBySimilarity} from "./orderBySimilarity";
+
+export function findClosestMatchingStrings(
+ name: string,
+ matches: string[],
+ minRating: number = 0.8,
+): string[] {
+ if (matches.length === 0) {
+ return [];
+ }
+
+ if (matches.length === 1) {
+ return matches;
+ }
+
+ return orderBySimilarity(name, matches).filter((match) =>
+ match.rating >= minRating
+ ).map((match) => match.target);
+}

MODIFY
internal/string-utils/findClosestStringMatch.ts
internal/string-utils/findClosestStringMatch.ts
* LICENSE file in the root directory of this source tree.
*/
-import {orderBySimilarity} from "./orderBySimilarity";
+import {findClosestMatchingStrings} from "./findClosestMatchingStrings";
export function findClosestStringMatch(
name: string,
matches: string[],
minRating: number = 0.8,
): undefined | string {
- if (matches.length === 0) {
+ const ratings = findClosestMatchingStrings(name, matches, minRating);
+ if (ratings.length === 0) {
return undefined;
}
- if (matches.length === 1) {
- return matches[0];
+ if (ratings.length === 1) {
+ return ratings[0];
}
- const ratings = orderBySimilarity(name, matches);
- const bestMatch = ratings[0];
-
- if (bestMatch.rating >= minRating) {
- return bestMatch.target;
- } else {
- return undefined;
- }
+ return ratings[0];
}

feat(cli): interactive yes/no and suggestion selection for cli commands (#1508)