@oclif/plugin-autocomplete
Version:
autocomplete plugin for oclif
344 lines (332 loc) • 12.5 kB
JavaScript
import * as ejs from 'ejs';
import * as util from 'node:util';
const argTemplate = ' "%s")\n %s\n ;;\n';
export default class ZshCompWithSpaces {
config;
_coTopics;
commands;
topics;
constructor(config) {
this.config = config;
this.topics = this.getTopics();
this.commands = this.getCommands();
}
generate() {
const firstArgs = [];
for (const t of this.topics) {
if (!t.name.includes(':'))
firstArgs.push({
id: t.name,
summary: t.description,
});
}
for (const c of this.commands) {
if (!firstArgs.some((a) => a.id === c.id) && !c.id.includes(':'))
firstArgs.push({
id: c.id,
summary: c.summary,
});
}
const mainArgsCaseBlock = () => {
let caseBlock = 'case $line[1] in\n';
for (const arg of firstArgs) {
if (this.coTopics.includes(arg.id)) {
// coTopics already have a completion function.
caseBlock += `${arg.id})\n _${this.config.bin}_${arg.id}\n ;;\n`;
}
else {
const cmd = this.commands.find((c) => c.id === arg.id);
if (cmd) {
// if it's a command and has flags, inline flag completion statement.
// skip it from the args statement if it doesn't accept any flag.
if (Object.keys(cmd.flags).length > 0) {
caseBlock += `${arg.id})\n${this.genZshFlagArgumentsBlock(cmd.flags)} ;; \n`;
}
}
else {
// it's a topic, redirect to its completion function.
caseBlock += `${arg.id})\n _${this.config.bin}_${arg.id}\n ;;\n`;
}
}
}
caseBlock += 'esac\n';
return caseBlock;
};
return `#compdef ${this.config.bin}
${this.config.binAliases?.map((a) => `compdef ${a}=${this.config.bin}`).join('\n') ?? ''}
${this.topics.map((t) => this.genZshTopicCompFun(t.name)).join('\n')}
_${this.config.bin}() {
local context state state_descr line
typeset -A opt_args
_arguments -C "1: :->cmds" "*::arg:->args"
case "$state" in
cmds)
${this.genZshValuesBlock(firstArgs)}
;;
args)
${mainArgsCaseBlock()}
;;
esac
}
_${this.config.bin}
`;
}
get coTopics() {
if (this._coTopics)
return this._coTopics;
const coTopics = [];
for (const topic of this.topics) {
for (const cmd of this.commands) {
if (topic.name === cmd.id) {
coTopics.push(topic.name);
}
}
}
this._coTopics = coTopics;
return this._coTopics;
}
genZshFlagArgumentsBlock(flags) {
// if a command doesn't have flags make it only complete files
// also add comp for the global `--help` flag.
if (!flags)
return '_arguments -S \\\n --help"[Show help for command]" "*: :_files';
const flagNames = Object.keys(flags);
// `-S`:
// Do not complete flags after a ‘--’ appearing on the line, and ignore the ‘--’. For example, with -S, in the line:
// foobar -x -- -y
// the ‘-x’ is considered a flag, the ‘-y’ is considered an argument, and the ‘--’ is considered to be neither.
let argumentsBlock = '_arguments -S \\\n';
for (const flagName of flagNames) {
const f = flags[flagName];
// skip hidden flags
if (f.hidden)
continue;
const flagSummary = this.sanitizeSummary(f.summary ?? f.description);
let flagSpec = '';
if (f.type === 'option') {
if (f.char) {
// eslint-disable-next-line unicorn/prefer-ternary
if (f.multiple) {
// this flag can be present multiple times on the line
flagSpec += `"*"{-${f.char},--${f.name}}`;
}
else {
flagSpec += `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}`;
}
flagSpec += `"[${flagSummary}]`;
flagSpec += f.options ? `:${f.name} options:(${f.options?.join(' ')})"` : ':file:_files"';
}
else {
if (f.multiple) {
// this flag can be present multiple times on the line
flagSpec += '"*"';
}
flagSpec += `--${f.name}"[${flagSummary}]:`;
flagSpec += f.options ? `${f.name} options:(${f.options.join(' ')})"` : 'file:_files"';
}
}
else if (f.char) {
// Flag.Boolean
flagSpec += `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}"[${flagSummary}]"`;
}
else {
// Flag.Boolean
flagSpec += `--${f.name}"[${flagSummary}]"`;
}
flagSpec += ' \\\n';
argumentsBlock += flagSpec;
}
// add global `--help` flag
argumentsBlock += '--help"[Show help for command]" \\\n';
// complete files if `-` is not present on the current line
argumentsBlock += '"*: :_files"';
return argumentsBlock;
}
genZshTopicCompFun(id) {
const coTopics = [];
for (const topic of this.topics) {
for (const cmd of this.commands) {
if (topic.name === cmd.id) {
coTopics.push(topic.name);
}
}
}
const flagArgsTemplate = ' "%s")\n %s\n ;;\n';
const underscoreSepId = id.replaceAll(':', '_');
const depth = id.split(':').length;
const isCotopic = coTopics.includes(id);
if (isCotopic) {
const compFuncName = `${this.config.bin}_${underscoreSepId}`;
const coTopicCompFunc = `_${compFuncName}() {
_${compFuncName}_flags() {
local context state state_descr line
typeset -A opt_args
${this.genZshFlagArgumentsBlock(this.commands.find((c) => c.id === id)?.flags)}
}
local context state state_descr line
typeset -A opt_args
_arguments -C "1: :->cmds" "*: :->args"
case "$state" in
cmds)
if [[ "\${words[CURRENT]}" == -* ]]; then
_${compFuncName}_flags
else
%s
fi
;;
args)
case $line[1] in
%s
*)
_${compFuncName}_flags
;;
esac
;;
esac
}
`;
const subArgs = [];
let argsBlock = '';
for (const t of this.topics.filter((t) => t.name.startsWith(id + ':') && t.name.split(':').length === depth + 1)) {
const subArg = t.name.split(':')[depth];
subArgs.push({
id: subArg,
summary: t.description,
});
argsBlock += util.format(argTemplate, subArg, `_${this.config.bin}_${underscoreSepId}_${subArg}`);
}
for (const c of this.commands.filter((c) => c.id.startsWith(id + ':') && c.id.split(':').length === depth + 1)) {
if (coTopics.includes(c.id))
continue;
const subArg = c.id.split(':')[depth];
subArgs.push({
id: subArg,
summary: c.summary,
});
argsBlock += util.format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags));
}
return util.format(coTopicCompFunc, this.genZshValuesBlock(subArgs), argsBlock);
}
let argsBlock = '';
const subArgs = [];
for (const t of this.topics.filter((t) => t.name.startsWith(id + ':') && t.name.split(':').length === depth + 1)) {
const subArg = t.name.split(':')[depth];
subArgs.push({
id: subArg,
summary: t.description,
});
argsBlock += util.format(argTemplate, subArg, `_${this.config.bin}_${underscoreSepId}_${subArg}`);
}
for (const c of this.commands.filter((c) => c.id.startsWith(id + ':') && c.id.split(':').length === depth + 1)) {
if (coTopics.includes(c.id))
continue;
const subArg = c.id.split(':')[depth];
subArgs.push({
id: subArg,
summary: c.summary,
});
argsBlock += util.format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags));
}
const topicCompFunc = `_${this.config.bin}_${underscoreSepId}() {
local context state state_descr line
typeset -A opt_args
_arguments -C "1: :->cmds" "*::arg:->args"
case "$state" in
cmds)
%s
;;
args)
case $line[1] in
%s
esac
;;
esac
}
`;
return util.format(topicCompFunc, this.genZshValuesBlock(subArgs), argsBlock);
}
genZshValuesBlock(subArgs) {
let valuesBlock = '_values "completions" \\\n';
for (const subArg of subArgs) {
valuesBlock += `"${subArg.id}[${subArg.summary}]" \\\n`;
}
return valuesBlock;
}
getCommands() {
const cmds = [];
for (const p of this.config.getPluginsList()) {
for (const c of p.commands) {
if (c.hidden)
continue;
const summary = this.sanitizeSummary(c.summary ?? c.description);
const { flags } = c;
cmds.push({
flags,
id: c.id,
summary,
});
for (const a of c.aliases) {
cmds.push({
flags,
id: a,
summary,
});
const split = a.split(':');
let topic = split[0];
// Completion funcs are generated from topics:
// `force` -> `force:org` -> `force:org:open|list`
//
// but aliases aren't guaranteed to follow the plugin command tree
// so we need to add any missing topic between the starting point and the alias.
for (let i = 0; i < split.length - 1; i++) {
if (!this.topics.some((t) => t.name === topic)) {
this.topics.push({
description: `${topic.replaceAll(':', ' ')} commands`,
name: topic,
});
}
topic += `:${split[i + 1]}`;
}
}
}
}
return cmds;
}
getTopics() {
const topics = this.config.topics
.filter((topic) => {
// it is assumed a topic has a child if it has children
const hasChild = this.config.topics.some((subTopic) => subTopic.name.includes(`${topic.name}:`));
return hasChild;
})
.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
})
.map((t) => {
const description = t.description
? this.sanitizeSummary(t.description)
: `${t.name.replaceAll(':', ' ')} commands`;
return {
description,
name: t.name,
};
});
return topics;
}
sanitizeSummary(summary) {
if (summary === undefined) {
return '';
}
return ejs
.render(summary, { config: this.config })
.replaceAll(/(["`])/g, '\\\\\\$1') // backticks and double-quotes require triple-backslashes
.replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes
.split('\n')[0]; // only use the first line
}
}