diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 814b4d81..a0b362dd 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -5,15 +5,15 @@ function sanitizeSummary(description?: string): string { if (description === undefined) { return '' } - return description - .replace(/([`"])/g, '\\\\\\$1') // backticks and double-quotes require triple-backslashes - // eslint-disable-next-line no-useless-escape - .replace(/([\[\]])/g, '\\\\$1') // square brackets require double-backslashes - .split('\n')[0] // only use the first line + return ( + description + .replace(/([`"])/g, '\\\\\\$1') // backticks and double-quotes require triple-backslashes + // eslint-disable-next-line no-useless-escape + .replace(/([\[\]])/g, '\\\\$1') // square brackets require double-backslashes + .split('\n')[0] + ) // only use the first line } -const argTemplate = ' "%s")\n %s\n ;;\n' - type CommandCompletion = { id: string; summary: string; @@ -30,43 +30,46 @@ type Topic = { } export default class ZshCompWithSpaces { - protected config: Config; + protected config: Config private topics: Topic[] private commands: CommandCompletion[] - private _coTopics?: string[] + private coTopics: string[] constructor(config: Config) { this.config = config this.topics = this.getTopics() this.commands = this.getCommands() + this.coTopics = this.getCoTopics() } public generate(): string { - const firstArgs: {id: string; summary?: string}[] = [] + const firstArgs: Array<{id: string; summary?: string}> = [] this.topics.forEach(t => { - if (!t.name.includes(':')) firstArgs.push({ - id: t.name, - summary: t.description, - }) + if (!t.name.includes(':')) + firstArgs.push({ + id: t.name, + summary: t.description, + }) }) this.commands.forEach(c => { - if (!firstArgs.find(a => a.id === c.id) && !c.id.includes(':')) firstArgs.push({ - id: c.id, - summary: c.summary, - }) + if (!firstArgs.find(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' + let caseBlock = '' 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` + caseBlock += `\n ${arg.id})\n _arguments -C "*::arg:->args"\n _${this.config.bin}_${arg.id}\n ;;` } else { const cmd = this.commands.find(c => c.id === arg.id) @@ -74,58 +77,61 @@ export default class ZshCompWithSpaces { // 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` + caseBlock += `\n ${arg.id})\n _arguments -C "*::arg:->args"\n ${this.genZshFlagArgumentsBlock(cmd.flags)} ;;` } } else { // it's a topic, redirect to its completion function. - caseBlock += `${arg.id})\n _${this.config.bin}_${arg.id}\n ;;\n` + caseBlock += `\n ${arg.id})\n _arguments -C "*::arg:->args"\n _${this.config.bin}_${arg.id}\n ;;` } } } - caseBlock += 'esac\n' - return caseBlock } - const compFunc = -`#compdef ${this.config.bin} + let flags = '\n --help"[Show help]" \\' + flags += '\n --version"[Show version]"\n ' -${this.topics.map(t => this.genZshTopicCompFun(t.name)).join('\n')} + const compFunc = `#compdef ${this.config.bin} +${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" + local -a flags=(%s) + + _arguments -C "1: :->cmds" "*: :->args" case "$state" in cmds) - ${this.genZshValuesBlock(firstArgs)} - ;; + %s \\ + "\${flags[@]}" + ;; args) - ${mainArgsCaseBlock()} - ;; + case $line[1] in%s + *) + _arguments -S \\ + "\${flags[@]}" + ;; + esac + ;; esac } _${this.config.bin} ` - return compFunc + return util.format(compFunc, flags, this.genZshValuesBlock(firstArgs), mainArgsCaseBlock()) } - private genZshFlagArgumentsBlock(flags?: CommandFlags): string { + private genZshFlagArguments(flags?: CommandFlags): string[] { // 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' + if (!flags) return ['--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' + const argumentsArray: string[] = [] for (const flagName of flagNames) { const f = flags[flagName] @@ -145,27 +151,11 @@ _${this.config.bin} } else { flagSpec += `"(-${f.char} --${f.name})"{-${f.char},--${f.name}}` } - - flagSpec += `"[${f.summary}]` - - if (f.options) { - flagSpec += `:${f.name} options:(${f.options?.join(' ')})"` - } else { - flagSpec += ':file:_files"' - } + } else if (f.multiple) { + // this flag can be present multiple times on the line + flagSpec += `"*"--${f.name}` } else { - if (f.multiple) { - // this flag can be present multiple times on the line - flagSpec += '"*"' - } - - flagSpec += `--${f.name}"[${f.summary}]:` - - if (f.options) { - flagSpec += `${f.name} options:(${f.options.join(' ')})"` - } else { - flagSpec += 'file:_files"' - } + flagSpec += `--${f.name}` } } else if (f.char) { // Flag.Boolean @@ -175,117 +165,65 @@ _${this.config.bin} flagSpec += `--${f.name}"[${f.summary}]"` } - flagSpec += ' \\\n' - argumentsBlock += flagSpec + if (f.type === 'option') { + flagSpec += `"[${f.summary}]` + + if (f.options) { + flagSpec += `:${f.name} options:(${f.options?.join(' ')})"` + } else { + flagSpec += ':file:_files"' + } + } + + flagSpec += ' \\' + argumentsArray.push(flagSpec) } // add global `--help` flag - argumentsBlock += '--help"[Show help for command]" \\\n' + argumentsArray.push('--help"[Show help for command]" \\') // complete files if `-` is not present on the current line - argumentsBlock += '"*: :_files"' + argumentsArray.push('"*: :_files"') + + return argumentsArray + } + + private genZshFlagArgumentsBlock(flags?: CommandFlags): string { + // `-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 \\' + this.genZshFlagArguments(flags).forEach(f => { + argumentsBlock += `\n ${f}` + }) return argumentsBlock } - private genZshValuesBlock(subArgs: {id: string; summary?: string}[]): string { - let valuesBlock = '_values "completions" \\\n' + private genZshValuesBlock(subArgs: Array<{id: string; summary?: string}>): string { + let valuesBlock = '_values "completions"' subArgs.forEach(subArg => { - valuesBlock += `"${subArg.id}[${subArg.summary}]" \\\n` + valuesBlock += ` \\\n "${subArg.id}[${subArg.summary}]"` }) return valuesBlock } private genZshTopicCompFun(id: string): string { - const coTopics: string[] = [] - - 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.replace(/:/g, '_') 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 + let flags = '' - _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: {id: string; summary?: string}[] = [] - - let argsBlock = '' - - this.topics - .filter(t => t.name.startsWith(id + ':') && t.name.split(':').length === depth + 1) - .forEach(t => { - const subArg = t.name.split(':')[depth] - - subArgs.push({ - id: subArg, - summary: t.description, - }) - - argsBlock += util.format(argTemplate, subArg, `_${this.config.bin}_${underscoreSepId}_${subArg}`) - }) - - this.commands - .filter(c => c.id.startsWith(id + ':') && c.id.split(':').length === depth + 1) - .forEach(c => { - if (coTopics.includes(c.id)) return - const subArg = c.id.split(':')[depth] - - subArgs.push({ - id: subArg, - summary: c.summary, - }) - - argsBlock += util.format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags)) - }) + const cflags = this.commands.find(c => c.id === id)?.flags + this.genZshFlagArguments(cflags).forEach(f => { + flags += `\n ${f}` + }) + flags += '\n ' - return util.format(coTopicCompFunc, this.genZshValuesBlock(subArgs), argsBlock) - } let argsBlock = '' - const subArgs: {id: string; summary?: string}[] = [] + const subArgs: Array<{id: string; summary?: string}> = [] this.topics .filter(t => t.name.startsWith(id + ':') && t.name.split(':').length === depth + 1) .forEach(t => { @@ -296,48 +234,51 @@ _${this.config.bin} summary: t.description, }) - argsBlock += util.format(argTemplate, subArg, `_${this.config.bin}_${underscoreSepId}_${subArg}`) + argsBlock += util.format('\n "%s")\n _arguments -C "*::arg:->args"\n %s\n ;;', subArg, `_${this.config.bin}_${underscoreSepId}_${subArg}`) }) - this.commands - .filter(c => c.id.startsWith(id + ':') && c.id.split(':').length === depth + 1) - .forEach(c => { - if (coTopics.includes(c.id)) return - const subArg = c.id.split(':')[depth] + for (const c of this.commands.filter(c => c.id.startsWith(id + ':') && c.id.split(':').length === depth + 1)) { + if (!this.coTopics?.includes(c.id)) { + const subArg = c.id.split(':')[depth] - subArgs.push({ - id: subArg, - summary: c.summary, - }) + subArgs.push({ + id: subArg, + summary: c.summary, + }) - argsBlock += util.format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags)) - }) + const block = this.genZshFlagArgumentsBlock(c.flags) + argsBlock += util.format('\n "%s")\n _arguments -C "*::arg:->args"\n %s\n ;;', subArg, block) + } + } - const topicCompFunc = -`_${this.config.bin}_${underscoreSepId}() { + const topicCompFunc = `_${this.config.bin}_${underscoreSepId}() { local context state state_descr line typeset -A opt_args - _arguments -C "1: :->cmds" "*::arg:->args" + local -a flags=(%s) + + _arguments -C "1: :->cmds" "*: :->args" case "$state" in cmds) -%s + %s \\ + "\${flags[@]}" ;; args) - case $line[1] in -%s + case $line[1] in%s + *) + _arguments -S \\ + "\${flags[@]}" + ;; esac ;; - esac + esac } ` - return util.format(topicCompFunc, this.genZshValuesBlock(subArgs), argsBlock) + return util.format(topicCompFunc, flags, this.genZshValuesBlock(subArgs), argsBlock) } - private get coTopics(): string [] { - if (this._coTopics) return this._coTopics - + private getCoTopics(): string[] { const coTopics: string[] = [] for (const topic of this.topics) { @@ -348,13 +289,12 @@ _${this.config.bin} } } - this._coTopics = coTopics - - return this._coTopics + return coTopics } private getTopics(): Topic[] { - const topics = this.config.topics.filter((topic: Interfaces.Topic) => { + const topics = this.config.topics + .filter((topic: Interfaces.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 @@ -426,4 +366,3 @@ _${this.config.bin} return cmds } } - diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index 86b8cb09..179c7701 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -181,122 +181,142 @@ _test-cli_app() { local context state state_descr line typeset -A opt_args - _arguments -C "1: :->cmds" "*::arg:->args" + local -a flags=( + --help"[Show help for command]" \\ + "*: :_files" + ) + + _arguments -C "1: :->cmds" "*: :->args" case "$state" in cmds) -_values "completions" \\ -"execute[execute code]" \\ - + _values "completions" \\ + "execute[execute code]" \\ + "\${flags[@]}" ;; args) case $line[1] in "execute") + _arguments -C "*::arg:->args" _test-cli_app_execute ;; - + *) + _arguments -S \\ + "\${flags[@]}" + ;; esac ;; - esac + esac } _test-cli_app_execute() { local context state state_descr line typeset -A opt_args - _arguments -C "1: :->cmds" "*::arg:->args" + local -a flags=( + --help"[Show help for command]" \\ + "*: :_files" + ) + + _arguments -C "1: :->cmds" "*: :->args" case "$state" in cmds) -_values "completions" \\ -"code[execute code]" \\ - + _values "completions" \\ + "code[execute code]" \\ + "\${flags[@]}" ;; args) case $line[1] in "code") + _arguments -C "*::arg:->args" _arguments -S \\ ---help"[Show help for command]" \\ -"*: :_files" - ;; - + --help"[Show help for command]" \\ + "*: :_files" + ;; + *) + _arguments -S \\ + "\${flags[@]}" + ;; esac ;; - esac + esac } _test-cli_deploy() { - _test-cli_deploy_flags() { - local context state state_descr line - typeset -A opt_args - - _arguments -S \\ -"*"{-m,--metadata}"[]:file:_files" \\ -"(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ ---json"[Format output as json.]" \\ -"(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ ---help"[Show help for command]" \\ -"*: :_files" - } - local context state state_descr line typeset -A opt_args + local -a flags=( + "*"{-m,--metadata}"[]:file:_files" \\ + "(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ + --json"[Format output as json.]" \\ + "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ + --help"[Show help for command]" \\ + "*: :_files" + ) + _arguments -C "1: :->cmds" "*: :->args" case "$state" in cmds) - if [[ "\${words[CURRENT]}" == -* ]]; then - _test-cli_deploy_flags - else -_values "completions" \\ -"functions[Deploy a function.]" \\ - - fi + _values "completions" \\ + "functions[Deploy a function.]" \\ + "\${flags[@]}" ;; args) case $line[1] in "functions") + _arguments -C "*::arg:->args" _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:file:_files" \\ ---help"[Show help for command]" \\ -"*: :_files" - ;; - - *) - _test-cli_deploy_flags - ;; + "(-b --branch)"{-b,--branch}"[]:file:_files" \\ + --help"[Show help for command]" \\ + "*: :_files" + ;; + *) + _arguments -S \\ + "\${flags[@]}" + ;; esac ;; esac } - _test-cli() { local context state state_descr line typeset -A opt_args - _arguments -C "1: :->cmds" "*::arg:->args" + local -a flags=( + --help"[Show help]" \\ + --version"[Show version]" + ) + + _arguments -C "1: :->cmds" "*: :->args" case "$state" in cmds) _values "completions" \\ -"app[execute code]" \\ -"deploy[Deploy a project]" \\ -"search[Search for a command]" \\ - - ;; + "app[execute code]" \\ + "deploy[Deploy a project]" \\ + "search[Search for a command]" \\ + "\${flags[@]}" + ;; args) case $line[1] in -app) - _test-cli_app - ;; -deploy) - _test-cli_deploy - ;; -esac - - ;; + app) + _arguments -C "*::arg:->args" + _test-cli_app + ;; + deploy) + _arguments -C "*::arg:->args" + _test-cli_deploy + ;; + *) + _arguments -S \\ + "\${flags[@]}" + ;; + esac + ;; esac }