Skip to content

Commit 57d2eee

Browse files
committed
feat(plugin-help): print available subcommands
1 parent 36c3162 commit 57d2eee

File tree

4 files changed

+177
-70
lines changed

4 files changed

+177
-70
lines changed

packages/plugin-help/src/index.ts

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ export const helpPlugin = ({
142142
console.log(s);
143143
}
144144

145+
const renderer = new HelpRenderer(
146+
mergedFormatters,
147+
cli,
148+
cli._globalFlags,
149+
effectiveNotes,
150+
effectiveExamples,
151+
groups,
152+
);
153+
154+
function tryPrintSubcommandsHelp(commandName: string) {
155+
const subcommandsOutput =
156+
renderer.renderAvailableSubcommands(commandName);
157+
158+
if (subcommandsOutput) {
159+
printHelp(subcommandsOutput);
160+
161+
return true;
162+
}
163+
164+
return false;
165+
}
166+
145167
if (command) {
146168
cli
147169
.command("help", "Show help", {
@@ -158,19 +180,18 @@ export const helpPlugin = ({
158180
[command] = resolveCommand(cli._commands, commandName);
159181

160182
if (!command) {
161-
throw new NoSuchCommandError(commandName.join(" "));
183+
const parentCommandName = commandName.join(" ");
184+
185+
if (tryPrintSubcommandsHelp(parentCommandName)) {
186+
return;
187+
}
188+
189+
// No subcommands, throw error
190+
throw new NoSuchCommandError(parentCommandName);
162191
}
163192
}
164193

165-
const renderer = new HelpRenderer(
166-
mergedFormatters,
167-
cli,
168-
cli._globalFlags,
169-
command,
170-
command ? command.help?.notes : effectiveNotes,
171-
command ? command.help?.examples : effectiveExamples,
172-
groups,
173-
);
194+
renderer.setCommand(command);
174195
printHelp(renderer.render());
175196
});
176197
}
@@ -190,18 +211,16 @@ export const helpPlugin = ({
190211
const command = ctx.command;
191212
// If no command resolved, but parameters are present, just let the next interceptor handle it
192213
if (!command && ctx.rawParsed.parameters.length > 0) {
214+
const parentCommandName = ctx.rawParsed.parameters.join(" ");
215+
216+
if (tryPrintSubcommandsHelp(parentCommandName)) {
217+
return;
218+
}
219+
193220
await next();
194221
}
195222

196-
const renderer = new HelpRenderer(
197-
mergedFormatters,
198-
cli,
199-
cli._globalFlags,
200-
command,
201-
command ? command.help?.notes : effectiveNotes,
202-
command ? command.help?.examples : effectiveExamples,
203-
groups,
204-
);
223+
renderer.setCommand(command);
205224
printHelp(renderer.render());
206225
} else {
207226
const shouldShowHelp =
@@ -210,15 +229,6 @@ export const helpPlugin = ({
210229
ctx.rawParsed.parameters.length === 0; // and no command supplied, means no root command defined
211230

212231
if (shouldShowHelp) {
213-
const renderer = new HelpRenderer(
214-
mergedFormatters,
215-
cli,
216-
cli._globalFlags,
217-
undefined,
218-
effectiveNotes,
219-
effectiveExamples,
220-
groups,
221-
);
222232
printHelp(renderer.render());
223233
} else {
224234
await next();

packages/plugin-help/src/renderer.ts

Lines changed: 86 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ export class HelpRenderer {
7070
private _commandGroups: Map<string, string>;
7171
private _flagGroups: Map<string, string>;
7272
private _globalFlagGroups: Map<string, string>;
73+
private _command: Command | undefined;
7374

7475
constructor(
7576
private _formatters: Formatters,
7677
private _cli: Clerc,
7778
private _globalFlags: ClercFlagsDefinition,
78-
private _command: Command | undefined,
7979
private _notes?: string[],
8080
private _examples?: [string, string][],
8181
groups?: GroupsOptions,
@@ -85,21 +85,17 @@ export class HelpRenderer {
8585
this._globalFlagGroups = groupDefinitionsToMap(groups?.globalFlags);
8686
}
8787

88-
public render(): string {
89-
const sections: Section[] = [
90-
this.renderHeader(),
91-
this.renderUsage(),
92-
this.renderParameters(),
93-
this.renderCommandFlags(),
94-
this.renderGlobalFlags(),
95-
this.renderCommands(),
96-
this.renderExamples(),
97-
this.renderNotes(),
98-
];
88+
public setCommand(command: Command | undefined): void {
89+
if (command) {
90+
this._command = command;
91+
this._notes = command?.help?.notes;
92+
this._examples = command?.help?.examples;
93+
}
94+
}
9995

96+
private renderSections(sections: Section[]): string {
10097
return sections
10198
.filter(isTruthy)
102-
.filter((section) => section.body.length > 0)
10399
.map((section) => {
104100
const body = Array.isArray(section.body)
105101
? section.body.filter((s) => s !== undefined).join("\n")
@@ -117,6 +113,21 @@ export class HelpRenderer {
117113
.join("\n\n");
118114
}
119115

116+
public render(): string {
117+
const sections: Section[] = [
118+
this.renderHeader(),
119+
this.renderUsage(),
120+
this.renderParameters(),
121+
this.renderCommandFlags(),
122+
this.renderGlobalFlags(),
123+
this.renderCommands(),
124+
this.renderExamples(),
125+
this.renderNotes(),
126+
];
127+
128+
return this.renderSections(sections);
129+
}
130+
120131
private renderHeader() {
121132
const { _name, _version, _description } = this._cli;
122133
const command = this._command;
@@ -201,14 +212,14 @@ export class HelpRenderer {
201212
private getSubcommands(parentCommandName: string): CommandsMap {
202213
const subcommands = new Map<string, Command>();
203214

204-
if (!parentCommandName) {
215+
if (parentCommandName === "") {
205216
return subcommands;
206217
}
207218

208219
const prefix = `${parentCommandName} `;
209220

210221
for (const [name, command] of this._cli._commands) {
211-
if (name.startsWith(prefix) && name !== parentCommandName) {
222+
if (name.startsWith(prefix)) {
212223
const subcommandName = name.slice(prefix.length);
213224
subcommands.set(subcommandName, command);
214225
}
@@ -217,31 +228,10 @@ export class HelpRenderer {
217228
return subcommands;
218229
}
219230

220-
private renderCommands() {
221-
const commands = this._cli._commands;
222-
223-
// If a command is selected, show its subcommands
224-
let commandsToShow: CommandsMap;
225-
let title = "Commands";
226-
let prefix = "";
227-
228-
if (this._command) {
229-
prefix = this._command.name ? `${this._command.name} ` : "";
230-
title = "Subcommands";
231-
commandsToShow = this.getSubcommands(this._command.name);
232-
233-
if (commandsToShow.size === 0) {
234-
return;
235-
}
236-
} else {
237-
commandsToShow = commands;
238-
}
239-
240-
if (commandsToShow.size === 0) {
241-
return;
242-
}
243-
244-
// Group commands
231+
private buildGroupedCommandsBody(
232+
commandsToShow: CommandsMap,
233+
prefix: string,
234+
): string[] {
245235
const groupedCommands = new Map<string, string[][]>();
246236
const defaultCommands: string[][] = [];
247237
let rootCommand: string[] = [];
@@ -265,7 +255,6 @@ export class HelpRenderer {
265255
);
266256

267257
if (command.name === "") {
268-
// Root command
269258
rootCommand = item;
270259
} else if (group && group !== DEFAULT_GROUP_KEY) {
271260
const groupItems = groupedCommands.get(group) ?? [];
@@ -276,7 +265,6 @@ export class HelpRenderer {
276265
}
277266
}
278267

279-
// Build body with groups
280268
const body: string[] = [];
281269

282270
const defaultGroup: string[][] = [];
@@ -306,6 +294,62 @@ export class HelpRenderer {
306294
}
307295
}
308296

297+
return body;
298+
}
299+
300+
public renderAvailableSubcommands(parentCommandName: string): string {
301+
const subcommands = this.getSubcommands(parentCommandName);
302+
303+
if (subcommands.size === 0) {
304+
return "";
305+
}
306+
307+
const prefix = `${parentCommandName} `;
308+
const body = this.buildGroupedCommandsBody(subcommands, prefix);
309+
310+
if (body.length === 0) {
311+
return "";
312+
}
313+
314+
const sections: Section[] = [
315+
{
316+
body: `${yc.green(this._cli._name)} ${yc.cyan(parentCommandName)} not found`,
317+
},
318+
{
319+
title: "Available Subcommands",
320+
body,
321+
},
322+
];
323+
324+
return this.renderSections(sections);
325+
}
326+
327+
private renderCommands() {
328+
const commands = this._cli._commands;
329+
330+
// If a command is selected, show its subcommands
331+
let commandsToShow: CommandsMap;
332+
let title = "Commands";
333+
let prefix = "";
334+
335+
if (this._command) {
336+
prefix = this._command.name ? `${this._command.name} ` : "";
337+
title = "Subcommands";
338+
commandsToShow = this.getSubcommands(this._command.name);
339+
340+
if (commandsToShow.size === 0) {
341+
return;
342+
}
343+
} else {
344+
commandsToShow = commands;
345+
}
346+
347+
if (commandsToShow.size === 0) {
348+
return;
349+
}
350+
351+
const body = this.buildGroupedCommandsBody(commandsToShow, prefix);
352+
309353
return {
310354
title,
311355
body,

packages/plugin-help/test/__snapshots__/plugin-help.test.ts.snap

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,30 @@ exports[`plugin-help > should show --help 1`] = `
254254
]
255255
`;
256256
257+
exports[`plugin-help > should show available subcommands when parent command does not exist (using --help) 1`] = `
258+
[
259+
[
260+
"test completions not found
261+
262+
Available Subcommands
263+
install Install shell completions
264+
uninstall Uninstall shell completions",
265+
],
266+
]
267+
`;
268+
269+
exports[`plugin-help > should show available subcommands when parent command does not exist 1`] = `
270+
[
271+
[
272+
"test completions not found
273+
274+
Available Subcommands
275+
install Install shell completions
276+
uninstall Uninstall shell completions",
277+
],
278+
]
279+
`;
280+
257281
exports[`plugin-help > should show help 1`] = `
258282
[
259283
[

packages/plugin-help/test/plugin-help.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,35 @@ describe("plugin-help", () => {
300300
}).rejects.toThrow(NoSuchCommandError);
301301
});
302302

303+
it("should show available subcommands when parent command does not exist", () => {
304+
TestBaseCli()
305+
.use(helpPlugin())
306+
.command("completions install", "Install shell completions")
307+
.command("completions uninstall", "Uninstall shell completions")
308+
.parse(["help", "completions"]);
309+
310+
expect(getConsoleMock("log").mock.calls).toMatchSnapshot();
311+
});
312+
313+
it("should show available subcommands when parent command does not exist (using --help)", () => {
314+
TestBaseCli()
315+
.use(helpPlugin())
316+
.command("completions install", "Install shell completions")
317+
.command("completions uninstall", "Uninstall shell completions")
318+
.parse(["completions", "--help"]);
319+
320+
expect(getConsoleMock("log").mock.calls).toMatchSnapshot();
321+
});
322+
323+
it("should still throw error when no subcommands exist for non-existent command", async () => {
324+
await expect(async () => {
325+
await TestBaseCli()
326+
.use(helpPlugin())
327+
.command("other", "Other command")
328+
.parse(["help", "not-exist"]);
329+
}).rejects.toThrow(NoSuchCommandError);
330+
});
331+
303332
it("should work with friendly-error", async () => {
304333
expect(async () => {
305334
await TestBaseCli()

0 commit comments

Comments
 (0)