1import { assert, describe, expect, it } from "@effect/vitest"
2import { Argument, Command, Flag } from "effect/cli"
3import * as Completions from "effect/cli/Completions"
4import * as Bash from "effect/cli/internal/completions/bash"
5import { fromCommand } from "effect/cli/internal/completions/descriptor"
6import * as Fish from "effect/cli/internal/completions/fish"
7import * as Zsh from "effect/cli/internal/completions/zsh"
8import { ComprehensiveCli } from "../fixtures/ComprehensiveCli.ts"
9
10// ---------------------------------------------------------------------------
11// Shared test fixtures
12// ---------------------------------------------------------------------------
13
14const simpleCmd = Command.make("greet", {
15 name: Argument.String("name").pipe(
16 Argument.withDescription("Name to greet")
17 ),
18 loud: Flag.Boolean("loud").pipe(
19 Flag.withAlias("l"),
20 Flag.withDescription("Shout the greeting")
21 ),
22 times: Flag.Int("times").pipe(
23 Flag.withDescription("Repeat count"),
24 Flag.withDefault(1)
25 )
26}).pipe(Command.withDescription("Greet someone"))
27
28const withSubcommands = (() => {
29 const start = Command.make("start", {
30 port: Flag.Int("port").pipe(
31 Flag.withAlias("p"),
32 Flag.withDescription("Port number")
33 ),
34 daemon: Flag.Boolean("daemon").pipe(
35 Flag.withDescription("Run as daemon")
36 )
37 }).pipe(Command.withDescription("Start the server"))
38
39 const stop = Command.make("stop", {
40 force: Flag.Boolean("force").pipe(
41 Flag.withAlias("f"),
42 Flag.withDescription("Force stop")
43 )
44 }).pipe(Command.withDescription("Stop the server"))
45
46 return Command.make("server", {
47 verbose: Flag.Boolean("verbose").pipe(Flag.withAlias("v")),
48 config: Flag.String("config")
49 }).pipe(
50 Command.withDescription("Server management"),
51 Command.withSubcommands([start, stop])
52 )
53})()
54
55const withChoices = Command.make("deploy", {
56 env: Flag.Literals("env", ["dev", "staging", "prod"]).pipe(
57 Flag.withDescription("Target environment")
58 ),
59 region: Argument.Literals("region", ["us-east", "eu-west", "ap-south"]).pipe(
60 Argument.withDescription("Deployment region")
61 )
62}).pipe(Command.withDescription("Deploy application"))
63
64const trickyValues = [
65 "it's-fine",
66 "node:20",
67 "with space",
68 "(whoami)",
69 "#tag",
70 "$HOME",
71 "back\\slash",
72 `say"hi"`,
73 "a*b",
74 "a;b",
75 "~x",
76 "foo'",
77 "a!b",
78 "\u{1F680}"
79]
80
81const withTrickyChoices = Command.make("deploy", {
82 mode: Flag.Literals("mode", trickyValues).pipe(
83 Flag.withDescription("Deploy mode")
84 ),
85 target: Argument.Literals("target", ["o'clock", "a:b", "{x,y}", "a\u{1F600}b"]).pipe(
86 Argument.withDescription("Deployment target")
87 )
88}).pipe(Command.withDescription("Deploy application"))
89
90const withPaths = Command.make("process", {
91 input: Flag.File("input").pipe(Flag.withDescription("Input file")),
92 outDir: Flag.Directory("output-dir").pipe(Flag.withDescription("Output directory")),
93 source: Argument.File("source", { mustExist: false }).pipe(
94 Argument.withDescription("Source file")
95 )
96}).pipe(Command.withDescription("Process files"))
97
98const withOptionalDirectoryAndSubcommands = Command.make("example", {
99 directory: Argument.Directory("directory").pipe(
100 Argument.withDescription("Directory to start in"),
101 Argument.optional
102 )
103}).pipe(
104 Command.withSubcommands([
105 Command.make("serve").pipe(Command.withDescription("Start the server"))
106 ])
107)
108
109const nested3Levels = (() => {
110 const leaf = Command.make("action", {
111 dryRun: Flag.Boolean("dry-run").pipe(Flag.withDescription("Dry run mode"))
112 }).pipe(Command.withDescription("Perform action"))
113
114 const mid = Command.make("sub").pipe(
115 Command.withSubcommands([leaf])
116 )
117
118 return Command.make("top").pipe(
119 Command.withSubcommands([mid])
120 )
121})()
122
123const emptyCmd = Command.make("noop").pipe(
124 Command.withDescription("Does nothing")
125)
126
127const choicesHelperSource = (script: string): string => {
128 const start = script.indexOf("_deploy--choices()")
129 const end = script.indexOf("\n}\n", start)
130 return script.slice(start, end + 2)
131}
132
133const linesWith = (script: string, needle: string): string =>
134 script.split("\n").filter((line) => line.includes(needle)).map((line) => line.trim()).join("\n")
135
136// ---------------------------------------------------------------------------
137// Bash completions
138// ---------------------------------------------------------------------------
139
140describe("Bash completions", () => {
141 it("completes the active positional argument instead of always using the first", () => {
142 const descriptor: Completions.CommandDescriptor = {
143 name: "tool",
144 description: undefined,
145 flags: [
146 {
147 name: "verbose",
148 aliases: ["v"],
149 description: undefined,
150 type: { _tag: "Boolean" }
151 },
152 {
153 name: "format",
154 aliases: ["f"],
155 description: undefined,
156 type: { _tag: "Choice", values: ["json", "text"] }
157 }
158 ],
159 arguments: [
160 {
161 name: "source",
162 description: undefined,
163 required: true,
164 variadic: false,
165 type: { _tag: "Choice", values: ["one"] }
166 },
167 {
168 name: "target",
169 description: undefined,
170 required: true,
171 variadic: false,
172 type: { _tag: "Choice", values: ["two"] }
173 }
174 ],
175 subcommands: []
176 }
177 const script = Bash.generate("tool", descriptor)
178
179 assert.include(script, `for ((i = _command_index + 1; i < cword; i++)); do`)
180 assert.include(script, `--verbose|-v|--no-verbose) ;;`)
181 assert.include(script, `--format|-f) _skip_next=1 ;;`)
182 assert.include(script, `--format=*|-f=*) ;;`)
183 assert.include(script, `0)\n _tool--choices "$cur" "$_comp_word" 'one'`)
184 assert.include(script, `1)\n _tool--choices "$cur" "$_comp_word" 'two'`)
185 })
186
187 it("generates completion function for root command", () => {
188 const desc = fromCommand(simpleCmd)
189 const script = Bash.generate("greet", desc)
190 assert.include(script, "_greet()")
191 assert.include(script, "complete -F _greet greet")
192 assert.include(script, `_init_completion -n "$COMP_WORDBREAKS" || return`)
193 })
194
195 it("includes subcommand names in word list", () => {
196 const desc = fromCommand(withSubcommands)
197 const script = Bash.generate("server", desc)
198 assert.include(script, "start)")
199 assert.include(script, "stop)")
200 })
201
202 it("does not dispatch subcommands from flag values", () => {
203 const desc = fromCommand(withSubcommands)
204 const script = Bash.generate("server", desc)
205 assert.include(
206 script,
207 `for ((i = _command_index + 1; i < cword; i++)); do
208 if (( _skip_next )); then
209 _skip_next=0
210 continue
211 fi
212 case "\${words[i]}" in
213 --config) _skip_next=1 ;;
214 --config=*) ;;
215 start)`
216 )
217 })
218
219 it("includes long flag names with -- prefix", () => {
220 const desc = fromCommand(simpleCmd)
221 const script = Bash.generate("greet", desc)
222 assert.include(script, "--loud")
223 assert.include(script, "--times")
224 })
225
226 it("includes short flag aliases", () => {
227 const desc = fromCommand(simpleCmd)
228 const script = Bash.generate("greet", desc)
229 assert.include(script, "-l")
230 })
231
232 it("generates --no-<flag> for boolean flags", () => {
233 const desc = fromCommand(simpleCmd)
234 const script = Bash.generate("greet", desc)
235 assert.include(script, "--no-loud")
236 })
237
238 it("uses compgen -f for file-type flags", () => {
239 const desc = fromCommand(withPaths)
240 const script = Bash.generate("process", desc)
241 assert.include(script, "compgen -f")
242 })
243
244 it("uses compgen -d for directory-type flags", () => {
245 const desc = fromCommand(withPaths)
246 const script = Bash.generate("process", desc)
247 assert.include(script, "compgen -d")
248 })
249
250 it("inlines choice values for choice flags", () => {
251 const desc = fromCommand(withChoices)
252 const script = Bash.generate("deploy", desc)
253 assert.include(script, `_deploy--choices "$cur" "$_comp_word" 'dev' 'staging' 'prod'`)
254 })
255
256 it("quotes choice values instead of exposing them to compgen -W re-expansion", () => {
257 const script = Bash.generate("deploy", fromCommand(withTrickyChoices))
258 expect(linesWith(script, `_deploy--choices "$cur"`)).toMatchInlineSnapshot(`
259 "_deploy--choices "$cur" "$_comp_word" 'it'\\''s-fine' 'node:20' 'with space' '(whoami)' '#tag' '$HOME' 'back\\slash' 'say"hi"' 'a*b' 'a;b' '~x' 'foo'\\''' 'a!b' '🚀'
260 _deploy--choices "$cur" "$_comp_word" 'o'\\''clock' 'a:b' '{x,y}' 'a😀b'"
261 `)
262 assert.notInclude(script, `compgen -W 'it`)
263 })
264
265 it("splits words on whitespace only, so a value holding a word-break character stays one word", () => {
266 const script = Bash.generate("deploy", fromCommand(withTrickyChoices))
267 assert.include(script, `_init_completion -n "$COMP_WORDBREAKS" || return`)
268 })
269
270 it("emits the choice helper", () => {
271 const script = Bash.generate("deploy", fromCommand(withTrickyChoices))
272 expect(choicesHelperSource(script)).toMatchInlineSnapshot(`
273 "_deploy--choices()
274 {
275 local _cur="$1" _word="$2"; shift 2
276
277 local _head="\${_cur%"$_word"}"
278 local _open=""
279 case "$_head" in
280 *\\') _open="'" ;;
281 *\\") _open='"' ;;
282 esac
283
284 local _prefix="$_cur" _committed="$_head"
285 _prefix=\${_prefix//\\\\/}; _prefix=\${_prefix//\\"/}; _prefix=\${_prefix//\\'/}
286 _committed=\${_committed//\\\\/}; _committed=\${_committed//\\"/}; _committed=\${_committed//\\'/}
287
288 COMPREPLY=()
289 local _choice _rest _match
290 for _choice in "$@"; do
291 [[ "$_choice" == "$_prefix"* ]] || continue
292 _rest="\${_choice#"$_committed"}"
293 case "$_open" in
294 "'")
295 if [[ "$_head" == "'" ]]; then
296 _match=\${_rest//\\'/\\'\\\\\\'\\'}
297 else
298 [[ "$_rest" == *\\'* ]] && continue
299 _match="$_rest"
300 fi
301 ;;
302 '"')
303 _match="\${_rest//\\\\/\\\\\\\\}"
304 _match="\${_match//\\$/\\\\$}"
305 _match="\${_match//\\\`/\\\\\\\`}"
306 _match="\${_match//\\"/\\\\\\"}"
307 ;;
308 *)
309 printf -v _match '%q' "$_rest"
310 [[ -z "$_head" && "$_match" == '~'* ]] && _match="\\\\$_match"
311 ;;
312 esac
313 [[ -n "$_open" && "$_match" == *"$_open" ]] && _match+="$_open"
314 COMPREPLY+=("$_match")
315 done
316 }"
317 `)
318 })
319
320 it("generates separate functions for nested subcommands", () => {
321 const desc = fromCommand(withSubcommands)
322 const script = Bash.generate("server", desc)
323 assert.include(script, "_server()")
324 assert.include(script, "_server_start()")
325 assert.include(script, "_server_stop()")
326 assert.include(script, `_server_start "$i"`)
327 })
328
329 it("handles commands with no subcommands", () => {
330 const desc = fromCommand(emptyCmd)
331 const script = Bash.generate("noop", desc)
332 assert.include(script, "_noop()")
333 assert.include(script, "complete -F _noop noop")
334 })
335
336 it("generates deeply nested functions", () => {
337 const desc = fromCommand(nested3Levels)
338 const script = Bash.generate("top", desc)
339 assert.include(script, "_top()")
340 assert.include(script, "_top_sub()")
341 assert.include(script, "_top_sub_action()")
342 })
343
344 it("wraps script in begin/end markers", () => {
345 const desc = fromCommand(simpleCmd)
346 const script = Bash.generate("greet", desc)
347 assert.include(script, "###-begin-greet-completions-###")
348 assert.include(script, "###-end-greet-completions-###")
349 })
350
351 it("groups flag aliases for used-flag filtering", () => {
352 const desc = fromCommand(simpleCmd)
353 const script = Bash.generate("greet", desc)
354 assert.include(script, "--loud|-l|--no-loud) _used_0=1 ;;")
355 assert.include(script, "--times) _used_1=1 ;;")
356 assert.include(script, `[[ -n "$_used_0" ]] || _filtered_flags+=" --loud -l --no-loud"`)
357 assert.include(script, `[[ -n "$_used_1" ]] || _filtered_flags+=" --times"`)
358 // Uses _filtered_flags instead of a static word list
359 assert.include(script, "compgen -W \"$_filtered_flags\"")
360 })
361
362 it("does not generate flag groups for commands with no flags", () => {
363 const desc = fromCommand(emptyCmd)
364 const script = Bash.generate("noop", desc)
365 assert.notInclude(script, "_used_0")
366 assert.notInclude(script, "_filtered_flags")
367 })
368
369 it("uses no bash 4 syntax, so the script runs on the bash macOS ships", () => {
370 const script = Bash.generate("comprehensive", fromCommand(ComprehensiveCli))
371 assert.notInclude(script, "local -A")
372 assert.notInclude(script, "declare -A")
373 })
374
375 it("includes inline _init_completion fallback", () => {
376 const desc = fromCommand(simpleCmd)
377 const script = Bash.generate("greet", desc)
378 assert.include(script, "if ! type _init_completion &>/dev/null; then")
379 assert.include(script, "COMPREPLY=()")
380 assert.include(script, `if [[ "$_line" == [[:blank:]]* ]]; then`)
381 assert.include(script, "((_i == COMP_CWORD)) && cword=$_j")
382 assert.include(script, `cur="\${words[cword]}"`)
383 })
384})
385
386// ---------------------------------------------------------------------------
387// Zsh completions
388// ---------------------------------------------------------------------------
389
390describe("Zsh completions", () => {
391 it("generates _arguments specs for flags", () => {
392 const desc = fromCommand(simpleCmd)
393 const script = Zsh.generate("greet", desc)
394 assert.include(script, "_arguments")
395 assert.include(script, "--loud")
396 assert.include(script, "--times")
397 })
398
399 it("includes flag descriptions in specs", () => {
400 const desc = fromCommand(simpleCmd)
401 const script = Zsh.generate("greet", desc)
402 assert.include(script, "Shout the greeting")
403 assert.include(script, "Repeat count")
404 })
405
406 it("includes subcommand descriptions with _describe", () => {
407 const desc = fromCommand(withSubcommands)
408 const script = Zsh.generate("server", desc)
409 assert.include(script, "_describe")
410 assert.include(script, "Start the server")
411 assert.include(script, "Stop the server")
412 })
413
414 it("generates --no-<flag> for boolean flags", () => {
415 const desc = fromCommand(simpleCmd)
416 const script = Zsh.generate("greet", desc)
417 assert.include(script, "--no-loud")
418 })
419
420 it("uses _files for file-type flags", () => {
421 const desc = fromCommand(withPaths)
422 const script = Zsh.generate("process", desc)
423 assert.include(script, "_files")
424 })
425
426 it("uses _directories for directory-type flags", () => {
427 const desc = fromCommand(withPaths)
428 const script = Zsh.generate("process", desc)
429 assert.include(script, "_directories")
430 })
431
432 it("inlines choice values with (val1 val2) syntax", () => {
433 const desc = fromCommand(withChoices)
434 const script = Zsh.generate("deploy", desc)
435 assert.include(script, "(dev staging prod)")
436 })
437
438 it("generates handler functions for nested subcommands", () => {
439 const desc = fromCommand(withSubcommands)
440 const script = Zsh.generate("server", desc)
441 assert.include(script, "_server()")
442 assert.include(script, "_server_start()")
443 assert.include(script, "_server_stop()")
444 })
445
446 it("generates argument specs for positional arguments", () => {
447 const desc = fromCommand(simpleCmd)
448 const script = Zsh.generate("greet", desc)
449 assert.include(script, "Name to greet")
450 })
451
452 it("generates choice argument completions", () => {
453 const desc = fromCommand(withChoices)
454 const script = Zsh.generate("deploy", desc)
455 assert.include(script, "(us-east eu-west ap-south)")
456 })
457
458 it("escapes choice values for both the spec quoting and the action list re-parse", () => {
459 const script = Zsh.generate("deploy", fromCommand(withTrickyChoices))
460 expect(linesWith(script, ":value:(")).toMatchInlineSnapshot(
461 `"'(--mode)--mode[Deploy mode]:value:(it\\'\\''s-fine node\\:20 with\\ space \\(whoami\\) \\#tag \\$HOME back\\\\slash say\\"hi\\" a\\*b a\\;b \\~x foo\\'\\'' a\\!b \\🚀)'"`
462 )
463 expect(linesWith(script, "Deployment target")).toMatchInlineSnapshot(
464 `"':Deployment target:(o\\'\\''clock a\\:b \\{x,y\\} a\\😀b)'"`
465 )
466 })
467
468 it("uses alternative argument sets for positional arguments and subcommands", () => {
469 const desc = fromCommand(withOptionalDirectoryAndSubcommands)
470 const script = Zsh.generate("example", desc)
471
472 assert.include(
473 script,
474 ` -
475 parent-arguments
476 ':Directory to start in:_directories'
477 -
478 subcommands
479 '1:command:->command'
480 '*::arg:->args'`
481 )
482 assert.notInclude(
483 script,
484 ` ':Directory to start in:_directories'
485 '1:command:->command'`
486 )
487 })
488
489 it("starts with #compdef directive", () => {
490 const desc = fromCommand(simpleCmd)
491 const script = Zsh.generate("greet", desc)
492 assert.isTrue(script.startsWith("#compdef greet"))
493 })
494
495 it("wraps script in begin/end markers", () => {
496 const desc = fromCommand(simpleCmd)
497 const script = Zsh.generate("greet", desc)
498 assert.include(script, "###-begin-greet-completions-###")
499 assert.include(script, "###-end-greet-completions-###")
500 })
501
502 it("declares state machine locals for commands with subcommands", () => {
503 const desc = fromCommand(withSubcommands)
504 const script = Zsh.generate("server", desc)
505 assert.include(script, "local context state state_descr line")
506 assert.include(script, "typeset -A opt_args")
507 })
508
509 it("does not declare state machine locals for leaf commands", () => {
510 const desc = fromCommand(simpleCmd)
511 const script = Zsh.generate("greet", desc)
512 assert.notInclude(script, "local context state state_descr line")
513 assert.notInclude(script, "typeset -A opt_args")
514 })
515
516 it("uses specs array instead of line continuations", () => {
517 const desc = fromCommand(simpleCmd)
518 const script = Zsh.generate("greet", desc)
519 assert.include(script, "local -a specs")
520 assert.include(script, "specs=(")
521 assert.include(script, "_arguments \"${specs[@]}\"")
522 })
523
524 it("generates exclusion groups for flag aliases", () => {
525 const desc = fromCommand(simpleCmd)
526 const script = Zsh.generate("greet", desc)
527 // --loud, -l, and --no-loud should share an exclusion group
528 assert.include(script, "'(--loud -l --no-loud)--loud[Shout the greeting]'")
529 assert.include(script, "'(--loud -l --no-loud)-l[Shout the greeting]'")
530 assert.include(script, "'(--loud -l --no-loud)--no-loud[Disable loud]'")
531 })
532
533 it("generates exclusion group for flags without aliases", () => {
534 const desc = fromCommand(simpleCmd)
535 const script = Zsh.generate("greet", desc)
536 // --times has no alias, exclusion group is just (--times)
537 assert.include(script, "'(--times)--times[Repeat count]:integer:'")
538 })
539
540 it("uses Disable description for boolean negation", () => {
541 const desc = fromCommand(simpleCmd)
542 const script = Zsh.generate("greet", desc)
543 assert.include(script, "--no-loud[Disable loud]")
544 })
545})
546
547// ---------------------------------------------------------------------------
548// Fish completions
549// ---------------------------------------------------------------------------
550
551describe("Fish completions", () => {
552 it("scopes nested completions by the full command path", () => {
553 const leaf = (name: string, flag: string): Completions.CommandDescriptor => ({
554 name,
555 description: undefined,
556 flags: [{ name: flag, aliases: [], description: undefined, type: { _tag: "Boolean" } }],
557 arguments: [],
558 subcommands: []
559 })
560 const descriptor: Completions.CommandDescriptor = {
561 name: "tool",
562 description: undefined,
563 flags: [],
564 arguments: [],
565 subcommands: [
566 {
567 name: "alpha",
568 description: undefined,
569 flags: [],
570 arguments: [],
571 subcommands: [leaf("common", "alpha-only")]
572 },
573 { name: "beta", description: undefined, flags: [], arguments: [], subcommands: [leaf("common", "beta-only")] }
574 ]
575 }
576 const lines = Fish.generate("tool", descriptor).split("\n")
577 const alphaOnly = lines.find((line) => line.includes("-l alpha-only"))!
578 const betaOnly = lines.find((line) => line.includes("-l beta-only"))!
579
580 assert.include(alphaOnly, "__fish_seen_subcommand_from alpha; and __fish_seen_subcommand_from common")
581 assert.include(betaOnly, "__fish_seen_subcommand_from beta; and __fish_seen_subcommand_from common")
582 })
583
584 it("generates complete commands for root subcommands", () => {
585 const desc = fromCommand(withSubcommands)
586 const script = Fish.generate("server", desc)
587 assert.include(script, "complete -c server")
588 assert.include(script, "-a 'start'")
589 assert.include(script, "-a 'stop'")
590 })
591
592 it("generates complete commands for flags with -l and -s", () => {
593 const desc = fromCommand(simpleCmd)
594 const script = Fish.generate("greet", desc)
595 assert.include(script, "-l loud")
596 assert.include(script, "-s l")
597 assert.include(script, "-l times")
598 })
599
600 it("generates --no-<flag> for boolean flags", () => {
601 const desc = fromCommand(simpleCmd)
602 const script = Fish.generate("greet", desc)
603 assert.include(script, "-l no-loud")
604 })
605
606 it("uses -r -F for file-type flags", () => {
607 const desc = fromCommand(withPaths)
608 const script = Fish.generate("process", desc)
609 assert.include(script, "-r -F")
610 })
611
612 it("uses -r -f -a for choice flags", () => {
613 const desc = fromCommand(withChoices)
614 const script = Fish.generate("deploy", desc)
615 assert.include(script, "-r -f -a 'dev staging prod'")
616 })
617
618 it("escapes choice values for both the string quoting and the expansion of the -a list", () => {
619 const script = Fish.generate("deploy", fromCommand(withTrickyChoices))
620 expect(linesWith(script, "-r -f -a")).toMatchInlineSnapshot(`
621 "complete -c deploy -n 'begin; not __fish_contains_opt mode; or contains -- (commandline -poc)[-1] --mode; end' -l mode -d 'Deploy mode' -r -f -a 'it\\\\\\'s-fine node\\\\:20 with\\\\ space \\\\(whoami\\\\) \\\\#tag \\\\$HOME back\\\\\\\\slash say\\\\"hi\\\\" a\\\\*b a\\\\;b \\\\~x foo\\\\\\' a\\\\!b \\\\🚀'
622 complete -c deploy -r -f -a 'o\\\\\\'clock a\\\\:b \\\\{x,y\\\\} a\\\\😀b' -d 'Deployment target'"
623 `)
624 })
625
626 it("escapes backslashes in descriptions before quotes", () => {
627 const trailingBackslash = Command.make("deploy", {
628 mode: Flag.Literals("mode", ["a"]).pipe(Flag.withDescription("Path like C:\\"))
629 })
630 const script = Fish.generate("deploy", fromCommand(trailingBackslash))
631 assert.include(script, `-d 'Path like C:\\\\'`)
632 })
633
634 it("uses -n conditions for nested subcommand flags", () => {
635 const desc = fromCommand(withSubcommands)
636 const script = Fish.generate("server", desc)
637 assert.include(script, "__fish_seen_subcommand_from start")
638 assert.include(script, "__fish_seen_subcommand_from stop")
639 })
640
641 it("includes descriptions with -d flag", () => {
642 const desc = fromCommand(withSubcommands)
643 const script = Fish.generate("server", desc)
644 assert.include(script, "-d 'Start the server'")
645 assert.include(script, "-d 'Stop the server'")
646 })
647
648 it("handles deeply nested command paths", () => {
649 const desc = fromCommand(nested3Levels)
650 const script = Fish.generate("top", desc)
651 assert.include(script, "__fish_seen_subcommand_from action")
652 assert.include(script, "-l dry-run")
653 })
654
655 it("handles commands with no flags", () => {
656 const desc = fromCommand(emptyCmd)
657 const script = Fish.generate("noop", desc)
658 assert.include(script, "###-begin-noop-completions-###")
659 assert.include(script, "###-end-noop-completions-###")
660 })
661
662 it("uses __fish_use_subcommand for root level", () => {
663 const desc = fromCommand(withSubcommands)
664 const script = Fish.generate("server", desc)
665 assert.include(script, "__fish_use_subcommand")
666 })
667
668 it("wraps script in begin/end markers", () => {
669 const desc = fromCommand(simpleCmd)
670 const script = Fish.generate("greet", desc)
671 assert.include(script, "###-begin-greet-completions-###")
672 assert.include(script, "###-end-greet-completions-###")
673 })
674
675 it("generates used-flag dedup conditions", () => {
676 const desc = fromCommand(simpleCmd)
677 const script = Fish.generate("greet", desc)
678 // --loud is boolean with alias -l — gets dedup condition on the -l entry
679 assert.include(script, "not __fish_contains_opt -s l loud no-loud")
680 const lines = script.split("\n")
681 const timesLongEntry = lines.find((l) => l.includes("-l times"))!
682 assert.include(
683 timesLongEntry,
684 "-n 'begin; not __fish_contains_opt times; or contains -- (commandline -poc)[-1] --times; end'"
685 )
686 const timesArgEntry = lines.find((l) => l.includes("-a '--times'"))!
687 assert.include(timesArgEntry, "not __fish_contains_opt times")
688 })
689
690 it("combines subcommand and dedup conditions", () => {
691 const desc = fromCommand(withSubcommands)
692 const script = Fish.generate("server", desc)
693 // daemon is boolean — gets subcommand + dedup condition on -l entry
694 assert.include(script, "__fish_seen_subcommand_from start; and not __fish_contains_opt daemon no-daemon")
695 const lines = script.split("\n")
696 const portLongEntry = lines.find((l) => l.includes("-l port"))!
697 assert.include(
698 portLongEntry,
699 "-n '__fish_seen_subcommand_from start; and begin; not __fish_contains_opt -s p port; or contains -- (commandline -poc)[-1] --port -p; end'"
700 )
701 const portArgEntry = lines.find((l) => l.includes("-a '--port'"))!
702 assert.include(portArgEntry, "not __fish_contains_opt -s p port")
703 })
704
705 it("root-level subcommands use __fish_use_subcommand without child guard", () => {
706 const desc = fromCommand(withSubcommands)
707 const script = Fish.generate("server", desc)
708 // Root-level subcommands only need __fish_use_subcommand (it already
709 // returns false once any subcommand is entered)
710 assert.include(script, "-n '__fish_use_subcommand' -f -a 'start'")
711 assert.include(script, "-n '__fish_use_subcommand' -f -a 'stop'")
712 })
713
714 it("guards nested child subcommands against re-offering", () => {
715 const desc = fromCommand(nested3Levels)
716 const script = Fish.generate("top", desc)
717 // sub's child "action" should be guarded
718 assert.include(
719 script,
720 "__fish_seen_subcommand_from sub; and not __fish_seen_subcommand_from action"
721 )
722 })
723
724 it("uses Disable description for boolean negation", () => {
725 const desc = fromCommand(simpleCmd)
726 const script = Fish.generate("greet", desc)
727 assert.include(script, "-l no-loud")
728 assert.include(script, "-d 'Disable loud'")
729 })
730
731 it("suppresses default file completion for commands without path arguments", () => {
732 const desc = fromCommand(withSubcommands)
733 const script = Fish.generate("server", desc)
734 // Root level: bare -f entry to suppress file listing
735 assert.include(script, "complete -c server -n '__fish_use_subcommand' -f")
736 // Leaf subcommand "start" has no path-type args — gets a bare -f entry
737 assert.include(script, "complete -c server -n '__fish_seen_subcommand_from start' -f")
738 })
739
740 it("adds -a entries for flags so they appear on bare TAB", () => {
741 const desc = fromCommand(simpleCmd)
742 const script = Fish.generate("greet", desc)
743 const lines = script.split("\n")
744 // --loud appears as an -a entry guarded by "not string match" and dedup
745 const loudArg = lines.find((l) => l.includes("-a '--loud'"))
746 assert.isDefined(loudArg)
747 assert.include(loudArg!, "not string match -q -- \"-*\" (commandline -ct)")
748 assert.include(loudArg!, "not __fish_contains_opt")
749 // --times also appears as an -a entry
750 const timesArg = lines.find((l) => l.includes("-a '--times'"))
751 assert.isDefined(timesArg)
752 assert.include(timesArg!, "not string match -q -- \"-*\" (commandline -ct)")
753 // Boolean negation also gets an -a entry
754 const noLoudArg = lines.find((l) => l.includes("-a '--no-loud'"))
755 assert.isDefined(noLoudArg)
756 })
757
758 it("bare-TAB -a entries use double quotes around glob pattern to avoid nested single-quote errors", () => {
759 const desc = fromCommand(simpleCmd)
760 const script = Fish.generate("greet", desc)
761 const lines = script.split("\n")
762 const argEntries = lines.filter((l) => /\s-a\s+'--/.test(l))
763 assert.isAbove(argEntries.length, 0)
764 for (const line of argEntries) {
765 // The -n condition must use double quotes around -* so it doesn't
766 // break the outer single-quoted string (Fish glob parse error).
767 assert.include(line, "\"-*\"", `bare-TAB entry should use double-quoted glob pattern: ${line}`)
768 assert.notInclude(line, "'-*'", `bare-TAB entry must NOT use single-quoted glob pattern: ${line}`)
769 }
770 })
771
772 it("does not suppress file completion for commands with path arguments", () => {
773 const desc = fromCommand(withPaths)
774 const script = Fish.generate("process", desc)
775 // "process" has a path-type positional arg — should NOT get a bare -f entry
776 const lines = script.split("\n")
777 const bareSuppression = lines.some((line) =>
778 // Match bare -f (file suppression) lines that don't have -l, -a, -r, -F
779 /^complete -c process( -n '[^']*')? -f$/.test(line)
780 )
781 assert.isFalse(bareSuppression, "Commands with path arguments should not suppress file completion")
782 })
783})
784
785// ---------------------------------------------------------------------------
786// Completions dispatcher
787// ---------------------------------------------------------------------------
788
789describe("Completions", () => {
790 it("dispatches to bash generator", () => {
791 const desc = fromCommand(simpleCmd)
792 const script = Completions.generate("greet", "bash", desc)
793 assert.include(script, "complete -F _greet greet")
794 })
795
796 it("dispatches to zsh generator", () => {
797 const desc = fromCommand(simpleCmd)
798 const script = Completions.generate("greet", "zsh", desc)
799 assert.include(script, "#compdef greet")
800 })
801
802 it("dispatches to fish generator", () => {
803 const desc = fromCommand(simpleCmd)
804 const script = Completions.generate("greet", "fish", desc)
805 assert.include(script, "complete -c greet")
806 })
807})
808
809// ---------------------------------------------------------------------------
810// Integration tests with ComprehensiveCli
811// ---------------------------------------------------------------------------
812
813describe("Completions integration", () => {
814 it("generates valid bash script for ComprehensiveCli", () => {
815 const desc = fromCommand(ComprehensiveCli)
816 const script = Bash.generate("mycli", desc)
817
818 // Root command
819 assert.include(script, "_mycli()")
820 assert.include(script, "complete -F _mycli mycli")
821
822 // Global flags
823 assert.include(script, "--debug")
824 assert.include(script, "-d")
825 assert.include(script, "--quiet")
826 assert.include(script, "-q")
827 assert.include(script, "--config")
828 assert.include(script, "--no-debug")
829 assert.include(script, "--no-quiet")
830
831 // Subcommands
832 assert.include(script, "_mycli_admin()")
833 assert.include(script, "_mycli_copy()")
834 assert.include(script, "_mycli_build()")
835 assert.include(script, "_mycli_git()")
836
837 // Nested subcommands
838 assert.include(script, "_mycli_admin_users()")
839 assert.include(script, "_mycli_admin_config()")
840 assert.include(script, "_mycli_git_clone()")
841 assert.include(script, "_mycli_git_add()")
842 assert.include(script, "_mycli_git_status()")
843
844 // Deeply nested
845 assert.include(script, "_mycli_admin_users_list()")
846 assert.include(script, "_mycli_admin_users_create()")
847 assert.include(script, "_mycli_admin_config_set()")
848 assert.include(script, "_mycli_admin_config_get()")
849
850 // File completion for copy command
851 assert.include(script, "compgen -f")
852 })
853
854 it("generates valid zsh script for ComprehensiveCli", () => {
855 const desc = fromCommand(ComprehensiveCli)
856 const script = Zsh.generate("mycli", desc)
857
858 // Zsh directives
859 assert.include(script, "#compdef mycli")
860 assert.include(script, "_arguments")
861 assert.include(script, "_describe")
862
863 // Root function
864 assert.include(script, "_mycli()")
865
866 // Global flags with descriptions
867 assert.include(script, "--debug")
868 assert.include(script, "Enable debug logging")
869 assert.include(script, "--quiet")
870
871 // Subcommand functions
872 assert.include(script, "_mycli_admin()")
873 assert.include(script, "_mycli_copy()")
874 assert.include(script, "_mycli_build()")
875 assert.include(script, "_mycli_git()")
876
877 // Nested subcommand functions
878 assert.include(script, "_mycli_admin_users()")
879 assert.include(script, "_mycli_git_clone()")
880
881 // File/directory completions
882 assert.include(script, "_files")
883 })
884
885 it("generates valid fish script for ComprehensiveCli", () => {
886 const desc = fromCommand(ComprehensiveCli)
887 const script = Fish.generate("mycli", desc)
888
889 // Fish complete commands
890 assert.include(script, "complete -c mycli")
891
892 // Root subcommands
893 assert.include(script, "-a 'admin'")
894 assert.include(script, "-a 'copy'")
895 assert.include(script, "-a 'build'")
896 assert.include(script, "-a 'git'")
897
898 // Root flags
899 assert.include(script, "-l debug")
900 assert.include(script, "-s d")
901 assert.include(script, "-l quiet")
902 assert.include(script, "-s q")
903 assert.include(script, "-l no-debug")
904 assert.include(script, "-l no-quiet")
905
906 // Descriptions
907 assert.include(script, "-d 'Administrative commands'")
908 assert.include(script, "-d 'Build the project'")
909
910 // Nested subcommand conditions
911 assert.include(script, "__fish_use_subcommand")
912 assert.include(script, "__fish_seen_subcommand_from admin")
913 assert.include(script, "__fish_seen_subcommand_from git")
914 })
915})