Skip to content

Commit 313f96b

Browse files
AmirSa12claude
authored andcommitted
feat: add tab completions
1 parent 7d40e4e commit 313f96b

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
@@ -106,7 +106,8 @@
106106
"Zenitsu",
107107
"quickstart",
108108
"pinia",
109-
"watchpack"
109+
"watchpack",
110+
"zsh"
110111
],
111112
"dictionaries": ["npm", "software-terms"],
112113
"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,
@@ -157,6 +158,7 @@ interface KnownWebpackCLICommands {
157158
help: CommandOptions<void, CommanderArgs, Context>;
158159
info: CommandOptions<void, CommanderArgs, Context>;
159160
configtest: CommandOptions<string | undefined, CommanderArgs, WebpackContext & Context>;
161+
complete: CommandOptions<void, CommanderArgs, Context>;
160162
}
161163

162164
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -585,6 +587,12 @@ class WebpackCLI {
585587
return str.length > 0 ? str.charAt(0).toUpperCase() + str.slice(1) : str;
586588
}
587589

590+
#commandTerm(command: CommanderCommand): string {
591+
const aliases = command.aliases().filter(Boolean);
592+
593+
return aliases.length > 0 ? `${command.name()}|${aliases.join("|")}` : command.name();
594+
}
595+
588596
toKebabCase(str: string): string {
589597
return str.replaceAll(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
590598
}
@@ -1432,14 +1440,12 @@ class WebpackCLI {
14321440
return `${parentCmdNames}${command.usage()} | ${parentCmdNames}[command] [options]`;
14331441
}
14341442

1435-
return `${parentCmdNames}${command.name()}|${command
1436-
.aliases()
1437-
.join("|")} ${command.usage()}`;
1443+
return `${parentCmdNames}${this.#commandTerm(command)} ${command.usage()}`;
14381444
},
14391445
// Support multiple aliases
14401446
subcommandTerm: (command) => {
14411447
const usage = command.usage();
1442-
return `${command.name()}|${command.aliases().join("|")}${usage.length > 0 ? ` ${usage}` : ""}`;
1448+
return `${this.#commandTerm(command)}${usage.length > 0 ? ` ${usage}` : ""}`;
14431449
},
14441450
visibleOptions: function visibleOptions(command) {
14451451
return command.options.filter((option) => {
@@ -2445,6 +2451,16 @@ class WebpackCLI {
24452451
);
24462452
},
24472453
},
2454+
complete: {
2455+
rawName: "complete",
2456+
name: "complete [shell]",
2457+
alias: [],
2458+
description: "Generate shell completion scripts.",
2459+
action: () => {
2460+
// Display-only stub for `--help`. Completion requests are handled in `run()`
2461+
// before parse so the completion adapter can inspect the loaded command tree.
2462+
},
2463+
},
24482464
};
24492465

24502466
#isCommand<A = void, O extends CommanderArgs = CommanderArgs, C extends Context = Context>(
@@ -2489,6 +2505,8 @@ class WebpackCLI {
24892505
return await this.makeCommand(this.#commands.info);
24902506
} else if (this.#isCommand(commandName, this.#commands.configtest)) {
24912507
return await this.makeCommand(this.#commands.configtest);
2508+
} else if (this.#isCommand(commandName, this.#commands.complete)) {
2509+
return await this.makeCommand(this.#commands.complete);
24922510
}
24932511

24942512
const pkg: string = commandName;
@@ -2527,7 +2545,125 @@ class WebpackCLI {
25272545
return externalCommand;
25282546
}
25292547

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

25332669
const self: WebpackCLI = this;
@@ -2735,6 +2871,10 @@ class WebpackCLI {
27352871
await command.parseAsync([...commandOperands, ...unknown], { from: "user" });
27362872
});
27372873

2874+
if (this.#isCompletionRequest(args, parseOptions)) {
2875+
await this.#prepareCompletions();
2876+
}
2877+
27382878
await this.program.parseAsync(args, parseOptions);
27392879
}
27402880

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)