Skip to content

Commit 400a144

Browse files
committed
feat: Add fish shell completion generation
This commit introduces the `gen-completions` command to generate shell completion scripts, with initial support for the `fish` shell. Key changes include: - A new `GenCompletionsCommand` class and its integration into the parsing logic in `src/index.ts`, enabling the `gen-completions` subcommand. - Updates to `src/metadata.ts` to refine argument collection, ensuring compatibility with dynamically generated command structures. - Comprehensive documentation in `README.md` detailing how to generate and install `fish` shell completions for the application. This feature enhances usability by providing command-line auto-completion capabilities.
1 parent 623f934 commit 400a144

5 files changed

Lines changed: 225 additions & 17 deletions

File tree

README.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,8 +557,37 @@ Options:
557557
Show this help message
558558

559559
Commands:
560-
serve Start development server
561-
build Build the project
560+
serve Start development server
561+
build Start the project
562+
gen-completions Generate shell completions
563+
```
564+
565+
## Shell Completions
566+
567+
The library includes a built-in `gen-completions` command that generates
568+
completion scripts for various shells (currently supporting `fish`).
569+
570+
### Generating Completions
571+
572+
To generate completions for your application:
573+
574+
```bash
575+
# Generate fish completions
576+
deno run myapp.ts gen-completions fish
577+
```
578+
579+
### Installing Completions (Fish)
580+
581+
To use the completions immediately in your current fish session:
582+
583+
```fish
584+
deno run myapp.ts gen-completions fish | source
585+
```
586+
587+
To make them permanent, save the output to your fish configuration directory:
588+
589+
```fish
590+
deno run myapp.ts gen-completions fish > ~/.config/fish/completions/myapp.fish
562591
```
563592

564593
## Error Handling

src/completions.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { ArgumentDef, ParsedArg, SubCommand } from "./types.ts";
2+
import { collectInstanceArgumentDefs } from "./metadata.ts";
3+
4+
/**
5+
* Generates fish shell completions for a command.
6+
*/
7+
export function generateFishCompletions(
8+
appName: string,
9+
parsedArgs: ParsedArg[],
10+
_argumentDefs: ArgumentDef[],
11+
subCommands?: Map<string, SubCommand>,
12+
): string {
13+
const lines: string[] = [];
14+
15+
// Disable file completion for the command by default
16+
lines.push(`complete -c ${appName} -f`);
17+
18+
// Add completions for global options
19+
// These should be available when NO subcommand has been selected yet
20+
for (const arg of parsedArgs) {
21+
const shortFlag = arg.short ? ` -s ${arg.short}` : "";
22+
const desc = arg.description ? ` -d "${arg.description}"` : "";
23+
lines.push(
24+
`complete -c ${appName} -n "__fish_use_subcommand" -l ${arg.name}${shortFlag}${desc}`,
25+
);
26+
}
27+
28+
// Add completions for subcommands
29+
if (subCommands && subCommands.size > 0) {
30+
for (const [name, subCommand] of subCommands) {
31+
const desc = subCommand.description
32+
? ` -d "${subCommand.description}"`
33+
: "";
34+
// Only suggest subcommand if we haven't seen one yet
35+
lines.push(
36+
`complete -c ${appName} -n "__fish_use_subcommand" -a "${name}"${desc}`,
37+
);
38+
39+
// Generate completions for the subcommand
40+
// We need to instantiate it to get its arguments
41+
const instance = new subCommand.commandClass() as Record<string, unknown>;
42+
const { parsedArgs: subParsedArgs } = collectInstanceArgumentDefs(
43+
instance,
44+
{ strict: false },
45+
);
46+
47+
// For subcommand options, we check if the subcommand is present in the command line
48+
for (const arg of subParsedArgs) {
49+
const shortFlag = arg.short ? ` -s ${arg.short}` : "";
50+
const desc = arg.description ? ` -d "${arg.description}"` : "";
51+
// Condition: __fish_seen_subcommand_from <subcommand>
52+
lines.push(
53+
`complete -c ${appName} -n "__fish_seen_subcommand_from ${name}" -l ${arg.name}${shortFlag}${desc}`,
54+
);
55+
}
56+
}
57+
}
58+
59+
return lines.join("\n");
60+
}

src/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import type { ParseOptions, ParseResult, SupportedType } from "./types.ts";
22
import { printHelp } from "./help.ts";
33
import { handleHelpDisplay, handleParsingError } from "./error-handling.ts";
4+
import {
5+
argument,
6+
command,
7+
description,
8+
required,
9+
type,
10+
validate,
11+
} from "./decorators.ts";
412
import { collectInstanceArgumentDefs } from "./metadata.ts";
13+
import { generateFishCompletions } from "./completions.ts";
514

615
/**
716
* Base class for CLI argument classes.
@@ -38,6 +47,22 @@ export class Args {
3847
}
3948
}
4049

50+
/**
51+
* Internal command for generating shell completions.
52+
*/
53+
@command
54+
class GenCompletionsCommand {
55+
@description("The shell to generate completions for (e.g., 'fish')")
56+
@type("string")
57+
@argument()
58+
@validate(
59+
(value: string) => value === "fish",
60+
"must be one of: fish (currently only fish is supported)",
61+
)
62+
@required()
63+
shell!: string;
64+
}
65+
4166
/**
4267
* CLI decorator to configure a class for command line parsing.
4368
*
@@ -268,6 +293,15 @@ function parseInstanceBased(
268293
// Collect subcommands from instance
269294
const subCommands = collectSubCommandsFromInstance(instance);
270295

296+
// Inject gen-completions command if not already present and we are at the root
297+
if (!commandPath && !subCommands.has("gen-completions")) {
298+
subCommands.set("gen-completions", {
299+
name: "gen-completions",
300+
commandClass: GenCompletionsCommand,
301+
description: "Generate shell completions",
302+
});
303+
}
304+
271305
// Create argument map for parsing
272306
const argMap = new Map<string, {
273307
name: string;
@@ -768,6 +802,22 @@ function parseInstanceBased(
768802
Object.assign(typedResult as Record<string, unknown>, parsedValues);
769803
result[arg] = typedResult;
770804
}
805+
806+
// Handle gen-completions execution
807+
if (
808+
arg === "gen-completions" &&
809+
subCommand.commandClass === GenCompletionsCommand
810+
) {
811+
const completions = generateFishCompletions(
812+
options?.name || "app",
813+
parsedArgs,
814+
argumentDefs,
815+
subCommands,
816+
);
817+
console.log(completions);
818+
Deno.exit(0);
819+
}
820+
771821
break; // Stop processing after subcommand
772822
} else {
773823
// Handle positional argument

src/metadata.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ function isUserDefinedProperty(descriptor: PropertyDescriptor): boolean {
4545
return descriptor.writable === true && descriptor.enumerable === true;
4646
}
4747

48+
interface CollectionOptions {
49+
strict?: boolean;
50+
}
51+
4852
/**
4953
* Collects argument definitions from an instance.
5054
*
@@ -53,10 +57,12 @@ function isUserDefinedProperty(descriptor: PropertyDescriptor): boolean {
5357
* duplicate short flags.
5458
*
5559
* @param instance - The class instance to analyze
60+
* @param options - Configuration options for metadata collection
5661
* @returns Object containing parsed options and positional argument definitions
5762
*/
5863
export function collectInstanceArgumentDefs(
5964
instance: Record<string, unknown>,
65+
options: CollectionOptions = { strict: true },
6066
): {
6167
parsedArgs: ParsedArg[];
6268
argumentDefs: ArgumentDef[];
@@ -98,11 +104,16 @@ export function collectInstanceArgumentDefs(
98104
if (propertyMetadata?.argument) {
99105
// Positional argument
100106
if (instance[propName] === undefined && !propertyMetadata.type) {
101-
throw new Error(
102-
`Property '${propName}' has no default value and no @type() decorator. ` +
103-
`Use @type("string"), @type("number"), etc. to specify the expected type. ` +
104-
`This is required because TypeScript cannot infer the type from undefined values.`,
105-
);
107+
if (options.strict) {
108+
throw new Error(
109+
`Property '${propName}' has no default value and no @type() decorator. ` +
110+
`Use @type("string"), @type("number"), etc. to specify the expected type. ` +
111+
`This is required because TypeScript cannot infer the type from undefined values.`,
112+
);
113+
} else {
114+
// In non-strict mode, skip properties with undetermined types
115+
continue;
116+
}
106117
}
107118

108119
argumentDefs.push({
@@ -116,11 +127,16 @@ export function collectInstanceArgumentDefs(
116127
} else if (propertyMetadata?.rawRest) {
117128
// Raw rest argument
118129
if (instance[propName] === undefined && !propertyMetadata.type) {
119-
throw new Error(
120-
`Property '${propName}' has no default value and no @type() decorator. ` +
121-
`Use @type("string[]") or another array type to specify the expected type. ` +
122-
`This is required because TypeScript cannot infer the type from undefined values.`,
123-
);
130+
if (options.strict) {
131+
throw new Error(
132+
`Property '${propName}' has no default value and no @type() decorator. ` +
133+
`Use @type("string[]") or another array type to specify the expected type. ` +
134+
`This is required because TypeScript cannot infer the type from undefined values.`,
135+
);
136+
} else {
137+
// In non-strict mode, skip properties with undetermined types
138+
continue;
139+
}
124140
}
125141

126142
argumentDefs.push({
@@ -134,11 +150,16 @@ export function collectInstanceArgumentDefs(
134150
} else {
135151
// Regular option
136152
if (instance[propName] === undefined && !propertyMetadata?.type) {
137-
throw new Error(
138-
`Property '${propName}' has no default value and no @type() decorator. ` +
139-
`Use @type("string"), @type("number"), etc. to specify the expected type. ` +
140-
`This is required because TypeScript cannot infer the type from undefined values.`,
141-
);
153+
if (options.strict) {
154+
throw new Error(
155+
`Property '${propName}' has no default value and no @type() decorator. ` +
156+
`Use @type("string"), @type("number"), etc. to specify the expected type. ` +
157+
`This is required because TypeScript cannot infer the type from undefined values.`,
158+
);
159+
} else {
160+
// In non-strict mode, skip properties with undetermined types
161+
continue;
162+
}
142163
}
143164

144165
parsedArgs.push({

tests/completions.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { assertEquals, assertStringIncludes } from "@std/assert";
2+
3+
Deno.test("gen-completions fish", async () => {
4+
const command = new Deno.Command("deno", {
5+
args: ["run", "examples/example.ts", "gen-completions", "fish"],
6+
stdout: "piped",
7+
stderr: "piped",
8+
});
9+
10+
const { code, stdout, stderr } = await command.output();
11+
const output = new TextDecoder().decode(stdout);
12+
const error = new TextDecoder().decode(stderr);
13+
14+
assertEquals(code, 0, `Command failed with code ${code}. Stderr: ${error}`);
15+
16+
// Check for some expected fish completion lines
17+
assertStringIncludes(output, "complete -c myapp -f");
18+
assertStringIncludes(
19+
output,
20+
'complete -c myapp -n "__fish_use_subcommand" -a "serve" -d "Start the development server"',
21+
);
22+
assertStringIncludes(
23+
output,
24+
'complete -c myapp -n "__fish_seen_subcommand_from serve" -l port',
25+
);
26+
assertStringIncludes(
27+
output,
28+
'complete -c myapp -n "__fish_use_subcommand" -l verbose',
29+
);
30+
});
31+
32+
Deno.test("gen-completions missing shell arg", async () => {
33+
const command = new Deno.Command("deno", {
34+
args: ["run", "examples/example.ts", "gen-completions"],
35+
stdout: "piped",
36+
stderr: "piped",
37+
});
38+
39+
const { code, stderr } = await command.output();
40+
const error = new TextDecoder().decode(stderr);
41+
42+
// Should fail because shell is required
43+
assertEquals(code, 1);
44+
assertStringIncludes(
45+
error,
46+
"Validation error for argument 'shell': is required",
47+
);
48+
});

0 commit comments

Comments
 (0)