Skip to content

Commit 2bfe024

Browse files
committed
Add wrangler shell completions
1 parent 12a63ef commit 2bfe024

File tree

9 files changed

+783
-47
lines changed

9 files changed

+783
-47
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add `wrangler completions` command for shell completion scripts (bash, zsh, fish)
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { execSync } from "child_process";
2+
import { describe, test } from "vitest";
3+
import { mockConsoleMethods } from "./helpers/mock-console";
4+
import { runInTempDir } from "./helpers/run-in-tmp";
5+
import { runWrangler } from "./helpers/run-wrangler";
6+
7+
function shellAvailable(shell: string): boolean {
8+
try {
9+
execSync(`which ${shell}`, { stdio: "ignore" });
10+
return true;
11+
} catch {
12+
return false;
13+
}
14+
}
15+
16+
describe("wrangler", () => {
17+
describe("completions", () => {
18+
const std = mockConsoleMethods();
19+
runInTempDir({ homedir: "./home" });
20+
21+
test("should show available shells in help", async ({ expect }) => {
22+
const result = runWrangler("completions --help");
23+
24+
await expect(result).resolves.toBeUndefined();
25+
expect(std.out).toContain("Generate shell completion scripts");
26+
expect(std.out).toContain("wrangler completions bash");
27+
expect(std.out).toContain("wrangler completions zsh");
28+
expect(std.out).toContain("wrangler completions fish");
29+
});
30+
31+
// =========================================================================
32+
// __complete command tests
33+
// =========================================================================
34+
describe("__complete", () => {
35+
test("should return top-level commands", async ({ expect }) => {
36+
await runWrangler("__complete wrangler");
37+
38+
expect(std.out).toContain("deploy\t");
39+
expect(std.out).toContain("dev\t");
40+
expect(std.out).toContain("kv\t");
41+
});
42+
43+
test("should return subcommands for namespace", async ({ expect }) => {
44+
// Empty string signals "complete after kv"
45+
await runWrangler('__complete wrangler kv ""');
46+
47+
expect(std.out).toContain("namespace\t");
48+
expect(std.out).toContain("key\t");
49+
});
50+
51+
test("should filter by prefix", async ({ expect }) => {
52+
await runWrangler("__complete wrangler kv na");
53+
54+
expect(std.out).toContain("namespace\t");
55+
expect(std.out).not.toContain("key\t");
56+
});
57+
58+
test("should return flags when prefix is --", async ({ expect }) => {
59+
// Empty string after dev, then filter by --
60+
await runWrangler('__complete wrangler dev "" --');
61+
62+
expect(std.out).toContain("--port\t");
63+
expect(std.out).toContain("--config\t");
64+
});
65+
66+
test("should exclude hidden commands", async ({ expect }) => {
67+
await runWrangler("__complete wrangler");
68+
69+
// "check" namespace is hidden
70+
expect(std.out).not.toMatch(/^check\t/m);
71+
});
72+
73+
test("should handle deeply nested commands", async ({ expect }) => {
74+
// Empty string signals "complete after http"
75+
await runWrangler('__complete wrangler queues consumer http ""');
76+
77+
expect(std.out).toContain("add\t");
78+
expect(std.out).toContain("remove\t");
79+
});
80+
81+
test("should output tab-separated format", async ({ expect }) => {
82+
await runWrangler("__complete wrangler");
83+
84+
// Each line should be "value\tdescription"
85+
const lines = std.out.trim().split("\n");
86+
for (const line of lines) {
87+
expect(line).toMatch(/^[^\t]+\t.*$/);
88+
}
89+
});
90+
91+
test("should include global flags", async ({ expect }) => {
92+
await runWrangler("__complete wrangler --");
93+
94+
expect(std.out).toContain("--help\t");
95+
expect(std.out).toContain("--config\t");
96+
});
97+
98+
test("should skip flags when building command path", async ({ expect }) => {
99+
// --binding should be skipped, so we're completing flags for "d1 create"
100+
// Use -- to prevent yargs from parsing --binding as a flag for __complete
101+
await runWrangler('__complete -- wrangler d1 create --binding foo ""');
102+
103+
expect(std.out).toContain("--name\t");
104+
// Should not re-suggest d1 or create
105+
expect(std.out).not.toMatch(/^d1\t/m);
106+
expect(std.out).not.toMatch(/^create\t/m);
107+
});
108+
109+
test("should skip flag values when building command path", async ({ expect }) => {
110+
// Both --config and its value should be skipped
111+
// Use -- to prevent yargs from parsing --config as a flag for __complete
112+
await runWrangler('__complete -- wrangler --config wrangler.toml kv ""');
113+
114+
expect(std.out).toContain("namespace\t");
115+
expect(std.out).toContain("key\t");
116+
});
117+
});
118+
119+
// =========================================================================
120+
// Shell script generation tests
121+
// =========================================================================
122+
const shells = ["bash", "zsh", "fish"] as const;
123+
124+
describe.each(shells)("%s", (shell) => {
125+
test("should output script with begin/end markers", async ({
126+
expect,
127+
}) => {
128+
await runWrangler(`completions ${shell}`);
129+
130+
expect(std.out).toContain("###-begin-wrangler-completions-###");
131+
expect(std.out).toContain("###-end-wrangler-completions-###");
132+
});
133+
134+
test("should show shell-specific description in help", async ({
135+
expect,
136+
}) => {
137+
await runWrangler(`completions ${shell} --help`);
138+
139+
expect(std.out).toContain(`Generate ${shell} completion script`);
140+
});
141+
142+
test("should reference wrangler __complete", async ({ expect }) => {
143+
await runWrangler(`completions ${shell}`);
144+
145+
expect(std.out).toContain("wrangler __complete");
146+
});
147+
});
148+
149+
// =========================================================================
150+
// Shell syntax validation tests
151+
// =========================================================================
152+
describe("bash", () => {
153+
test.skipIf(!shellAvailable("bash"))(
154+
"should generate valid bash syntax",
155+
async ({ expect }) => {
156+
await runWrangler("completions bash");
157+
158+
// bash -n checks syntax without executing
159+
expect(() => {
160+
execSync("bash -n", { input: std.out });
161+
}).not.toThrow();
162+
}
163+
);
164+
165+
test("should define _wrangler_completions function", async ({
166+
expect,
167+
}) => {
168+
await runWrangler("completions bash");
169+
170+
expect(std.out).toContain("_wrangler_completions()");
171+
});
172+
173+
test("should register completion with complete builtin", async ({
174+
expect,
175+
}) => {
176+
await runWrangler("completions bash");
177+
178+
expect(std.out).toContain(
179+
"complete -o default -F _wrangler_completions wrangler"
180+
);
181+
});
182+
});
183+
184+
describe("zsh", () => {
185+
test.skipIf(!shellAvailable("zsh"))(
186+
"should generate valid zsh syntax",
187+
async ({ expect }) => {
188+
await runWrangler("completions zsh");
189+
190+
// zsh -n checks syntax without executing
191+
expect(() => {
192+
execSync("zsh -n", { input: std.out });
193+
}).not.toThrow();
194+
}
195+
);
196+
197+
test("should start with #compdef directive", async ({ expect }) => {
198+
await runWrangler("completions zsh");
199+
200+
expect(std.out).toContain("#compdef wrangler");
201+
});
202+
203+
test("should use _describe for completions", async ({ expect }) => {
204+
await runWrangler("completions zsh");
205+
206+
expect(std.out).toContain("_describe 'wrangler' completions");
207+
});
208+
209+
test("should register with compdef", async ({ expect }) => {
210+
await runWrangler("completions zsh");
211+
212+
expect(std.out).toContain("compdef _wrangler wrangler");
213+
});
214+
});
215+
216+
describe("fish", () => {
217+
test.skipIf(!shellAvailable("fish"))(
218+
"should generate valid fish syntax",
219+
async ({ expect }) => {
220+
await runWrangler("completions fish");
221+
222+
// fish -n checks syntax without executing
223+
expect(() => {
224+
execSync("fish -n", { input: std.out });
225+
}).not.toThrow();
226+
}
227+
);
228+
229+
test("should define __wrangler_prepare_completions function", async ({
230+
expect,
231+
}) => {
232+
await runWrangler("completions fish");
233+
234+
expect(std.out).toContain("function __wrangler_prepare_completions");
235+
});
236+
237+
test("should register completion with complete builtin", async ({
238+
expect,
239+
}) => {
240+
await runWrangler("completions fish");
241+
242+
expect(std.out).toContain("complete -c wrangler -f -n");
243+
expect(std.out).toContain("$__wrangler_comp_results");
244+
});
245+
246+
test("should capture both completed tokens and current token", async ({
247+
expect,
248+
}) => {
249+
await runWrangler("completions fish");
250+
251+
// commandline -opc gets completed tokens
252+
expect(std.out).toContain("commandline -opc");
253+
// commandline -ct gets current token being typed
254+
expect(std.out).toContain("commandline -ct");
255+
});
256+
});
257+
});
258+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { createCommand, createNamespace } from "../core/create-command";
2+
import { handleComplete } from "./complete-handler";
3+
import { logger } from "../logger";
4+
import { getDefinitionTree } from "./registry-store";
5+
import { getBashScript, getFishScript, getZshScript } from "./scripts";
6+
7+
export const completionsNamespace = createNamespace({
8+
metadata: {
9+
description: "Generate shell completion scripts",
10+
owner: "Workers: Authoring and Testing",
11+
status: "stable",
12+
epilogue: `Installation:
13+
bash: wrangler completions bash >> ~/.bashrc
14+
zsh: wrangler completions zsh >> ~/.zshrc
15+
fish: wrangler completions fish > ~/.config/fish/completions/wrangler.fish`,
16+
},
17+
});
18+
19+
export const completionsBashCommand = createCommand({
20+
metadata: {
21+
description: "Generate bash completion script",
22+
owner: "Workers: Authoring and Testing",
23+
status: "stable",
24+
},
25+
behaviour: {
26+
printBanner: false,
27+
provideConfig: false,
28+
},
29+
handler() {
30+
const tree = getDefinitionTree();
31+
logger.log(getBashScript(tree));
32+
},
33+
});
34+
35+
export const completionsZshCommand = createCommand({
36+
metadata: {
37+
description: "Generate zsh completion script",
38+
owner: "Workers: Authoring and Testing",
39+
status: "stable",
40+
},
41+
behaviour: {
42+
printBanner: false,
43+
provideConfig: false,
44+
},
45+
handler() {
46+
const tree = getDefinitionTree();
47+
logger.log(getZshScript(tree));
48+
},
49+
});
50+
51+
export const completionsFishCommand = createCommand({
52+
metadata: {
53+
description: "Generate fish completion script",
54+
owner: "Workers: Authoring and Testing",
55+
status: "stable",
56+
},
57+
behaviour: {
58+
printBanner: false,
59+
provideConfig: false,
60+
},
61+
handler() {
62+
const tree = getDefinitionTree();
63+
logger.log(getFishScript(tree));
64+
},
65+
});
66+
67+
export const completeCommand = createCommand({
68+
metadata: {
69+
description: "Output completions for shell integration",
70+
owner: "Workers: Authoring and Testing",
71+
status: "stable",
72+
hidden: true, // Not shown in --help
73+
},
74+
behaviour: {
75+
printBanner: false,
76+
provideConfig: false,
77+
},
78+
positionalArgs: ["args"],
79+
args: {
80+
args: {
81+
type: "string",
82+
array: true,
83+
description: "Command line words to complete",
84+
},
85+
},
86+
handler(args) {
87+
// When -- is used, yargs puts args in _ instead of the positional array
88+
// and includes the command name "__complete" as first element
89+
let completionArgs =
90+
args.args && args.args.length > 0
91+
? args.args
92+
: ((args as unknown as { _: string[] })._ ?? []);
93+
// Filter out __complete if it's the first arg (happens with --)
94+
if (completionArgs[0] === "__complete") {
95+
completionArgs = completionArgs.slice(1);
96+
}
97+
handleComplete(completionArgs);
98+
},
99+
});

0 commit comments

Comments
 (0)