diff --git a/README.md b/README.md index e162262c..199cf308 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ EXAMPLES $ oclif-example autocomplete --refresh-cache ``` -_See code: [src/commands/autocomplete/index.ts](https://github.com/oclif/plugin-autocomplete/blob/3.2.36/src/commands/autocomplete/index.ts)_ +_See code: [src/commands/autocomplete/index.ts](https://github.com/oclif/plugin-autocomplete/blob/3.2.37-autocomplete.1/src/commands/autocomplete/index.ts)_ diff --git a/package.json b/package.json index 51992768..097ed617 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "@oclif/plugin-autocomplete", "description": "autocomplete plugin for oclif", - "version": "3.2.36", + "version": "3.2.37-autocomplete.1", "author": "Salesforce", "bugs": "https://github.com/oclif/plugin-autocomplete/issues", "dependencies": { - "@oclif/core": "^4", + "@oclif/core": "autocomplete", "ansis": "^3.16.0", "debug": "^4.4.1", "ejs": "^3.1.10" diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 2bd3ad98..a4859739 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -62,13 +62,42 @@ __autocomplete() else # Flag - # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") - # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag - normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + # The full CLI command separated by colons + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 2)}")" )" + local flagName="\${prev#--}" + + # Try to get dynamic completions + local dynamicOpts=$( autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi + else + # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") + # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" - # The line below finds the command in $commands using grep - # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") - opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + # The line below finds the command in $commands using grep + # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") + opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + fi fi COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) diff --git a/src/autocomplete/bash.ts b/src/autocomplete/bash.ts index a1de4d90..6fa61325 100644 --- a/src/autocomplete/bash.ts +++ b/src/autocomplete/bash.ts @@ -13,15 +13,50 @@ __autocomplete() if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ \${COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + + local flagName="\${prev#--}" + # Try to get dynamic completions + local dynamicOpts=$( autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) diff --git a/src/autocomplete/powershell.ts b/src/autocomplete/powershell.ts index 46b8b582..7090086a 100644 --- a/src/autocomplete/powershell.ts +++ b/src/autocomplete/powershell.ts @@ -27,7 +27,7 @@ export default class PowerShellComp { constructor(config: Config) { this.config = config this.topics = this.getTopics() - this.commands = this.getCommands() + this.commands = [] } private get coTopics(): string[] { @@ -48,7 +48,12 @@ export default class PowerShellComp { return this._coTopics } - public generate(): string { + public async generate(): Promise { + // Ensure commands are loaded with completion properties + if (this.commands.length === 0) { + await this.init() + } + const genNode = (partialId: string): Record => { const node: Record = {} @@ -206,9 +211,34 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + + try { + $DynamicOptions = & ${this.config.bin} autocomplete:options --command=$CommandId --flag=$FlagName 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). @@ -269,6 +299,10 @@ Register-ArgumentCompleter -Native -CommandName ${ return compRegister } + async init(): Promise { + this.commands = await this.getCommands() + } + private genCmdHashtable(cmd: CommandCompletion): string { const flaghHashtables: string[] = [] @@ -289,18 +323,19 @@ Register-ArgumentCompleter -Native -CommandName ${ if (f.type === 'option' && f.multiple) { flaghHashtables.push( - ` "${f.name}" = @{ + ` "${flagName}" = @{ "summary" = "${flagSummary}" "multiple" = $true }`, ) } else { - flaghHashtables.push(` "${f.name}" = @{ "summary" = "${flagSummary}" }`) + flaghHashtables.push(` "${flagName}" = @{ "summary" = "${flagSummary}" }`) } } } const cmdHashtable = `@{ + "id" = "${cmd.id}" "summary" = "${cmd.summary}" "flags" = @{ ${flaghHashtables.join('\n')} @@ -365,14 +400,31 @@ ${flaghHashtables.join('\n')} return leafTpl } - private getCommands(): CommandCompletion[] { + private async getCommands(): Promise { const cmds: CommandCompletion[] = [] 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 + + // Try to load actual command class to get flags with completion properties + // This allows us to see dynamic completions, but gracefully falls back to + // manifest flags if loading fails - preserving existing behavior + let {flags} = c + try { + // eslint-disable-next-line no-await-in-loop + const CommandClass = await c.load() + // Use flags from command class if available and not empty (includes completion properties) + if (CommandClass.flags && Object.keys(CommandClass.flags).length > 0) { + flags = CommandClass.flags as CommandFlags + } + } catch { + // Fall back to manifest flags if loading fails + // This ensures existing commands without completions continue to work exactly as before + flags = c.flags + } + cmds.push({ flags, id: c.id, diff --git a/src/autocomplete/zsh.ts b/src/autocomplete/zsh.ts index 115dca9f..d010703b 100644 --- a/src/autocomplete/zsh.ts +++ b/src/autocomplete/zsh.ts @@ -28,7 +28,7 @@ export default class ZshCompWithSpaces { constructor(config: Config) { this.config = config this.topics = this.getTopics() - this.commands = this.getCommands() + this.commands = [] } private get coTopics(): string[] { @@ -49,7 +49,12 @@ export default class ZshCompWithSpaces { return this._coTopics } - public generate(): string { + public async generate(): Promise { + // Ensure commands are loaded with completion properties + if (this.commands.length === 0) { + await this.init() + } + const firstArgs: {id: string; summary?: string}[] = [] for (const t of this.topics) { @@ -82,7 +87,7 @@ 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 += `${arg.id})\n${this.genZshFlagArgumentsBlock(cmd.flags, cmd.id)} ;; \n` } } else { // it's a topic, redirect to its completion function. @@ -99,6 +104,9 @@ export default class ZshCompWithSpaces { return `#compdef ${this.config.bin} ${this.config.binAliases?.map((a) => `compdef ${a}=${this.config.bin}`).join('\n') ?? ''} + +${this.genDynamicCompletionHelper()} + ${this.topics.map((t) => this.genZshTopicCompFun(t.name)).join('\n')} _${this.config.bin}() { @@ -121,70 +129,181 @@ _${this.config.bin} ` } - private genZshFlagArgumentsBlock(flags?: CommandFlags): string { + async init(): Promise { + this.commands = await this.getCommands() + } + + private genDynamicCompletionHelper(): string { + // Using ${'$'} instead of \$ to avoid linter errors + return `# Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_${this.config.bin}_escape_comp() { + local value="${'$'}1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="${'$'}{value//\\\\/\\\\\\\\}" + value="${'$'}{value//:/\\\\:}" + value="${'$'}{value// /\\\\ }" + printf '%s\\n' "${'$'}value" +} + +_${this.config.bin}_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "${'$'}XDG_CACHE_HOME" ]]; then + local cache_dir="${'$'}XDG_CACHE_HOME/${this.config.bin}/autocomplete/flag_completions" + elif [[ "${'$'}OSTYPE" == darwin* ]]; then + local cache_dir="${'$'}HOME/Library/Caches/${this.config.bin}/autocomplete/flag_completions" + else + local cache_dir="${'$'}HOME/.cache/${this.config.bin}/autocomplete/flag_completions" + fi + + local cache_file="$cache_dir/${'$'}{cmd_id//[:]/_}_${'$'}{flag_name}.cache" + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=${'$'}(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=${'$'}(date +%s) + local age=${'$'}((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "${'$'}line" ]] && _${this.config.bin}_escape_comp "${'$'}line" + done < <(tail -n +2 "$cache_file") + return 0 + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=${'$'}(${this.config.bin} autocomplete:options --command=${'$'}{cmd_id} --flag=${'$'}{flag_name} 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "${'$'}(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Output the escaped completions + while IFS= read -r line; do + [[ -n "${'$'}line" ]] && _${this.config.bin}_escape_comp "${'$'}line" + done <<< "$raw_output" + fi + # If no output, return nothing (will fall back to default completion) +} +` + } + + private genZshCompletionSuffix(f: Command.Flag.Cached, flagName: string, commandId: string | undefined): string { + // Only handle option flags + if (f.type !== 'option') return '' + + // Check completion type: static, dynamic, or none + const {completion} = f as any + const completionType = completion?.type + const hasStaticCompletion = completionType === 'static' && Array.isArray(completion?.options) + const hasDynamicCompletion = completionType === 'dynamic' || typeof completion?.options === 'function' + const cacheDuration = completion?.cacheDuration || 86_400 // Default: 24 hours + + if (hasStaticCompletion && commandId) { + // STATIC: Embed options directly (instant!) + const options = completion.options.join(' ') + return f.char ? `:${flagName}:(${options})` : `: :${flagName}:(${options})` + } + + if (f.options) { + // Legacy static options + return f.char + ? `:${flagName} options:(${f.options?.join(' ')})` + : `: :${flagName} options:(${f.options.join(' ')})` + } + + // ONLY add dynamic completion if the flag has a completion property + if (hasDynamicCompletion && commandId) { + // Use command substitution to generate completions inline + return `: :(${'$'}(_${this.config.bin}_dynamic_comp ${commandId} ${flagName} ${cacheDuration}))` + } + + // No completion defined - fall back to file completion + return ':file:_files' + } + + private genZshFlagArgumentsBlock(flags?: CommandFlags, commandId?: string): 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 '_arguments -S \\\n"--help[Show help for command]" \\\n"*: :_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: + // 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. + // 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] + // willie testing changes // skip hidden flags if (f.hidden) continue const flagSummary = this.sanitizeSummary(f.summary ?? f.description) + const flagSpec = this.genZshFlagSpec(f, flagName, flagSummary, commandId) - let flagSpec = '' + argumentsBlock += flagSpec + ' \\\n' + } - 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}}` - } + // add global `--help` flag + argumentsBlock += '"--help[Show help for command]" \\\n' + // complete files if `-` is not present on the current line + argumentsBlock += '"*: :_files"' - flagSpec += `"[${flagSummary}]` + return argumentsBlock + } - 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 += '"*"' - } + private genZshFlagSpec(f: Command.Flag.Any, flagName: string, flagSummary: string, commandId?: string): string { + if (f.type === 'option') { + return this.genZshOptionFlagSpec(f, flagName, flagSummary, commandId) + } - flagSpec += `--${f.name}"[${flagSummary}]:` + // Boolean flag + if (f.char) { + return `"(-${f.char} --${flagName})"{-${f.char},--${flagName}}"[${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}]"` - } + return `"--${flagName}[${flagSummary}]"` + } - flagSpec += ' \\\n' - argumentsBlock += flagSpec + private genZshOptionFlagSpec( + f: Command.Flag.Cached, + flagName: string, + flagSummary: string, + commandId?: string, + ): string { + // TypeScript doesn't narrow f to option type, so we cast + const optionFlag = f as Command.Flag.Cached & {multiple?: boolean} + const completionSuffix = this.genZshCompletionSuffix(f, flagName, commandId) + + if (f.char) { + const multiplePart = optionFlag.multiple + ? `"*"{-${f.char},--${flagName}}` + : `"(-${f.char} --${flagName})"{-${f.char},--${flagName}}` + return `${multiplePart}"[${flagSummary}]${completionSuffix}"` } - // 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 + const multiplePart = optionFlag.multiple ? '*' : '' + return `"${multiplePart}--${flagName}[${flagSummary}]${completionSuffix}"` } private genZshTopicCompFun(id: string): string { @@ -213,7 +332,7 @@ _${this.config.bin} local context state state_descr line typeset -A opt_args - ${this.genZshFlagArgumentsBlock(this.commands.find((c) => c.id === id)?.flags)} + ${this.genZshFlagArgumentsBlock(this.commands.find((c) => c.id === id)?.flags, id)} } local context state state_descr line @@ -266,7 +385,7 @@ _${this.config.bin} summary: c.summary, }) - argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags)) + argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags, c.id)) } return format(coTopicCompFunc, this.genZshValuesBlock(subArgs), argsBlock) @@ -295,7 +414,7 @@ _${this.config.bin} summary: c.summary, }) - argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags)) + argsBlock += format(flagArgsTemplate, subArg, this.genZshFlagArgumentsBlock(c.flags, c.id)) } const topicCompFunc = `_${this.config.bin}_${underscoreSepId}() { @@ -322,21 +441,40 @@ _${this.config.bin} private genZshValuesBlock(subArgs: {id: string; summary?: string}[]): string { let valuesBlock = '_values "completions" \\\n' - for (const subArg of subArgs) { - valuesBlock += `"${subArg.id}[${subArg.summary}]" \\\n` + for (let i = 0; i < subArgs.length; i++) { + const subArg = subArgs[i] + const isLast = i === subArgs.length - 1 + valuesBlock += `"${subArg.id}[${subArg.summary}]"${isLast ? '\n' : ' \\\n'}` } return valuesBlock } - private getCommands(): CommandCompletion[] { + private async getCommands(): Promise { const cmds: CommandCompletion[] = [] 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 + + // Try to load actual command class to get flags with completion properties + // This allows us to see dynamic completions, but gracefully falls back to + // manifest flags if loading fails - preserving existing behavior + let {flags} = c + try { + // eslint-disable-next-line no-await-in-loop + const CommandClass = await c.load() + // Use flags from command class if available and not empty (includes completion properties) + if (CommandClass.flags && Object.keys(CommandClass.flags).length > 0) { + flags = CommandClass.flags as CommandFlags + } + } catch { + // Fall back to manifest flags if loading fails + // This ensures existing commands without completions continue to work exactly as before + flags = c.flags + } + cmds.push({ flags, id: c.id, @@ -408,6 +546,26 @@ _${this.config.bin} return topics } + private hasDynamicCompletions(): boolean { + // Check if any command has dynamic completions + for (const command of this.commands) { + const flags = command.flags || {} + for (const flag of Object.values(flags)) { + if ( + flag.type === 'option' && + flag.completion && // If completion doesn't have a type, assume dynamic (backward compatibility) + // If it has type === 'dynamic', it's dynamic + // @ts-expect-error - completion.type may not exist yet in types + (!flag.completion.type || flag.completion.type === 'dynamic') + ) { + return true + } + } + } + + return false + } + private sanitizeSummary(summary?: string): string { if (summary === undefined) { return '' diff --git a/src/commands/autocomplete/create.ts b/src/commands/autocomplete/create.ts index 983e3325..fae7778e 100644 --- a/src/commands/autocomplete/create.ts +++ b/src/commands/autocomplete/create.ts @@ -33,33 +33,6 @@ export default class Create extends AutocompleteBase { static hidden = true private _commands?: CommandCompletion[] - private get bashCommandsWithFlagsList(): string { - return this.commands - .map((c) => { - const publicFlags = this.genCmdPublicFlags(c).trim() - return `${c.id} ${publicFlags}` - }) - .join('\n') - } - - private get bashCompletionFunction(): string { - const {cliBin} = this - const supportSpaces = this.config.topicSeparator === ' ' - const bashScript = - process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces - ? bashAutocomplete - : bashAutocompleteWithSpaces - return ( - bashScript - // eslint-disable-next-line unicorn/prefer-spread - .concat( - ...(this.config.binAliases?.map((alias) => `complete -F __autocomplete ${alias}`).join('\n') ?? []), - ) - .replaceAll('', cliBin) - .replaceAll('', this.bashCommandsWithFlagsList) - ) - } - private get bashCompletionFunctionPath(): string { // /autocomplete/functions/bash/.bash return path.join(this.bashFunctionsDir, `${this.cliBin}.bash`) @@ -83,65 +56,6 @@ export default class Create extends AutocompleteBase { return path.join(this.autocompleteCacheDir, 'bash_setup') } - private get commands(): CommandCompletion[] { - if (this._commands) return this._commands - - const cmds: CommandCompletion[] = [] - - for (const p of this.config.getPluginsList()) { - for (const c of p.commands) { - try { - if (c.hidden) continue - const description = sanitizeDescription(c.summary ?? (c.description || '')) - const {flags} = c - cmds.push({ - description, - flags, - id: c.id, - }) - for (const a of c.aliases) { - cmds.push({ - description, - flags, - id: a, - }) - } - } catch (error: any) { - debug(`Error creating zsh flag spec for command ${c.id}`) - debug(error.message) - this.writeLogFile(error.message) - } - } - } - - this._commands = cmds - - return this._commands - } - - private get genAllCommandsMetaString(): string { - // eslint-disable-next-line no-useless-escape - return this.commands.map((c) => `\"${c.id.replaceAll(':', '\\:')}:${c.description}\"`).join('\n') - } - - private get genCaseStatementForFlagsMetaString(): string { - // command) - // _command_flags=( - // "--boolean[bool descr]" - // "--value=-[value descr]:" - // ) - // ;; - return this.commands - .map( - (c) => `${c.id}) - _command_flags=( - ${this.genZshFlagSpecs(c)} - ) -;;\n`, - ) - .join('\n') - } - private get pwshCompletionFunctionPath(): string { // /autocomplete/functions/powershell/.ps1 return path.join(this.pwshFunctionsDir, `${this.cliBin}.ps1`) @@ -152,51 +66,6 @@ export default class Create extends AutocompleteBase { return path.join(this.autocompleteCacheDir, 'functions', 'powershell') } - private get zshCompletionFunction(): string { - const {cliBin} = this - const allCommandsMeta = this.genAllCommandsMetaString - const caseStatementForFlagsMeta = this.genCaseStatementForFlagsMetaString - - return `#compdef ${cliBin} - -_${cliBin} () { - local _command_id=\${words[2]} - local _cur=\${words[CURRENT]} - local -a _command_flags=() - - ## public cli commands & flags - local -a _all_commands=( -${allCommandsMeta} - ) - - _set_flags () { - case $_command_id in -${caseStatementForFlagsMeta} - esac - } - ## end public cli commands & flags - - _complete_commands () { - _describe -t all-commands "all commands" _all_commands - } - - if [ $CURRENT -gt 2 ]; then - if [[ "$_cur" == -* ]]; then - _set_flags - else - _path_files - fi - fi - - - _arguments -S '1: :_complete_commands' \\ - $_command_flags -} - -_${cliBin} -` - } - private get zshCompletionFunctionPath(): string { // /autocomplete/functions/zsh/_ return path.join(this.zshFunctionsDir, `_${this.cliBin}`) @@ -233,21 +102,43 @@ compinit;\n` // zsh const supportSpaces = this.config.topicSeparator === ' ' - await Promise.all( - [ - writeFile(this.bashSetupScriptPath, this.bashSetupScript), - writeFile(this.bashCompletionFunctionPath, this.bashCompletionFunction), - writeFile(this.zshSetupScriptPath, this.zshSetupScript), - // eslint-disable-next-line unicorn/prefer-spread - ].concat( - process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces - ? [writeFile(this.zshCompletionFunctionPath, this.zshCompletionFunction)] - : [ - writeFile(this.zshCompletionFunctionPath, new ZshCompWithSpaces(this.config).generate()), - writeFile(this.pwshCompletionFunctionPath, new PowerShellComp(this.config).generate()), - ], - ), - ) + // Generate completion scripts (all in parallel for performance) + const [zshScript, bashCompletionFunction, pwshScript, oldZshScript] = await Promise.all([ + // Modern Zsh (with spaces support) + (async () => { + const zshGenerator = new ZshCompWithSpaces(this.config) + return zshGenerator.generate() + })(), + // Bash + this.getBashCompletionFunction(), + // PowerShell + (async () => { + const pwshGenerator = new PowerShellComp(this.config) + return pwshGenerator.generate() + })(), + // Old Zsh (colon separator) - only if needed + process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces + ? this.getZshCompletionFunction() + : Promise.resolve(''), + ]) + + // Write all files + const writeOperations = [ + writeFile(this.bashSetupScriptPath, this.bashSetupScript), + writeFile(this.bashCompletionFunctionPath, bashCompletionFunction), + writeFile(this.zshSetupScriptPath, this.zshSetupScript), + ] + + if (process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces) { + writeOperations.push(writeFile(this.zshCompletionFunctionPath, oldZshScript)) + } else { + writeOperations.push( + writeFile(this.zshCompletionFunctionPath, zshScript), + writeFile(this.pwshCompletionFunctionPath, pwshScript), + ) + } + + await Promise.all(writeOperations) } private async ensureDirs() { @@ -260,6 +151,30 @@ compinit;\n` ]) } + private async genAllCommandsMetaString(): Promise { + const commands = await this.getCommands() + return commands.map((c) => `"${c.id.replaceAll(':', '\\:')}:${c.description}"`).join('\n') + } + + private async genCaseStatementForFlagsMetaString(): Promise { + // command) + // _command_flags=( + // "--boolean[bool descr]" + // "--value=-[value descr]:" + // ) + // ;; + const commands = await this.getCommands() + return commands + .map( + (c) => `${c.id}) + _command_flags=( + ${this.genZshFlagSpecs(c)} + ) +;;\n`, + ) + .join('\n') + } + private genCmdPublicFlags(Command: CommandCompletion): string { const Flags = Command.flags || {} return Object.keys(Flags) @@ -283,4 +198,131 @@ compinit;\n` }) .join('\n') } + + private async getBashCommandsWithFlagsList(): Promise { + const commands = await this.getCommands() + return commands + .map((c) => { + const publicFlags = this.genCmdPublicFlags(c).trim() + return `${c.id} ${publicFlags}` + }) + .join('\n') + } + + private async getBashCompletionFunction(): Promise { + const {cliBin} = this + const supportSpaces = this.config.topicSeparator === ' ' + const bashScript = + process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces + ? bashAutocomplete + : bashAutocompleteWithSpaces + const bashCommandsWithFlagsList = await this.getBashCommandsWithFlagsList() + return ( + bashScript + // eslint-disable-next-line unicorn/prefer-spread + .concat( + ...(this.config.binAliases?.map((alias) => `complete -F __autocomplete ${alias}`).join('\n') ?? []), + ) + .replaceAll('', cliBin) + .replaceAll('', bashCommandsWithFlagsList) + ) + } + + private async getCommands(): Promise { + if (this._commands) return this._commands + + const cmds: CommandCompletion[] = [] + + for (const p of this.config.getPluginsList()) { + for (const c of p.commands) { + try { + if (c.hidden) continue + const description = sanitizeDescription(c.summary ?? (c.description || '')) + + // Try to load actual command class to get flags with completion properties + // This allows us to see dynamic completions, but gracefully falls back to + // manifest flags if loading fails - preserving existing behavior + let {flags} = c + try { + // eslint-disable-next-line no-await-in-loop + const CommandClass = await c.load() + // Use flags from command class if available and not empty (includes completion properties) + if (CommandClass.flags && Object.keys(CommandClass.flags).length > 0) { + flags = CommandClass.flags + } + } catch { + // Fall back to manifest flags if loading fails + // This ensures existing commands without completions continue to work exactly as before + flags = c.flags + } + + cmds.push({ + description, + flags, + id: c.id, + }) + for (const a of c.aliases) { + cmds.push({ + description, + flags, + id: a, + }) + } + } catch (error: any) { + debug(`Error creating bash flag spec for command ${c.id}`) + debug(error.message) + this.writeLogFile(error.message) + } + } + } + + this._commands = cmds + + return this._commands + } + + private async getZshCompletionFunction(): Promise { + const {cliBin} = this + const allCommandsMeta = await this.genAllCommandsMetaString() + const caseStatementForFlagsMeta = await this.genCaseStatementForFlagsMetaString() + + return `#compdef ${cliBin} + +_${cliBin} () { + local _command_id=\${words[2]} + local _cur=\${words[CURRENT]} + local -a _command_flags=() + + ## public cli commands & flags + local -a _all_commands=( +${allCommandsMeta} + ) + + _set_flags () { + case $_command_id in +${caseStatementForFlagsMeta} + esac + } + ## end public cli commands & flags + + _complete_commands () { + _describe -t all-commands "all commands" _all_commands + } + + if [ $CURRENT -gt 2 ]; then + if [[ "$_cur" == -* ]]; then + _set_flags + else + _path_files + fi + fi + + + _arguments -S '1: :_complete_commands' \\ + $_command_flags +} + +_${cliBin} +` + } } diff --git a/src/commands/autocomplete/index.ts b/src/commands/autocomplete/index.ts index 3d3a7a9d..bfedc80e 100644 --- a/src/commands/autocomplete/index.ts +++ b/src/commands/autocomplete/index.ts @@ -4,6 +4,7 @@ import {EOL} from 'node:os' import {AutocompleteBase} from '../../base.js' import Create from './create.js' +import Options from './options.js' export default class Index extends AutocompleteBase { static args = { @@ -37,11 +38,107 @@ export default class Index extends AutocompleteBase { ux.action.start(`${bold('Building the autocomplete cache')}`) await Create.run([], this.config) - ux.action.stop() + + ux.action.status = 'Generating dynamic completion caches' + await this.generateCompletionCaches() if (!flags['refresh-cache']) { this.printShellInstructions(shell) } + + ux.action.stop() + } + + private async generateCompletionCaches(): Promise { + const commandsWithDynamicCompletions: Array<{commandId: string; flagName: string}> = [] + + // Find all commands with flags that have completion functions + // This ONLY loads command classes, doesn't affect existing functionality + for (const commandId of this.config.commandIDs) { + const command = this.config.findCommand(commandId) + if (!command) continue + + try { + // Load the actual command class to access completion functions + // Falls back gracefully if loading fails - no impact on existing commands + // eslint-disable-next-line no-await-in-loop + const CommandClass = await command.load() + const flags = CommandClass.flags || {} + + for (const [flagName, flag] of Object.entries(flags)) { + if (flag.type !== 'option') continue + + const {completion} = flag as any + // Skip flags without completion property - no extra work for existing flags + if (!completion) continue + + // Check if it has dynamic completion or legacy options function + const isDynamic = completion.type === 'dynamic' || typeof completion.options === 'function' + + if (isDynamic) { + commandsWithDynamicCompletions.push({commandId, flagName}) + } + } + } catch { + // Silently ignore errors loading command class + // Existing commands continue to work with manifest-based completions + continue + } + } + + // Early exit if no dynamic completions found - zero impact on existing functionality + if (commandsWithDynamicCompletions.length === 0) { + return + } + + const total = commandsWithDynamicCompletions.length + const startTime = Date.now() + + ux.action.start(`${bold('Generating')} ${total} ${bold('dynamic completion caches')} ${cyan('(in parallel)')}`) + + const results = await this.runWithConcurrency( + commandsWithDynamicCompletions, + 10, + async ({commandId, flagName}, index) => { + ux.action.status = `${index + 1}/${total}` + try { + // Get completion options + const options = await Options.getCompletionOptions(this.config, commandId, flagName) + + // Write to cache file (same location and format as shell helper) + if (options.length > 0) { + await this.writeCacheFile(commandId, flagName, options) + return {commandId, count: options.length, flagName, success: true} + } + + // No options returned - log for debugging + return {commandId, count: 0, flagName, success: true} + } catch (error) { + // Log error for debugging + return {commandId, error: (error as Error).message, flagName, success: false} + } + }, + ) + + const withOptions = results.filter((r) => (r as any).count > 0).length + const duration = ((Date.now() - startTime) / 1000).toFixed(1) + + ux.action.stop( + `${bold('✓')} Generated ${withOptions}/${total} caches in ${cyan(duration + 's')} ${cyan(`(~${(total / Number(duration)).toFixed(0)}/s)`)}`, + ) + + // Show details for all caches + this.log('') + this.log(bold('Cache generation details:')) + for (const result of results as any[]) { + if (result.success && result.count > 0) { + // this.log(` ✓ ${result.commandId} --${result.flagName}: ${result.count} options cached`) + } else if (result.count === 0) { + this.log(` ⚠️ ${result.commandId} --${result.flagName}: No options returned`) + } else { + this.log(` ❌ ${result.commandId} --${result.flagName}: ${result.error}`) + } + } } private printShellInstructions(shell: string): void { @@ -63,7 +160,7 @@ Setup Instructions for ${this.config.bin.toUpperCase()} CLI Autocomplete --- The previous command adds the ${cyan(setupEnvVar)} environment variable to your Bash config file and then sources the file. - ${bold('NOTE')}: If you’ve configured your terminal to start as a login shell, you may need to modify the command so it updates either the ~/.bash_profile or ~/.profile file. For example: + ${bold('NOTE')}: If you've configured your terminal to start as a login shell, you may need to modify the command so it updates either the ~/.bash_profile or ~/.profile file. For example: ${cyan(`printf "$(${scriptCommand})" >> ~/.bash_profile; source ~/.bash_profile`)} @@ -126,4 +223,52 @@ Setup Instructions for ${this.config.bin.toUpperCase()} CLI Autocomplete --- ` this.log(instructions) } + + /** + * Run async tasks with concurrency limit + */ + private async runWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, + ): Promise { + const results: R[] = [] + const executing: Array> = [] + + for (const [index, item] of items.entries()) { + const promise = fn(item, index).then((result) => { + results[index] = result + executing.splice(executing.indexOf(promise), 1) + }) + + executing.push(promise) + + if (executing.length >= concurrency) { + // eslint-disable-next-line no-await-in-loop + await Promise.race(executing) + } + } + + await Promise.all(executing) + return results + } + + private async writeCacheFile(commandId: string, flagName: string, options: string[]): Promise { + const {join} = await import('node:path') + const {mkdir, writeFile} = await import('node:fs/promises') + + // Match the shell helper's cache directory structure + const cacheDir = join(this.autocompleteCacheDir, 'flag_completions') + await mkdir(cacheDir, {recursive: true}) + + // Match the shell helper's filename format (replace colons with underscores) + const cacheFilename = `${commandId.replaceAll(':', '_')}_${flagName}.cache` + const cacheFile = join(cacheDir, cacheFilename) + + // Write cache file with timestamp (same format as shell helper) + const timestamp = Math.floor(Date.now() / 1000) // Unix timestamp in seconds + const content = `${timestamp}\n${options.join('\n')}\n` + + await writeFile(cacheFile, content, 'utf8') + } } diff --git a/src/commands/autocomplete/options.ts b/src/commands/autocomplete/options.ts new file mode 100644 index 00000000..dbafb23d --- /dev/null +++ b/src/commands/autocomplete/options.ts @@ -0,0 +1,68 @@ +import {Command, Config, Flags, Interfaces} from '@oclif/core' + +export default class Options extends Command { + static description = 'Display dynamic flag value completions' + static flags = { + command: Flags.string({ + char: 'c', + description: 'Command name or ID', + required: true, + }), + flag: Flags.string({ + char: 'f', + description: 'Flag name', + required: true, + }), + } + static hidden = true + + /** + * Get completion options for a specific command flag + * @param config - The oclif config + * @param commandId - The command ID + * @param flagName - The flag name + * @returns Array of completion options, or empty array if none available + */ + static async getCompletionOptions(config: Config, commandId: string, flagName: string): Promise { + try { + const command = config.findCommand(commandId) + if (!command) { + return [] + } + + // Load the actual command class to get the completion definition + const CommandClass = await command.load() + + // Get the flag definition + const flagDef = CommandClass.flags?.[flagName] as Interfaces.OptionFlag + if (!flagDef || !flagDef.completion) { + return [] + } + + // Handle dynamic completions (or legacy completions without type) + const optionsFunc = flagDef.completion.options + if (typeof optionsFunc !== 'function') { + return [] + } + + // Execute the completion function + const completionContext: Interfaces.CompletionContext = { + config, + } + + return await optionsFunc(completionContext) + } catch { + // Silently fail and return empty completions + return [] + } + } + + async run(): Promise { + const {flags} = await this.parse(Options) + const commandId = flags.command + const flagName = flags.flag + + const options = await Options.getCompletionOptions(this.config, commandId, flagName) + this.log(options.join('\n')) + } +} diff --git a/test/autocomplete/bash.test.ts b/test/autocomplete/bash.test.ts index 7c93b680..0515bcc3 100644 --- a/test/autocomplete/bash.test.ts +++ b/test/autocomplete/bash.test.ts @@ -185,7 +185,8 @@ skipWindows('bash comp', () => { const create = new Create([], config) // @ts-expect-error because it's a private method - expect(create.bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash + const bashCompletionFunction = await create.getBashCompletionFunction() + expect(bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash _test-cli_autocomplete() { @@ -204,15 +205,50 @@ ${'app:execute:code '} if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ $\{COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="$\{COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + + local flagName="$\{prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "$\{cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "$\{opts}" -- $\{cur}) ) @@ -229,7 +265,8 @@ complete -o default -F _test-cli_autocomplete test-cli`) config.binAliases = ['alias'] const create = new Create([], config) // @ts-expect-error because it's a private method - expect(create.bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash + const bashCompletionFunction = await create.getBashCompletionFunction() + expect(bashCompletionFunction.trim()).to.equal(`#!/usr/bin/env bash _test-cli_autocomplete() { @@ -248,15 +285,50 @@ ${'app:execute:code '} if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ $\{COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="$\{COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + + local flagName="$\{prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "$\{cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "$\{opts}" -- $\{cur}) ) @@ -274,7 +346,8 @@ complete -F _test-cli_autocomplete alias`) config.binAliases = ['alias', 'alias2'] const create = new Create([], config) // @ts-expect-error because it's a private method - expect(create.bashCompletionFunction).to.equal(`#!/usr/bin/env bash + const bashCompletionFunction = await create.getBashCompletionFunction() + expect(bashCompletionFunction).to.equal(`#!/usr/bin/env bash _test-cli_autocomplete() { @@ -293,15 +366,50 @@ ${'app:execute:code '} if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ $\{COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="$\{COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + + local flagName="$\{prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(test-cli autocomplete:options --command="$\{__COMP_WORDS}" --flag="$\{flagName}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "$\{cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ $\{COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "$\{COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="$\{COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "$\{__COMP_WORDS}" | sed -n "s/^$\{__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "$\{opts}" -- $\{cur}) ) diff --git a/test/autocomplete/powershell.test.ts b/test/autocomplete/powershell.test.ts index 25283d0e..eb41d1a5 100644 --- a/test/autocomplete/powershell.test.ts +++ b/test/autocomplete/powershell.test.ts @@ -194,10 +194,11 @@ describe('powershell completion', () => { } }) - it('generates a valid completion file.', () => { + it('generates a valid completion file.', async () => { config.bin = 'test-cli' const powerShellComp = new PowerShellComp(config as Config) - expect(powerShellComp.generate()).to.equal(` + const script = await powerShellComp.generate() + expect(script).to.equal(` using namespace System.Management.Automation using namespace System.Management.Automation.Language @@ -212,6 +213,7 @@ $scriptblock = { "_summary" = "execute code" "code" = @{ "_command" = @{ + "id" = "app:execute:code" "summary" = "execute code" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -225,6 +227,7 @@ $scriptblock = { "deploy" = @{ "_command" = @{ + "id" = "deploy" "summary" = "Deploy a project" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -239,6 +242,7 @@ $scriptblock = { } "functions" = @{ "_command" = @{ + "id" = "deploy:functions" "summary" = "Deploy a function." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -250,6 +254,7 @@ $scriptblock = { "autocomplete" = @{ "_command" = @{ + "id" = "autocomplete" "summary" = "Display autocomplete installation instructions." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -260,6 +265,7 @@ $scriptblock = { "search" = @{ "_command" = @{ + "id" = "search" "summary" = "Search for a command" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -329,9 +335,34 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + + try { + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). @@ -386,11 +417,11 @@ Register-ArgumentCompleter -Native -CommandName test-cli -ScriptBlock $scriptblo `) }) - it('generates a valid completion file with a bin alias.', () => { + it('generates a valid completion file with a bin alias.', async () => { config.bin = 'test-cli' config.binAliases = ['test'] const powerShellComp = new PowerShellComp(config as Config) - expect(powerShellComp.generate()).to.equal(` + expect(await powerShellComp.generate()).to.equal(` using namespace System.Management.Automation using namespace System.Management.Automation.Language @@ -405,6 +436,7 @@ $scriptblock = { "_summary" = "execute code" "code" = @{ "_command" = @{ + "id" = "app:execute:code" "summary" = "execute code" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -418,6 +450,7 @@ $scriptblock = { "deploy" = @{ "_command" = @{ + "id" = "deploy" "summary" = "Deploy a project" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -432,6 +465,7 @@ $scriptblock = { } "functions" = @{ "_command" = @{ + "id" = "deploy:functions" "summary" = "Deploy a function." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -443,6 +477,7 @@ $scriptblock = { "autocomplete" = @{ "_command" = @{ + "id" = "autocomplete" "summary" = "Display autocomplete installation instructions." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -453,6 +488,7 @@ $scriptblock = { "search" = @{ "_command" = @{ + "id" = "search" "summary" = "Search for a command" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -522,9 +558,34 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + + try { + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). @@ -579,11 +640,11 @@ Register-ArgumentCompleter -Native -CommandName @("test","test-cli") -ScriptBloc `) }) - it('generates a valid completion file with multiple bin aliases.', () => { + it('generates a valid completion file with multiple bin aliases.', async () => { config.bin = 'test-cli' config.binAliases = ['test', 'test1'] const powerShellComp = new PowerShellComp(config as Config) - expect(powerShellComp.generate()).to.equal(` + expect(await powerShellComp.generate()).to.equal(` using namespace System.Management.Automation using namespace System.Management.Automation.Language @@ -598,6 +659,7 @@ $scriptblock = { "_summary" = "execute code" "code" = @{ "_command" = @{ + "id" = "app:execute:code" "summary" = "execute code" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -611,6 +673,7 @@ $scriptblock = { "deploy" = @{ "_command" = @{ + "id" = "deploy" "summary" = "Deploy a project" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -625,6 +688,7 @@ $scriptblock = { } "functions" = @{ "_command" = @{ + "id" = "deploy:functions" "summary" = "Deploy a function." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -636,6 +700,7 @@ $scriptblock = { "autocomplete" = @{ "_command" = @{ + "id" = "autocomplete" "summary" = "Display autocomplete installation instructions." "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -646,6 +711,7 @@ $scriptblock = { "search" = @{ "_command" = @{ + "id" = "search" "summary" = "Search for a command" "flags" = @{ "help" = @{ "summary" = "Show help for command" } @@ -715,9 +781,34 @@ $scriptblock = { # Start completing command. if ($NextArg._command -ne $null) { - # Complete flags - # \`cli config list -\` - if ($WordToComplete -like '-*') { + # Check if we're completing a flag value + $PrevWord = if ($CurrentLine.Count -gt 0) { $CurrentLine[-1] } else { "" } + $IsCompletingFlagValue = $PrevWord -like '--*' -and $WordToComplete -notlike '-*' + + if ($IsCompletingFlagValue) { + # Try dynamic flag value completion + $FlagName = $PrevWord.TrimStart('-') + $CommandId = $NextArg._command.id + + try { + $DynamicOptions = & test-cli autocomplete:options --command=$CommandId --flag=$FlagName 2>$null + if ($DynamicOptions) { + $DynamicOptions | Where-Object { + $_.StartsWith("$WordToComplete") + } | ForEach-Object { + New-Object -Type CompletionResult -ArgumentList \` + $($Mode -eq "MenuComplete" ? "$_ " : "$_"), + $_, + "ParameterValue", + " " + } + } + } catch { + # Fall back to no completions if dynamic completion fails + } + } elseif ($WordToComplete -like '-*') { + # Complete flags + # \`cli config list -\` $NextArg._command.flags.GetEnumerator() | Sort-Object -Property key | Where-Object { # Filter out already used flags (unless \`flag.multiple = true\`). diff --git a/test/autocomplete/zsh.test.ts b/test/autocomplete/zsh.test.ts index df92261a..2a8a5068 100644 --- a/test/autocomplete/zsh.test.ts +++ b/test/autocomplete/zsh.test.ts @@ -55,6 +55,9 @@ const commandPluginA: Command.Loadable = { flags: { 'api-version': { char: 'a', + completion: { + options: async () => ['50.0', '51.0', '52.0'], + }, multiple: false, name: 'api-version', type: 'option', @@ -74,6 +77,9 @@ const commandPluginA: Command.Loadable = { }, metadata: { char: 'm', + completion: { + options: async () => ['ApexClass', 'CustomObject', 'Profile'], + }, multiple: true, name: 'metadata', type: 'option', @@ -97,6 +103,9 @@ const commandPluginB: Command.Loadable = { flags: { branch: { char: 'b', + completion: { + options: async () => ['main', 'develop', 'feature'], + }, multiple: false, name: 'branch', type: 'option', @@ -200,10 +209,79 @@ skipWindows('zsh comp', () => { } }) - it('generates a valid completion file.', () => { + it('generates a valid completion file.', async () => { config.bin = 'test-cli' const zshCompWithSpaces = new ZshCompWithSpaces(config as Config) - expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + expect(await zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + + + +# Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_test-cli_escape_comp() { + local value="$1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="\${value//\\\\/\\\\\\\\}" + value="\${value//:/\\\\:}" + value="\${value// /\\\\ }" + printf '%s\\n' "$value" +} + +_test-cli_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "$XDG_CACHE_HOME" ]]; then + local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" + elif [[ "$OSTYPE" == darwin* ]]; then + local cache_dir="$HOME/Library/Caches/test-cli/autocomplete/flag_completions" + else + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + fi + + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=$(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=$(date +%s) + local age=$((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done < <(tail -n +2 "$cache_file") + return 0 + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=$(test-cli autocomplete:options --command=\${cmd_id} --flag=\${flag_name} 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "$(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Output the escaped completions + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done <<< "$raw_output" + fi + # If no output, return nothing (will fall back to default completion) +} _test-cli_app() { @@ -215,7 +293,7 @@ _test-cli_app() { case "$state" in cmds) _values "completions" \\ -"execute[execute code]" \\ +"execute[execute code]" ;; args) @@ -238,14 +316,14 @@ _test-cli_app_execute() { case "$state" in cmds) _values "completions" \\ -"code[execute code]" \\ +"code[execute code]" ;; args) case $line[1] in "code") _arguments -S \\ ---help"[Show help for command]" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -260,11 +338,11 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ +"(-a --api-version)"{-a,--api-version}"[]: :($(_test-cli_dynamic_comp deploy api-version 86400))" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ ---json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:file:_files" \\ ---help"[Show help for command]" \\ +"--json[Format output as json.]" \\ +"*"{-m,--metadata}"[]: :($(_test-cli_dynamic_comp deploy metadata 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" } @@ -279,7 +357,7 @@ _test-cli_deploy() { _test-cli_deploy_flags else _values "completions" \\ -"functions[Deploy a function.]" \\ +"functions[Deploy a function.]" fi ;; @@ -287,8 +365,8 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:file:_files" \\ ---help"[Show help for command]" \\ +"(-b --branch)"{-b,--branch}"[]: :($(_test-cli_dynamic_comp deploy:functions branch 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -312,7 +390,7 @@ _test-cli() { _values "completions" \\ "app[execute code]" \\ "deploy[Deploy a project]" \\ -"search[Search for a command]" \\ +"search[Search for a command]" ;; args) @@ -333,13 +411,82 @@ _test-cli `) }) - it('generates a valid completion file with a bin alias.', () => { + it('generates a valid completion file with a bin alias.', async () => { config.bin = 'test-cli' config.binAliases = ['testing'] const zshCompWithSpaces = new ZshCompWithSpaces(config as Config) - expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + expect(await zshCompWithSpaces.generate()).to.equal(`#compdef test-cli compdef testing=test-cli + +# Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_test-cli_escape_comp() { + local value="$1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="\${value//\\\\/\\\\\\\\}" + value="\${value//:/\\\\:}" + value="\${value// /\\\\ }" + printf '%s\\n' "$value" +} + +_test-cli_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "$XDG_CACHE_HOME" ]]; then + local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" + elif [[ "$OSTYPE" == darwin* ]]; then + local cache_dir="$HOME/Library/Caches/test-cli/autocomplete/flag_completions" + else + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + fi + + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=$(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=$(date +%s) + local age=$((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done < <(tail -n +2 "$cache_file") + return 0 + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=$(test-cli autocomplete:options --command=\${cmd_id} --flag=\${flag_name} 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "$(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Output the escaped completions + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done <<< "$raw_output" + fi + # If no output, return nothing (will fall back to default completion) +} + + _test-cli_app() { local context state state_descr line typeset -A opt_args @@ -349,7 +496,7 @@ _test-cli_app() { case "$state" in cmds) _values "completions" \\ -"execute[execute code]" \\ +"execute[execute code]" ;; args) @@ -372,14 +519,14 @@ _test-cli_app_execute() { case "$state" in cmds) _values "completions" \\ -"code[execute code]" \\ +"code[execute code]" ;; args) case $line[1] in "code") _arguments -S \\ ---help"[Show help for command]" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -394,11 +541,11 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ +"(-a --api-version)"{-a,--api-version}"[]: :($(_test-cli_dynamic_comp deploy api-version 86400))" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ ---json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:file:_files" \\ ---help"[Show help for command]" \\ +"--json[Format output as json.]" \\ +"*"{-m,--metadata}"[]: :($(_test-cli_dynamic_comp deploy metadata 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" } @@ -413,7 +560,7 @@ _test-cli_deploy() { _test-cli_deploy_flags else _values "completions" \\ -"functions[Deploy a function.]" \\ +"functions[Deploy a function.]" fi ;; @@ -421,8 +568,8 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:file:_files" \\ ---help"[Show help for command]" \\ +"(-b --branch)"{-b,--branch}"[]: :($(_test-cli_dynamic_comp deploy:functions branch 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -446,7 +593,7 @@ _test-cli() { _values "completions" \\ "app[execute code]" \\ "deploy[Deploy a project]" \\ -"search[Search for a command]" \\ +"search[Search for a command]" ;; args) @@ -467,14 +614,83 @@ _test-cli `) }) - it('generates a valid completion file with multiple bin aliases.', () => { + it('generates a valid completion file with multiple bin aliases.', async () => { config.bin = 'test-cli' config.binAliases = ['testing', 'testing2'] const zshCompWithSpaces = new ZshCompWithSpaces(config as Config) - expect(zshCompWithSpaces.generate()).to.equal(`#compdef test-cli + expect(await zshCompWithSpaces.generate()).to.equal(`#compdef test-cli compdef testing=test-cli compdef testing2=test-cli + +# Dynamic completion helper with timestamp-based caching +# This outputs completion options to stdout for use in command substitution +# Minimal escaping for zsh completion arrays - only escape truly problematic chars +_test-cli_escape_comp() { + local value="$1" + # For zsh completion arrays :(value1 value2), we primarily need to escape: + # - Backslashes (must come first) + # - Spaces (since they separate array elements) + # - Colons (special meaning in some contexts) + value="\${value//\\\\/\\\\\\\\}" + value="\${value//:/\\\\:}" + value="\${value// /\\\\ }" + printf '%s\\n' "$value" +} + +_test-cli_dynamic_comp() { + local cmd_id="$1" + local flag_name="$2" + local cache_duration="$3" + + # Determine cache directory based on platform (cross-platform) + if [[ -n "$XDG_CACHE_HOME" ]]; then + local cache_dir="$XDG_CACHE_HOME/test-cli/autocomplete/flag_completions" + elif [[ "$OSTYPE" == darwin* ]]; then + local cache_dir="$HOME/Library/Caches/test-cli/autocomplete/flag_completions" + else + local cache_dir="$HOME/.cache/test-cli/autocomplete/flag_completions" + fi + + local cache_file="$cache_dir/\${cmd_id//[:]/_}_\${flag_name}.cache" + + # Check if cache file exists and is valid + if [[ -f "$cache_file" ]]; then + # Read timestamp from first line + local cache_timestamp=$(head -n 1 "$cache_file" 2>/dev/null) + local current_timestamp=$(date +%s) + local age=$((current_timestamp - cache_timestamp)) + + # Check if cache is still valid + if [[ -n "$cache_timestamp" ]] && (( age < cache_duration )); then + # Cache valid - read and output escaped options (skip first line and empty lines) + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done < <(tail -n +2 "$cache_file") + return 0 + fi + fi + + # Cache miss or expired - call Node.js + mkdir -p "$cache_dir" 2>/dev/null + local raw_output=$(test-cli autocomplete:options --command=\${cmd_id} --flag=\${flag_name} 2>/dev/null) + + if [[ -n "$raw_output" ]]; then + # Save to cache with timestamp + { + echo "$(date +%s)" + echo "$raw_output" + } > "$cache_file" + + # Output the escaped completions + while IFS= read -r line; do + [[ -n "$line" ]] && _test-cli_escape_comp "$line" + done <<< "$raw_output" + fi + # If no output, return nothing (will fall back to default completion) +} + + _test-cli_app() { local context state state_descr line typeset -A opt_args @@ -484,7 +700,7 @@ _test-cli_app() { case "$state" in cmds) _values "completions" \\ -"execute[execute code]" \\ +"execute[execute code]" ;; args) @@ -507,14 +723,14 @@ _test-cli_app_execute() { case "$state" in cmds) _values "completions" \\ -"code[execute code]" \\ +"code[execute code]" ;; args) case $line[1] in "code") _arguments -S \\ ---help"[Show help for command]" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -529,11 +745,11 @@ _test-cli_deploy() { typeset -A opt_args _arguments -S \\ -"(-a --api-version)"{-a,--api-version}"[]:file:_files" \\ +"(-a --api-version)"{-a,--api-version}"[]: :($(_test-cli_dynamic_comp deploy api-version 86400))" \\ "(-i --ignore-errors)"{-i,--ignore-errors}"[Ignore errors.]" \\ ---json"[Format output as json.]" \\ -"*"{-m,--metadata}"[]:file:_files" \\ ---help"[Show help for command]" \\ +"--json[Format output as json.]" \\ +"*"{-m,--metadata}"[]: :($(_test-cli_dynamic_comp deploy metadata 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" } @@ -548,7 +764,7 @@ _test-cli_deploy() { _test-cli_deploy_flags else _values "completions" \\ -"functions[Deploy a function.]" \\ +"functions[Deploy a function.]" fi ;; @@ -556,8 +772,8 @@ _values "completions" \\ case $line[1] in "functions") _arguments -S \\ -"(-b --branch)"{-b,--branch}"[]:file:_files" \\ ---help"[Show help for command]" \\ +"(-b --branch)"{-b,--branch}"[]: :($(_test-cli_dynamic_comp deploy:functions branch 86400))" \\ +"--help[Show help for command]" \\ "*: :_files" ;; @@ -581,7 +797,7 @@ _test-cli() { _values "completions" \\ "app[execute code]" \\ "deploy[Deploy a project]" \\ -"search[Search for a command]" \\ +"search[Search for a command]" ;; args) diff --git a/test/commands/autocomplete/create.test.ts b/test/commands/autocomplete/create.test.ts index c4455ce3..bc11cd24 100644 --- a/test/commands/autocomplete/create.test.ts +++ b/test/commands/autocomplete/create.test.ts @@ -67,8 +67,11 @@ compinit; `) }) - it('#bashCompletionFunction', () => { - expect(cmd.bashCompletionFunction).to.eq(`#!/usr/bin/env bash + it('#bashCompletionFunction', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - accessing private method for testing + const bashCompletionFunction = await cmd.getBashCompletionFunction() + expect(bashCompletionFunction).to.eq(`#!/usr/bin/env bash _oclif-example_autocomplete() { @@ -77,7 +80,7 @@ _oclif-example_autocomplete() COMPREPLY=() local commands=" -autocomplete --skip-instructions +autocomplete --refresh-cache autocomplete:foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json " @@ -85,15 +88,50 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json if [[ "$cur" != "-"* ]]; then opts=$(printf "$commands" | grep -Eo '^[a-zA-Z0-9:_-]+') else - local __COMP_WORDS - if [[ \${COMP_WORDS[2]} == ":" ]]; then - #subcommand - __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + + local flagName="\${prev#--}" + # Try to get dynamic completions + local dynamicOpts=$(oclif-example autocomplete:options --command="\${__COMP_WORDS}" --flag="\${flagName}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi else - #simple command - __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + local __COMP_WORDS + if [[ \${COMP_WORDS[2]} == ":" ]]; then + #subcommand + __COMP_WORDS=$(printf "%s" "\${COMP_WORDS[@]:1:3}") + else + #simple command + __COMP_WORDS="\${COMP_WORDS[@]:1:1}" + fi + opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi - opts=$(printf "$commands" | grep "\${__COMP_WORDS}" | sed -n "s/^\${__COMP_WORDS} //p") fi _get_comp_words_by_ref -n : cur COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) @@ -118,7 +156,8 @@ complete -o default -F _oclif-example_autocomplete oclif-example\n`) readJson(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../test.oclif.manifest.json')) await spacedPlugin.load() - expect(spacedCmd.bashCompletionFunction).to.eq(`#!/usr/bin/env bash + const bashCompletionFunction = await spacedCmd.getBashCompletionFunction() + expect(bashCompletionFunction).to.eq(`#!/usr/bin/env bash # This function joins an array using a character passed in # e.g. ARRAY=(one two three) -> join_by ":" \${ARRAY[@]} -> "one:two:three" @@ -131,7 +170,7 @@ _oclif-example_autocomplete() COMPREPLY=() local commands=" -autocomplete --skip-instructions +autocomplete --refresh-cache autocomplete:foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json " @@ -184,13 +223,42 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json ${'else '} # Flag - # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") - # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag - normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" + # Check if we're completing a flag value (previous word is a flag) + local prev="\${COMP_WORDS[COMP_CWORD-1]}" + if [[ "$prev" == --* ]] && [[ "$cur" != "-"* ]]; then + # We're completing a flag value, try dynamic completion + # The full CLI command separated by colons + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 2)}")" )" + local flagName="\${prev#--}" + + # Try to get dynamic completions + local dynamicOpts=$(oclif-example autocomplete:options --command="\${normalizedCommand}" --flag="\${flagName}" 2>/dev/null) + + if [[ -n "$dynamicOpts" ]]; then + # Handle dynamic options line-by-line to properly support special characters + # This avoids issues with spaces, dollar signs, and other shell metacharacters + COMPREPLY=() + while IFS= read -r option; do + # Only add options that match the current word being completed + if [[ -z "$cur" ]] || [[ "$option" == "$cur"* ]]; then + COMPREPLY+=("$option") + fi + done <<< "$dynamicOpts" + return 0 + else + # Fall back to file completion + COMPREPLY=($(compgen -f -- "\${cur}")) + return 0 + fi + else + # The full CLI command separated by colons (e.g. "mycli command subcommand --fl" -> "command:subcommand") + # This needs to be defined with $COMP_CWORD-1 as opposed to above because the current "word" on the command line is a flag and the command is everything before the flag + normalizedCommand="$( printf "%s" "$(join_by ":" "\${COMP_WORDS[@]:1:($COMP_CWORD - 1)}")" )" - # The line below finds the command in $commands using grep - # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") - opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + # The line below finds the command in $commands using grep + # Then, using sed, it removes everything from the found command before the --flags (e.g. "command:subcommand:subsubcom --flag1 --flag2" -> "--flag1 --flag2") + opts=$(printf "%s " "\${commands[@]}" | grep "\${normalizedCommand}" | sed -n "s/^\${normalizedCommand} //p") + fi fi COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) @@ -199,9 +267,12 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json complete -F _oclif-example_autocomplete oclif-example\n`) }) - it('#zshCompletionFunction', () => { + it('#zshCompletionFunction', async () => { /* eslint-disable no-useless-escape */ - expect(cmd.zshCompletionFunction).to.eq(`#compdef oclif-example + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - accessing private method for testing + const zshCompletionFunction = await cmd.getZshCompletionFunction() + expect(zshCompletionFunction).to.eq(`#compdef oclif-example _oclif-example () { local _command_id=\${words[2]} @@ -219,7 +290,7 @@ _oclif-example () { case $_command_id in autocomplete) _command_flags=( - "--skip-instructions[don't show installation instructions]" + "--refresh-cache[Refresh cache (ignores displaying instructions)]" ) ;; diff --git a/test/commands/autocomplete/options.test.ts b/test/commands/autocomplete/options.test.ts new file mode 100644 index 00000000..016b6a15 --- /dev/null +++ b/test/commands/autocomplete/options.test.ts @@ -0,0 +1,52 @@ +import {Config} from '@oclif/core' +import {runCommand} from '@oclif/test' +import {expect} from 'chai' + +describe('autocomplete:options', () => { + let config: Config + + before(async () => { + config = await Config.load() + }) + + it('returns empty string for non-existent command', async () => { + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', '--command', 'nonexistent', '--flag', 'someflag'], + config, + ) + expect(stdout).to.equal('') + }) + + it('returns empty string for command without flag', async () => { + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', '--command', 'autocomplete', '--flag', 'nonexistentflag'], + config, + ) + expect(stdout).to.equal('') + }) + + it('returns empty string for flag without completion', async () => { + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', '--command', 'autocomplete', '--flag', 'refresh-cache'], + config, + ) + expect(stdout).to.equal('') + }) + + it('handles errors gracefully', async () => { + // Test with invalid arguments - should return empty string + const {stdout} = await runCommand<{name: string}>( + ['autocomplete:options', '--command', 'invalid:command:that:does:not:exist', '--flag', 'flag'], + config, + ) + // Should return empty string on error + expect(stdout).to.equal('') + }) + + // Note: We can't easily test actual completion results without creating a test command + // with a completion function. The test manifest doesn't include commands with dynamic completions. + // In a real scenario, you would: + // 1. Create a command with a completion function in your plugin + // 2. Add it to the test manifest + // 3. Test that calling options returns the expected results +}) diff --git a/test/test.oclif.manifest.json b/test/test.oclif.manifest.json index 91102112..c5cd26ff 100644 --- a/test/test.oclif.manifest.json +++ b/test/test.oclif.manifest.json @@ -7,17 +7,13 @@ "pluginName": "@oclif/plugin-autocomplete", "pluginType": "core", "aliases": [], - "examples": [ - "$ heroku autocomplete", - "$ heroku autocomplete bash", - "$ heroku autocomplete zsh" - ], + "examples": ["$ heroku autocomplete", "$ heroku autocomplete bash", "$ heroku autocomplete zsh"], "flags": { - "skip-instructions": { - "name": "skip-instructions", + "refresh-cache": { + "name": "refresh-cache", "type": "boolean", - "char": "s", - "description": "don't show installation instructions" + "char": "r", + "description": "Refresh cache (ignores displaying instructions)" } }, "args": [ diff --git a/yarn.lock b/yarn.lock index aac20a3f..694ed8cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1858,6 +1858,30 @@ wordwrap "^1.0.0" wrap-ansi "^7.0.0" +"@oclif/core@autocomplete": + version "4.5.6-autocomplete.0" + resolved "https://registry.yarnpkg.com/@oclif/core/-/core-4.5.6-autocomplete.0.tgz#749f8a5fbdfc62f57e32aef37b6affefb045cdb0" + integrity sha512-PTYvKFHMR/rECFvzAvZqfrq31I8BpIM15qz9+X4BMf3lx9gbYJqPpRi4bOR8ullb6ddUVjDGRENRZQlBznsEgw== + dependencies: + ansi-escapes "^4.3.2" + ansis "^3.17.0" + clean-stack "^3.0.1" + cli-spinners "^2.9.2" + debug "^4.4.3" + ejs "^3.1.10" + get-package-type "^0.1.0" + indent-string "^4.0.0" + is-wsl "^2.2.0" + lilconfig "^3.1.3" + minimatch "^9.0.5" + semver "^7.7.3" + string-width "^4.2.3" + supports-color "^8" + tinyglobby "^0.2.14" + widest-line "^3.1.0" + wordwrap "^1.0.0" + wrap-ansi "^7.0.0" + "@oclif/plugin-help@^6", "@oclif/plugin-help@^6.2.33": version "6.2.33" resolved "https://registry.yarnpkg.com/@oclif/plugin-help/-/plugin-help-6.2.33.tgz#931dc79b09e11ba50186a9846a2cf5a42a99e1ea"