Skip to content

Commit af0c3cc

Browse files
committed
feat: add tab completions
1 parent e4c5226 commit af0c3cc

6 files changed

Lines changed: 264 additions & 6 deletions

File tree

‎.cspell.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@
104104
"Zenitsu",
105105
"quickstart",
106106
"pinia",
107-
"watchpack"
107+
"watchpack",
108+
"zsh"
108109
],
109110
"dictionaries": ["npm", "software-terms"],
110111
"ignorePaths": [

‎package-lock.json‎

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/webpack-cli/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"!**/*__tests__"
3232
],
3333
"dependencies": {
34+
"@bomb.sh/tab": "^0.0.22",
3435
"@discoveryjs/json-ext": "^1.1.0",
3536
"commander": "^14.0.3",
3637
"cross-spawn": "^7.0.6",

‎packages/webpack-cli/src/webpack-cli.ts‎

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import { type Readable as ReadableType } from "node:stream";
55
import { fileURLToPath, pathToFileURL } from "node:url";
66
import util from "node:util";
7+
import { type RootCommand } from "@bomb.sh/tab";
78
import { type stringifyChunked as stringifyChunkedType } from "@discoveryjs/json-ext";
89
import {
910
type Command as CommanderCommand,
@@ -149,6 +150,7 @@ interface KnownWebpackCLICommands {
149150
help: CommandOptions<void, CommanderArgs, Context>;
150151
info: CommandOptions<void, CommanderArgs, Context>;
151152
configtest: CommandOptions<string | undefined, CommanderArgs, WebpackContext & Context>;
153+
complete: CommandOptions<void, CommanderArgs, Context>;
152154
}
153155

154156
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -577,6 +579,12 @@ class WebpackCLI {
577579
return str.length > 0 ? str.charAt(0).toUpperCase() + str.slice(1) : str;
578580
}
579581

582+
#commandTerm(command: CommanderCommand): string {
583+
const aliases = command.aliases().filter(Boolean);
584+
585+
return aliases.length > 0 ? `${command.name()}|${aliases.join("|")}` : command.name();
586+
}
587+
580588
toKebabCase(str: string): string {
581589
return str.replaceAll(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
582590
}
@@ -1424,14 +1432,12 @@ class WebpackCLI {
14241432
return `${parentCmdNames}${command.usage()} | ${parentCmdNames}[command] [options]`;
14251433
}
14261434

1427-
return `${parentCmdNames}${command.name()}|${command
1428-
.aliases()
1429-
.join("|")} ${command.usage()}`;
1435+
return `${parentCmdNames}${this.#commandTerm(command)} ${command.usage()}`;
14301436
},
14311437
// Support multiple aliases
14321438
subcommandTerm: (command) => {
14331439
const usage = command.usage();
1434-
return `${command.name()}|${command.aliases().join("|")}${usage.length > 0 ? ` ${usage}` : ""}`;
1440+
return `${this.#commandTerm(command)}${usage.length > 0 ? ` ${usage}` : ""}`;
14351441
},
14361442
visibleOptions: function visibleOptions(command) {
14371443
return command.options.filter((option) => {
@@ -2439,6 +2445,16 @@ class WebpackCLI {
24392445
);
24402446
},
24412447
},
2448+
complete: {
2449+
rawName: "complete",
2450+
name: "complete [shell]",
2451+
alias: [],
2452+
description: "Generate shell completion scripts.",
2453+
action: () => {
2454+
// Display-only stub for `--help`. Completion requests are handled in `run()`
2455+
// before parse so the completion adapter can inspect the loaded command tree.
2456+
},
2457+
},
24422458
};
24432459

24442460
#isCommand<A = void, O extends CommanderArgs = CommanderArgs, C extends Context = Context>(
@@ -2483,6 +2499,8 @@ class WebpackCLI {
24832499
return await this.makeCommand(this.#commands.info);
24842500
} else if (this.#isCommand(commandName, this.#commands.configtest)) {
24852501
return await this.makeCommand(this.#commands.configtest);
2502+
} else if (this.#isCommand(commandName, this.#commands.complete)) {
2503+
return await this.makeCommand(this.#commands.complete);
24862504
}
24872505

24882506
const pkg: string = commandName;
@@ -2521,7 +2539,125 @@ class WebpackCLI {
25212539
return externalCommand;
25222540
}
25232541

2524-
async run(args: readonly string[], parseOptions: ParseOptions) {
2542+
#isCompletionRequest(args: readonly string[], parseOptions?: ParseOptions): boolean {
2543+
const userArgs = parseOptions?.from === "user" ? args : args.slice(2);
2544+
2545+
return userArgs.find((arg) => arg !== "--" && !arg.startsWith("-")) === "complete";
2546+
}
2547+
2548+
async #prepareCompletions(): Promise<void> {
2549+
// Register every option (same as `--help`) so tab can inspect the full tree.
2550+
this.program.forHelp = true;
2551+
2552+
await Promise.all(
2553+
Object.values(this.#commands)
2554+
.filter((command) => command.rawName !== "complete")
2555+
.map((command) => this.#loadCommandByName(command.rawName)),
2556+
);
2557+
2558+
const { default: tab } = await import("@bomb.sh/tab/commander");
2559+
2560+
this.#attachCompletionHandlers(tab(this.program));
2561+
}
2562+
2563+
#attachCompletionHandlers(completion: RootCommand): void {
2564+
const helpOption = completion.options.get("help");
2565+
2566+
if (helpOption) {
2567+
helpOption.handler = (complete) => {
2568+
complete("verbose", "Show all available commands and options");
2569+
};
2570+
}
2571+
2572+
const outputHandler = (complete: (value: string, description: string) => void) => {
2573+
complete("json", "JSON output");
2574+
complete("markdown", "Markdown output");
2575+
};
2576+
2577+
for (const name of ["info", "version"]) {
2578+
const outputOption = completion.commands.get(name)?.options.get("output");
2579+
2580+
if (outputOption) {
2581+
outputOption.handler = outputHandler;
2582+
}
2583+
}
2584+
2585+
const progressHandler = (complete: (value: string, description: string) => void) => {
2586+
complete("profile", "Capture profiling data");
2587+
};
2588+
2589+
for (const name of ["build", "watch", "serve"]) {
2590+
const progressOption = completion.commands.get(name)?.options.get("progress");
2591+
2592+
if (progressOption) {
2593+
progressOption.handler = progressHandler;
2594+
}
2595+
}
2596+
2597+
const completeCommand = completion.commands.get("complete");
2598+
const shellArg = completeCommand?.arguments.get("shell");
2599+
2600+
if (shellArg) {
2601+
shellArg.handler = (complete) => {
2602+
complete("zsh", "Zsh");
2603+
complete("bash", "Bash");
2604+
complete("fish", "Fish");
2605+
complete("powershell", "PowerShell");
2606+
};
2607+
}
2608+
2609+
for (const command of this.program.commands) {
2610+
const tabCommand = completion.commands.get(command.name());
2611+
2612+
if (!tabCommand) {
2613+
continue;
2614+
}
2615+
2616+
for (const option of command.options) {
2617+
const longName = option.long?.slice(2);
2618+
2619+
if (!longName) {
2620+
continue;
2621+
}
2622+
2623+
const { configs } = option as Option & { configs?: ArgumentConfig[] };
2624+
2625+
if (!configs) {
2626+
continue;
2627+
}
2628+
2629+
const values: string[] = [];
2630+
2631+
for (const config of configs) {
2632+
if (config.type !== "enum" || !config.values) {
2633+
continue;
2634+
}
2635+
2636+
for (const value of config.values) {
2637+
if (typeof value === "string") {
2638+
values.push(value);
2639+
}
2640+
}
2641+
}
2642+
2643+
if (values.length === 0) {
2644+
continue;
2645+
}
2646+
2647+
const tabOption = tabCommand.options.get(longName);
2648+
2649+
if (tabOption) {
2650+
tabOption.handler = (complete) => {
2651+
for (const value of values) {
2652+
complete(value, "");
2653+
}
2654+
};
2655+
}
2656+
}
2657+
}
2658+
}
2659+
2660+
async run(args: readonly string[], parseOptions?: ParseOptions) {
25252661
// Default `--color` and `--no-color` options
25262662

25272663
const self: WebpackCLI = this;
@@ -2729,6 +2865,10 @@ class WebpackCLI {
27292865
await command.parseAsync([...commandOperands, ...unknown], { from: "user" });
27302866
});
27312867

2868+
if (this.#isCompletionRequest(args, parseOptions)) {
2869+
await this.#prepareCompletions();
2870+
}
2871+
27322872
await this.program.parseAsync(args, parseOptions);
27332873
}
27342874

‎test/complete/complete.test.js‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"use strict";
2+
3+
const { run } = require("../utils/test-utils");
4+
5+
const parseCompletions = (stdout) =>
6+
stdout
7+
.split("\n")
8+
.map((line) => line.trimEnd())
9+
.filter((line) => line && !line.startsWith(":"))
10+
.map((line) => line.split("\t")[0]);
11+
12+
describe("complete", () => {
13+
it("should generate a zsh completion script", async () => {
14+
const { exitCode, stderr, stdout } = await run(__dirname, ["complete", "zsh"]);
15+
16+
expect(exitCode).toBe(0);
17+
expect(stderr).toBeFalsy();
18+
expect(stdout).toContain("webpack");
19+
expect(stdout).toContain("complete");
20+
});
21+
22+
it("should generate a bash completion script", async () => {
23+
const { exitCode, stderr, stdout } = await run(__dirname, ["complete", "bash"]);
24+
25+
expect(exitCode).toBe(0);
26+
expect(stderr).toBeFalsy();
27+
expect(stdout).toContain("webpack");
28+
});
29+
30+
it("should suggest commands", async () => {
31+
const { exitCode, stderr, stdout } = await run(__dirname, ["complete", "--", ""]);
32+
33+
expect(exitCode).toBe(0);
34+
expect(stderr).toBeFalsy();
35+
36+
const suggestions = parseCompletions(stdout);
37+
38+
expect(suggestions).toEqual(expect.arrayContaining(["build", "watch", "serve", "info"]));
39+
});
40+
41+
it("should suggest option values for --mode", async () => {
42+
const { exitCode, stderr, stdout } = await run(__dirname, [
43+
"complete",
44+
"--",
45+
"build",
46+
"--mode=",
47+
]);
48+
49+
expect(exitCode).toBe(0);
50+
expect(stderr).toBeFalsy();
51+
52+
const suggestions = parseCompletions(stdout);
53+
54+
expect(suggestions).toEqual(expect.arrayContaining(["development", "production", "none"]));
55+
});
56+
57+
it("should suggest option values for info --output", async () => {
58+
const { exitCode, stderr, stdout } = await run(__dirname, [
59+
"complete",
60+
"--",
61+
"info",
62+
"--output=",
63+
]);
64+
65+
expect(exitCode).toBe(0);
66+
expect(stderr).toBeFalsy();
67+
68+
const suggestions = parseCompletions(stdout);
69+
70+
expect(suggestions).toEqual(expect.arrayContaining(["json", "markdown"]));
71+
});
72+
73+
it("should suggest flags for the build command", async () => {
74+
const { exitCode, stderr, stdout } = await run(__dirname, ["complete", "--", "build", "--"]);
75+
76+
expect(exitCode).toBe(0);
77+
expect(stderr).toBeFalsy();
78+
79+
const suggestions = parseCompletions(stdout);
80+
81+
expect(suggestions).toEqual(expect.arrayContaining(["--mode", "--config", "--entry"]));
82+
});
83+
});

0 commit comments

Comments
 (0)