Skip to content

Commit

Permalink
feat(command): add shell completion support for fish shell (#91)
Browse files Browse the repository at this point in the history
  • Loading branch information
c4spar committed Oct 4, 2020
1 parent 2062989 commit 7e94214
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 24 deletions.
60 changes: 37 additions & 23 deletions command/completions/complete.ts
Expand Up @@ -9,31 +9,45 @@ export class CompleteCommand extends Command {
super();
this.description("Get completions for given action from given command.")
.arguments("<action:string> [command...:string]")
.action(async (_, action: string, commandNames: string[]) => {
let parent: Command | undefined;
let completeCommand: Command = commandNames
.reduce((cmd: Command, name: string): Command => {
parent = cmd;
const childCmd: Command | undefined = cmd.getCommand(name, false);
if (!childCmd) {
throw new Error(
`Auto-completion failed. Command not found: ${
commandNames.join(" ")
}`,
);
}
return childCmd;
}, cmd || this.getMainCommand());
.option("-l, --list", "Use line break as value separator.")
.action(
async (
{ list }: { list?: boolean },
// deno-lint-ignore no-undef
action: string,
// deno-lint-ignore no-undef
commandNames: string[],
) => {
let parent: Command | undefined;
// deno-lint-ignore no-undef
let completeCommand: Command = commandNames
.reduce((cmd: Command, name: string): Command => {
parent = cmd;
const childCmd: Command | undefined = cmd.getCommand(name, false);
if (!childCmd) {
throw new Error(
`Auto-completion failed. Command not found: ${
// deno-lint-ignore no-undef
commandNames.join(" ")
}`,
);
}
return childCmd;
}, cmd || this.getMainCommand());

const completion: ICompletion | undefined = completeCommand
.getCompletion(action);
const result: string[] =
await completion?.complete(completeCommand, parent) ?? [];
const completion: ICompletion | undefined = completeCommand
// deno-lint-ignore no-undef
.getCompletion(action);
const result: string[] =
await completion?.complete(completeCommand, parent) ?? [];

if (result?.length) {
Deno.stdout.writeSync(new TextEncoder().encode(result.join(" ")));
}
})
if (result?.length) {
Deno.stdout.writeSync(new TextEncoder().encode(result.join(
list ? "\n" : " ",
)));
}
},
)
.reset();
}
}
165 changes: 165 additions & 0 deletions command/completions/fish-completions-generator.ts
@@ -0,0 +1,165 @@
import type { Command } from "../command.ts";
import type { IOption } from "../types.ts";

interface CompleteOptions {
description?: string;
shortOption?: string;
longOption?: string;
required?: boolean;
standalone?: boolean;
arguments?: string;
}

/** Fish completions generator. */
export class FishCompletionsGenerator {
/** Generates fish completions script for given command. */
public static generate(cmd: Command) {
return new FishCompletionsGenerator(cmd).generate();
}

private constructor(protected cmd: Command) {}

/** Generates fish completions script. */
private generate(): string {
const path = this.cmd.getPath();
const version: string | undefined = this.cmd.getVersion()
? ` v${this.cmd.getVersion()}`
: "";

return `#!/usr/bin/env fish
# fish completion support for ${path}${version}
function __fish_${replaceSpecialChars(this.cmd.getName())}_using_command
set cmds ${getCommandFnNames(this.cmd).join(" ")}
set words (commandline -opc)
set cmd "_"
for word in $words
switch $word
case '-*'
continue
case '*'
set word (string replace -r -a '\\W' '_' $word)
set cmd_tmp $cmd"_$word"
if contains $cmd_tmp $cmds
set cmd $cmd_tmp
end
end
end
if [ "$cmd" = "$argv[1]" ]
return 0
end
return 1
end
${this.generateCompletions(this.cmd).trim()}
`;
}

private generateCompletions(command: Command): string {
const parent: Command | undefined = command.getParent();
let result = ``;

if (parent) {
// command
result += "\n" + this.complete(parent, {
description: command.getShortDescription(),
arguments: command.getName(),
});
}

// arguments
const commandArgs = command.getArguments();
if (commandArgs.length) {
result += "\n" + this.complete(command, {
arguments: commandArgs.length
? this.getCompletionCommand(
commandArgs[0].action + " " + getCompletionsPath(command),
)
: undefined,
});
}

// options
for (const option of command.getOptions(false)) {
result += "\n" + this.completeOption(command, option);
}

for (const subCommand of command.getCommands(false)) {
result += this.generateCompletions(subCommand);
}

return result;
}

private completeOption(command: Command, option: IOption) {
const flags = option.flags.split(/[, ] */g);
const shortOption: string | undefined = flags
.find((flag) => flag.length === 2)
?.replace(/^(-)+/, "");
const longOption: string | undefined = flags
.find((flag) => flag.length > 2)
?.replace(/^(-)+/, "");

return this.complete(command, {
description: option.description,
shortOption: shortOption,
longOption: longOption,
// required: option.requiredValue,
required: true,
standalone: option.standalone,
arguments: option.args.length
? this.getCompletionCommand(
option.args[0].action + " " + getCompletionsPath(command),
)
: undefined,
});
}

private complete(command: Command, options: CompleteOptions) {
const cmd = ["complete"];
cmd.push("-c", this.cmd.getName());
cmd.push(
"-n",
`'__fish_${replaceSpecialChars(this.cmd.getName())}_using_command __${
replaceSpecialChars(command.getPath())
}'`,
);
options.shortOption && cmd.push("-s", options.shortOption);
options.longOption && cmd.push("-l", options.longOption);
options.standalone && cmd.push("-x");
cmd.push("-k");
cmd.push("-f");
if (options.arguments) {
options.required && cmd.push("-r");
cmd.push("-a", options.arguments);
}
options.description && cmd.push("-d", `'${options.description}'`);
return cmd.join(" ");
}

private getCompletionCommand(cmd: string): string {
return `'(${this.cmd.getName()} completions complete -l ${cmd.trim()})'`;
}
}

function getCommandFnNames(
cmd: Command,
cmds: Array<string> = [],
): Array<string> {
cmds.push(`__${replaceSpecialChars(cmd.getPath())}`);
cmd.getCommands(false).forEach((command) => {
getCommandFnNames(command, cmds);
});
return cmds;
}

function getCompletionsPath(command: Command): string {
return command.getPath()
.split(" ")
.slice(1)
.join(" ");
}

function replaceSpecialChars(str: string): string {
return str.replace(/[^a-zA-Z0-9]/g, "_");
}
24 changes: 24 additions & 0 deletions command/completions/fish.ts
@@ -0,0 +1,24 @@
import { Command } from "../command.ts";
import { dim, italic } from "../deps.ts";
import { FishCompletionsGenerator } from "./fish-completions-generator.ts";

/**
* Generate fish completion script.
*/
export class FishCompletionsCommand extends Command {
public constructor(cmd?: Command) {
super();
this.description(() => {
cmd = cmd || this.getMainCommand();
return `Generate shell completions for fish.
${dim(italic(`${cmd.getPath()} completions fish > ${cmd.getPath()}.fish`))}
${dim(italic(`source ${cmd.getPath()}.fish`))}`;
})
.action(() => {
Deno.stdout.writeSync(new TextEncoder().encode(
FishCompletionsGenerator.generate(cmd || this.getMainCommand()),
));
});
}
}
4 changes: 3 additions & 1 deletion command/completions/mod.ts
Expand Up @@ -2,6 +2,7 @@ import { Command } from "../command.ts";
import { dim, italic } from "../deps.ts";
import { BashCompletionsCommand } from "./bash.ts";
import { CompleteCommand } from "./complete.ts";
import { FishCompletionsCommand } from "./fish.ts";
import { ZshCompletionsCommand } from "./zsh.ts";

/**
Expand All @@ -28,8 +29,9 @@ To enable shell completions for this program add following line to your ${
`;
})
.action(() => this.help())
.command("zsh", new ZshCompletionsCommand(cmd))
.command("bash", new BashCompletionsCommand(cmd))
.command("fish", new FishCompletionsCommand(cmd))
.command("zsh", new ZshCompletionsCommand(cmd))
.command("complete", new CompleteCommand(cmd).hidden())
.reset();
}
Expand Down

0 comments on commit 7e94214

Please sign in to comment.