From 29bd230fe7a66c5136dd86f86928ad6abcedb62d Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Wed, 30 Jul 2025 00:46:05 -0300 Subject: [PATCH 01/17] feat: bash rewrite --- src/autocomplete/bash-spaces.ts | 374 ++++++++++++++++++++++++++-- src/commands/autocomplete/create.ts | 34 +-- 2 files changed, 375 insertions(+), 33 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 2bd3ad98..184b2958 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -1,17 +1,50 @@ -const script = `#!/usr/bin/env bash +import {Command, Config, Interfaces} from '@oclif/core' +import * as ejs from 'ejs' + +type CommandCompletion = { + flags: CommandFlags + id: string + summary: string +} + +type CommandFlags = { + [name: string]: Command.Flag.Cached +} + +type Topic = { + description: string + name: string +} + +export default class BashCompWithSpaces { + protected config: Config + private commands: CommandCompletion[] + private topics: Topic[] + + constructor(config: Config) { + this.config = config + this.topics = this.getTopics() + this.commands = this.getCommands() + } + + public generate(): string { + const commandsWithFlags = this.generateCommandsWithFlags() + const flagCompletionCases = this.generateFlagCompletionCases() + + return `#!/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" function join_by { local IFS="$1"; shift; echo "$*"; } -__autocomplete() +_${this.config.bin}_autocomplete() { - local cur="\${COMP_WORDS[COMP_CWORD]}" opts normalizedCommand colonPrefix IFS=$' \\t\\n' + local prev="\${COMP_WORDS[COMP_CWORD-1]}" COMPREPLY=() local commands=" - +${commandsWithFlags} " function __trim_colon_commands() @@ -37,14 +70,57 @@ __autocomplete() done } + # Check if we're completing a flag value (only for flags that actually take values) + if [[ "$prev" =~ ^(-[a-zA-Z]|--[a-zA-Z0-9-]+)$ ]]; then + # Get the command path (everything except the current word and previous flag) + local cmd_words=() + for ((i=1; i "command:subcom") - normalizedCommand="$( printf "%s" "$(join_by ":" "\${__COMP_WORDS[@]}")" )" + normalizedCommand="$( printf "%s" "$(join_by ":" "\${clean_words[@]}")" )" - # The command hirarchy, with colons, leading up to the last subcommand entered (e.g. "mycli com subcommand subsubcom" -> "com:subcommand:") + # The command hierarchy, with colons, leading up to the last subcommand entered colonPrefix="\${normalizedCommand%"\${normalizedCommand##*:}"}" if [[ -z "$normalizedCommand" ]]; then @@ -57,24 +133,286 @@ __autocomplete() # Trim higher level and subcommands from the subcommands to suggest __trim_colon_commands "$colonPrefix" - opts=$(printf "%s " "\${commands[@]}") # | grep -Eo '^[a-zA-Z0-9_-]+' + opts=$(printf "%s " "\${commands[@]}") fi - else - # Flag + else + # Handle flag completion OR fallthrough from boolean flag case above + # Flag completion + + # DEBUG: Dump completion state to temp file + echo "=== DEBUG FLAG COMPLETION ===" > /tmp/sf_completion_debug.log + echo "COMP_WORDS: \${COMP_WORDS[@]}" >> /tmp/sf_completion_debug.log + echo "COMP_CWORD: \$COMP_CWORD" >> /tmp/sf_completion_debug.log + echo "cur: $cur" >> /tmp/sf_completion_debug.log + echo "prev: $prev" >> /tmp/sf_completion_debug.log + + # Get the command path (everything except flags and their values) + local cmd_words=() + local i=1 + while [[ i -lt COMP_CWORD ]]; do + if [[ "\${COMP_WORDS[i]}" =~ ^--[a-zA-Z0-9-]+$ ]]; then + # Found a long flag, skip it and its potential value + if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* ]]; then + ((i++)) # Skip the flag value + fi + elif [[ "\${COMP_WORDS[i]}" =~ ^-[a-zA-Z]$ ]]; then + # Found a short flag, skip it and its potential value + if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* ]]; then + ((i++)) # Skip the flag value + fi + elif [[ "\${COMP_WORDS[i]}" != -* ]]; then + # This is a command word (not a flag) + cmd_words+=("\${COMP_WORDS[i]}") + fi + ((i++)) + done + normalizedCommand="$( printf "%s" "$(join_by ":" "\${cmd_words[@]}")" )" + echo "cmd_words: \${cmd_words[@]}" >> /tmp/sf_completion_debug.log + echo "normalizedCommand: $normalizedCommand" >> /tmp/sf_completion_debug.log - # 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)}")" )" + # Get already used flags to avoid suggesting them again + local used_flags=() + local i=1 + while [[ i -lt COMP_CWORD ]]; do + if [[ "\${COMP_WORDS[i]}" =~ ^--[a-zA-Z0-9-]+$ ]]; then + used_flags+=("\${COMP_WORDS[i]}") + # Only skip next word if it's actually a flag value (not starting with - and not empty) + if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* && -n "\${COMP_WORDS[$((i+1))]}" ]]; then + ((i++)) + fi + elif [[ "\${COMP_WORDS[i]}" =~ ^-[a-zA-Z]$ ]]; then + used_flags+=("\${COMP_WORDS[i]}") + # Only skip next word if it's actually a flag value (not starting with - and not empty) + if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* && -n "\${COMP_WORDS[$((i+1))]}" ]]; then + ((i++)) + fi + fi + ((i++)) + done + + echo "used_flags: \${used_flags[@]}" >> /tmp/sf_completion_debug.log - # 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") + # Find the command in $commands and extract its flags + local cmd_line=$(printf "%s\\n" "\${commands[@]}" | grep "^$normalizedCommand ") + if [[ -n "$cmd_line" ]]; then + # Extract flags from the command line + local all_flags=$(echo "$cmd_line" | sed -n "s/^$normalizedCommand //p") + echo "cmd_line: $cmd_line" >> /tmp/sf_completion_debug.log + echo "all_flags: $all_flags" >> /tmp/sf_completion_debug.log + + # Build a mapping of short to long flags for equivalency checking + local flag_pairs=() + local temp_flags=($all_flags) + local j=0 + while [[ j -lt \${#temp_flags[@]} ]]; do + if [[ "\${temp_flags[j]}" =~ ^-[a-zA-Z]$ && $((j+1)) -lt \${#temp_flags[@]} && "\${temp_flags[$((j+1))]}" =~ ^--[a-zA-Z0-9-]+$ ]]; then + flag_pairs+=("\${temp_flags[j]}:\${temp_flags[$((j+1))]}") + ((j += 2)) + else + ((j++)) + fi + done + + # Filter out already used flags (including equivalent short/long forms) + local available_flags=() + for flag in $all_flags; do + local flag_found=false + + # Check direct match + for used_flag in "\${used_flags[@]}"; do + if [[ "$flag" == "$used_flag" ]]; then + flag_found=true + break + fi + done + + # Check equivalent short/long form + if [[ "$flag_found" == false ]]; then + for pair in "\${flag_pairs[@]}"; do + local short_flag="\${pair%:*}" + local long_flag="\${pair#*:}" + for used_flag in "\${used_flags[@]}"; do + if [[ "$flag" == "$short_flag" && "$used_flag" == "$long_flag" ]] || [[ "$flag" == "$long_flag" && "$used_flag" == "$short_flag" ]]; then + flag_found=true + break 2 + fi + done + done + fi + + if [[ "$flag_found" == false ]]; then + available_flags+=("$flag") + fi + done + + echo "flag_pairs: \${flag_pairs[@]}" >> /tmp/sf_completion_debug.log + echo "available_flags: \${available_flags[@]}" >> /tmp/sf_completion_debug.log + opts=$(printf "%s " "\${available_flags[@]}") + else + echo "No cmd_line found for: $normalizedCommand" >> /tmp/sf_completion_debug.log + opts="" + fi + + echo "final opts: $opts" >> /tmp/sf_completion_debug.log fi COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) } -complete -F __autocomplete +complete -F _${this.config.bin}_autocomplete ${this.config.bin} +${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autocomplete ${alias}`).join('\n') ?? ''} ` + } + + private genCmdPublicFlags(command: CommandCompletion): string { + const flags = Object.keys(command.flags) + .filter((flag) => !command.flags[flag].hidden) + .map((flag) => { + const f = command.flags[flag] + const flagStr = f.char ? `-${f.char} --${flag}` : `--${flag}` + return flagStr + }) + + return flags.join(' ') + } + + private generateCommandsWithFlags(): string { + return this.commands + .map((c) => { + const publicFlags = this.genCmdPublicFlags(c).trim() + // Keep colon-separated format for internal bash completion logic + return `${c.id} ${publicFlags}` + }) + .join('\n') + } + + private generateFlagCompletionCases(): string { + const cases: string[] = [] + + for (const cmd of this.commands) { + const flagCases: string[] = [] + + for (const [flagName, flag] of Object.entries(cmd.flags)) { + if (flag.hidden) continue + + if (flag.type === 'option' && flag.options) { + const options = flag.options.join(' ') + + // Handle both long and short flag forms + if (flag.char) { + flagCases.push( + ` if [[ "$prev" == "--${flagName}" || "$prev" == "-${flag.char}" ]]; then`, + ` opts="${options}"`, + ` has_flag_values=true`, + ` fi`, + ) + } else { + flagCases.push( + ` if [[ "$prev" == "--${flagName}" ]]; then`, + ` opts="${options}"`, + ` has_flag_values=true`, + ` fi`, + ) + } + } + } + + if (flagCases.length > 0) { + // Convert colon-separated command IDs to space-separated for SF CLI format + const spaceId = cmd.id.replaceAll(':', ' ') + cases.push(` "${spaceId}")`, ...flagCases, ` ;;`) + } + } + + return cases.join('\n') + } + + private getCommands(): CommandCompletion[] { + const cmds: CommandCompletion[] = [] + + for (const p of this.config.getPluginsList()) { + // For testing: only include commands from @salesforce/plugin-auth + // if (p.name !== '@salesforce/plugin-auth') continue + + for (const c of p.commands) { + if (c.hidden) continue + const summary = this.sanitizeSummary(c.summary ?? c.description) + const {flags} = c + cmds.push({ + flags, + id: c.id, + summary, + }) + + for (const a of c.aliases) { + cmds.push({ + flags, + id: a, + summary, + }) + + const split = a.split(':') + let topic = split[0] + + // Add missing topics for aliases + for (let i = 0; i < split.length - 1; i++) { + if (!this.topics.some((t) => t.name === topic)) { + this.topics.push({ + description: `${topic.replaceAll(':', ' ')} commands`, + name: topic, + }) + } -export default script + topic += `:${split[i + 1]}` + } + } + } + } + + return cmds + } + + private getTopics(): 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 + }) + .sort((a, b) => { + if (a.name < b.name) { + return -1 + } + + if (a.name > b.name) { + return 1 + } + + return 0 + }) + .map((t) => { + const description = t.description + ? this.sanitizeSummary(t.description) + : `${t.name.replaceAll(':', ' ')} commands` + + return { + description, + name: t.name, + } + }) + + return topics + } + + private sanitizeSummary(summary?: string): string { + if (summary === undefined) { + return '' + } + + return ejs + .render(summary, {config: this.config}) + .replaceAll(/(["`])/g, '\\\\\\$1') // backticks and double-quotes require triple-backslashes + .replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes + .split('\n')[0] // only use the first line + } +} diff --git a/src/commands/autocomplete/create.ts b/src/commands/autocomplete/create.ts index 983e3325..df7cd0e8 100644 --- a/src/commands/autocomplete/create.ts +++ b/src/commands/autocomplete/create.ts @@ -2,7 +2,7 @@ import makeDebug from 'debug' import {mkdir, writeFile} from 'node:fs/promises' import path from 'node:path' -import bashAutocompleteWithSpaces from '../../autocomplete/bash-spaces.js' +import BashCompWithSpaces from '../../autocomplete/bash-spaces.js' import bashAutocomplete from '../../autocomplete/bash.js' import PowerShellComp from '../../autocomplete/powershell.js' import ZshCompWithSpaces from '../../autocomplete/zsh.js' @@ -43,21 +43,25 @@ export default class Create extends AutocompleteBase { } 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) - ) + + if (process.env.OCLIF_AUTOCOMPLETE_TOPIC_SEPARATOR === 'colon' || !supportSpaces) { + // Use the old static bash script for colon-separated topics + const {cliBin} = this + return ( + bashAutocomplete + // eslint-disable-next-line unicorn/prefer-spread + .concat( + ...(this.config.binAliases?.map((alias) => `complete -F __autocomplete ${alias}`).join('\n') ?? []), + ) + .replaceAll('', cliBin) + .replaceAll('', this.bashCommandsWithFlagsList) + ) + } + + // Use the new BashCompWithSpaces class for space-separated topics + const bashComp = new BashCompWithSpaces(this.config) + return bashComp.generate() } private get bashCompletionFunctionPath(): string { From a72928d616e843d8dd7118f66b29f5857434f921 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sat, 2 Aug 2025 20:36:37 -0300 Subject: [PATCH 02/17] fix: handle known flag values/support multiple --- package.json | 2 - src/autocomplete/bash-spaces.ts | 144 +++++++++++++++++++++++++++----- yarn.lock | 26 ------ 3 files changed, 121 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index cba0be2c..d3867709 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@types/debug": "^4.1.12", "@types/ejs": "^3.1.5", "@types/mocha": "^10.0.9", - "@types/nock": "^11.1.0", "@types/node": "^18", "chai": "^4", "commitlint": "^19", @@ -30,7 +29,6 @@ "husky": "^9.1.7", "lint-staged": "^15.5.2", "mocha": "^10.8.2", - "nock": "^13.5.6", "nyc": "^15.1.0", "oclif": "^4.22.4", "prettier": "^3.6.2", diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 184b2958..1d99fa9d 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -30,6 +30,7 @@ export default class BashCompWithSpaces { public generate(): string { const commandsWithFlags = this.generateCommandsWithFlags() const flagCompletionCases = this.generateFlagCompletionCases() + const multipleFlagsCases = this.generateMultipleFlagsCases() return `#!/usr/bin/env bash @@ -47,6 +48,19 @@ _${this.config.bin}_autocomplete() ${commandsWithFlags} " + # Function to check if a flag can be specified multiple times + function __is_multiple_flag() + { + local cmd="$1" + local flag="$2" + case "$cmd" in +${multipleFlagsCases} + *) + return 1 + ;; + esac + } + function __trim_colon_commands() { # Turn $commands into an array @@ -70,14 +84,61 @@ ${commandsWithFlags} done } - # Check if we're completing a flag value (only for flags that actually take values) + # Check if we're completing a flag value by looking for the last flag that expects a value + local last_flag_expecting_value="" + local should_complete_flag_value=false + + # Check if the previous word is a flag (simple case) if [[ "$prev" =~ ^(-[a-zA-Z]|--[a-zA-Z0-9-]+)$ ]]; then - # Get the command path (everything except the current word and previous flag) + last_flag_expecting_value="$prev" + should_complete_flag_value=true + else + # Look backwards through the words to find the last flag that might expect a value + for ((i=COMP_CWORD-1; i>=1; i--)); do + local word="\${COMP_WORDS[i]}" + if [[ "$word" =~ ^(-[a-zA-Z]|--[a-zA-Z0-9-]+)$ ]]; then + # Found a flag, check if it expects a value and doesn't have one yet + local flag_has_value=false + if [[ $((i+1)) -lt COMP_CWORD ]]; then + local next_word="\${COMP_WORDS[$((i+1))]}" + if [[ "$next_word" != -* && -n "$next_word" ]]; then + flag_has_value=true + fi + fi + + # If this flag doesn't have a value yet, it might be expecting one + if [[ "$flag_has_value" == false ]]; then + last_flag_expecting_value="$word" + should_complete_flag_value=true + break + fi + elif [[ "$word" != -* ]]; then + # Hit a non-flag word, stop looking + break + fi + done + fi + + if [[ "$should_complete_flag_value" == true ]]; then + # Get the command path (everything except flags and their values) local cmd_words=() - for ((i=1; i> /tmp/sf_completion_debug.log + flag_found=false + else + # Check direct match + for used_flag in "\${used_flags[@]}"; do + if [[ "$flag" == "$used_flag" ]]; then + flag_found=true + break + fi done + + # Check equivalent short/long form + if [[ "$flag_found" == false ]]; then + for pair in "\${flag_pairs[@]}"; do + local short_flag="\${pair%:*}" + local long_flag="\${pair#*:}" + for used_flag in "\${used_flags[@]}"; do + if [[ "$flag" == "$short_flag" && "$used_flag" == "$long_flag" ]] || [[ "$flag" == "$long_flag" && "$used_flag" == "$short_flag" ]]; then + flag_found=true + break 2 + fi + done + done + fi fi if [[ "$flag_found" == false ]]; then @@ -327,6 +396,35 @@ ${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autoco return cases.join('\n') } + private generateMultipleFlagsCases(): string { + const cases: string[] = [] + + for (const cmd of this.commands) { + const multipleFlags: string[] = [] + + for (const [flagName, flag] of Object.entries(cmd.flags)) { + if (flag.hidden) continue + + if ((flag as any).multiple) { + // Handle both long and short flag forms + if (flag.char) { + multipleFlags.push(`"--${flagName}"`, `"-${flag.char}"`) + } else { + multipleFlags.push(`"--${flagName}"`) + } + } + } + + if (multipleFlags.length > 0) { + // Use colon-separated command IDs to match how normalizedCommand is built in flag completion + const flagChecks = multipleFlags.map(flag => `[[ "$flag" == ${flag} ]]`).join(' || ') + cases.push(` "${cmd.id}")`, ` if ${flagChecks}; then return 0; fi`, ` return 1`, ` ;;`) + } + } + + return cases.join('\n') + } + private getCommands(): CommandCompletion[] { const cmds: CommandCompletion[] = [] diff --git a/yarn.lock b/yarn.lock index be8cf791..838dae34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2288,13 +2288,6 @@ dependencies: "@types/node" "*" -"@types/nock@^11.1.0": - version "11.1.0" - resolved "https://registry.yarnpkg.com/@types/nock/-/nock-11.1.0.tgz#0a8c1056a31ba32a959843abccf99626dd90a538" - integrity sha512-jI/ewavBQ7X5178262JQR0ewicPAcJhXS/iFaNJl0VHLfyosZ/kwSrsa6VNQNSO8i9d8SqdRgOtZSOKJ/+iNMw== - dependencies: - nock "*" - "@types/node@*", "@types/node@^22.0.0", "@types/node@^22.5.5": version "22.5.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.5.tgz#52f939dd0f65fc552a4ad0b392f3c466cc5d7a44" @@ -5723,11 +5716,6 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= -json-stringify-safe@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= - json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -6087,15 +6075,6 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" -nock@*, nock@^13.5.6: - version "13.5.6" - resolved "https://registry.yarnpkg.com/nock/-/nock-13.5.6.tgz#5e693ec2300bbf603b61dae6df0225673e6c4997" - integrity sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ== - dependencies: - debug "^4.1.0" - json-stringify-safe "^5.0.1" - propagate "^2.0.0" - node-preload@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/node-preload/-/node-preload-0.2.1.tgz#c03043bb327f417a18fee7ab7ee57b408a144301" @@ -6577,11 +6556,6 @@ process-on-spawn@^1.0.0: dependencies: fromentries "^1.2.0" -propagate@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" - integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" From 37daae2f1acb71b740c316287346c5edd3bc50f4 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:35:54 -0300 Subject: [PATCH 03/17] test: add e2e test for bash completion --- .github/workflows/test.yml | 23 ++ package.json | 1 + src/autocomplete/bash-spaces.ts | 37 +-- test/e2e/bash-completion.test.ts | 424 +++++++++++++++++++++++++++++++ 4 files changed, 469 insertions(+), 16 deletions(-) create mode 100644 test/e2e/bash-completion.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 58755e81..85ecfca4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,3 +13,26 @@ jobs: windows-unit-tests: needs: linux-unit-tests uses: salesforcecli/github-workflows/.github/workflows/unitTestsWindows.yml@main + e2e-bash-spaces-tests: + needs: linux-unit-tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup node + # https://github.com/actions/setup-node/releases/tag/v3.8.1 + uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d + with: + node-version: lts/* + registry-url: 'https://registry.npmjs.org' + cache: yarn + - name: Install sf CLI + run: npm i -g @salesforce/cli + - name: Install dependencies + run: yarn install + - name: Build branch + run: yarn compile + - name: Link plugin + run: sf plugin link --no-install + - name: Run tests + run: yarn test:e2e:bash-spaces + diff --git a/package.json b/package.json index d3867709..05750f38 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "prepare": "husky && yarn build", "pretest": "yarn build && tsc -p test --noEmit", "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "test:e2e:bash-spaces": "mocha --forbid-only test/e2e/bash-completion.test.ts", "version": "oclif readme && git add README.md" }, "type": "module" diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 1d99fa9d..d7408044 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -31,7 +31,7 @@ export default class BashCompWithSpaces { const commandsWithFlags = this.generateCommandsWithFlags() const flagCompletionCases = this.generateFlagCompletionCases() const multipleFlagsCases = this.generateMultipleFlagsCases() - + return `#!/usr/bin/env bash # This function joins an array using a character passed in @@ -204,7 +204,7 @@ ${flagCompletionCases} # DEBUG: Dump completion state to temp file echo "=== DEBUG FLAG COMPLETION ===" > /tmp/sf_completion_debug.log echo "COMP_WORDS: \${COMP_WORDS[@]}" >> /tmp/sf_completion_debug.log - echo "COMP_CWORD: \$COMP_CWORD" >> /tmp/sf_completion_debug.log + echo "COMP_CWORD: $COMP_CWORD" >> /tmp/sf_completion_debug.log echo "cur: $cur" >> /tmp/sf_completion_debug.log echo "prev: $prev" >> /tmp/sf_completion_debug.log @@ -341,7 +341,7 @@ ${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autoco const flagStr = f.char ? `-${f.char} --${flag}` : `--${flag}` return flagStr }) - + return flags.join(' ') } @@ -357,13 +357,13 @@ ${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autoco private generateFlagCompletionCases(): string { const cases: string[] = [] - + for (const cmd of this.commands) { const flagCases: string[] = [] - + for (const [flagName, flag] of Object.entries(cmd.flags)) { if (flag.hidden) continue - + if (flag.type === 'option' && flag.options) { const options = flag.options.join(' ') @@ -385,26 +385,26 @@ ${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autoco } } } - + if (flagCases.length > 0) { // Convert colon-separated command IDs to space-separated for SF CLI format const spaceId = cmd.id.replaceAll(':', ' ') cases.push(` "${spaceId}")`, ...flagCases, ` ;;`) } } - + return cases.join('\n') } private generateMultipleFlagsCases(): string { const cases: string[] = [] - + for (const cmd of this.commands) { const multipleFlags: string[] = [] - + for (const [flagName, flag] of Object.entries(cmd.flags)) { if (flag.hidden) continue - + if ((flag as any).multiple) { // Handle both long and short flag forms if (flag.char) { @@ -414,14 +414,19 @@ ${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autoco } } } - + if (multipleFlags.length > 0) { // Use colon-separated command IDs to match how normalizedCommand is built in flag completion - const flagChecks = multipleFlags.map(flag => `[[ "$flag" == ${flag} ]]`).join(' || ') - cases.push(` "${cmd.id}")`, ` if ${flagChecks}; then return 0; fi`, ` return 1`, ` ;;`) + const flagChecks = multipleFlags.map((flag) => `[[ "$flag" == ${flag} ]]`).join(' || ') + cases.push( + ` "${cmd.id}")`, + ` if ${flagChecks}; then return 0; fi`, + ` return 1`, + ` ;;`, + ) } } - + return cases.join('\n') } @@ -431,7 +436,7 @@ ${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autoco for (const p of this.config.getPluginsList()) { // For testing: only include commands from @salesforce/plugin-auth // if (p.name !== '@salesforce/plugin-auth') continue - + for (const c of p.commands) { if (c.hidden) continue const summary = this.sanitizeSummary(c.summary ?? c.description) diff --git a/test/e2e/bash-completion.test.ts b/test/e2e/bash-completion.test.ts new file mode 100644 index 00000000..791f7b2a --- /dev/null +++ b/test/e2e/bash-completion.test.ts @@ -0,0 +1,424 @@ +import {expect} from 'chai' +import {ChildProcess, exec, spawn} from 'node:child_process' +import {setTimeout as sleep} from 'node:timers/promises' +import {promisify} from 'node:util' + +const execAsync = promisify(exec) + +interface FlagInfo { + char?: string + hidden?: boolean + multiple?: boolean + name: string + options?: string[] + type: 'boolean' | 'option' +} + +interface TestExpectations { + allFlags: string[] // For '-' completion + flagsWithValues: { + // For flag value completion + [flagName: string]: string[] + } + longFlags: string[] // For '--' completion + multipleFlags: string[] // Flags that can be used multiple times +} + +class CommandInfoHelper { + private commandsCache: any[] | null = null + + extractFlags(command: any): FlagInfo[] { + if (!command.flags) return [] + + const flags: FlagInfo[] = [] + + for (const [flagName, flagData] of Object.entries(command.flags as any)) { + const flagDataTyped = flagData as any + const flag: FlagInfo = { + hidden: flagDataTyped.hidden || false, + name: flagName, + type: flagDataTyped.type === 'boolean' ? 'boolean' : 'option', + } + + if (flagDataTyped.char) { + flag.char = flagDataTyped.char + } + + if (flagDataTyped.options && Array.isArray(flagDataTyped.options)) { + flag.options = flagDataTyped.options + } + + if (flagDataTyped.multiple) { + flag.multiple = flagDataTyped.multiple + } + + flags.push(flag) + } + + return flags.filter((flag) => !flag.hidden) + } + + async fetchCommandInfo(): Promise { + if (this.commandsCache) { + return this.commandsCache as any[] + } + + try { + const {stdout} = await execAsync('sf commands --json') + this.commandsCache = JSON.parse(stdout) + return this.commandsCache! + } catch (error) { + throw new Error(`Failed to fetch SF command info: ${error}`) + } + } + + generateExpectations(command: any): TestExpectations { + const flags = this.extractFlags(command) + + const allFlags: string[] = [] + const longFlags: string[] = [] + const flagsWithValues: {[key: string]: string[]} = {} + const multipleFlags: string[] = [] + + for (const flag of flags) { + // Add long flag + const longFlag = `--${flag.name}` + allFlags.push(longFlag) + longFlags.push(longFlag) + + // Add short flag if available + if (flag.char) { + const shortFlag = `-${flag.char}` + allFlags.push(shortFlag) + } + + // Track flags with known values + if (flag.options && flag.options.length > 0) { + flagsWithValues[flag.name] = flag.options + } + + // Track multiple flags + if (flag.multiple) { + multipleFlags.push(longFlag) + if (flag.char) { + multipleFlags.push(`-${flag.char}`) + } + } + } + + return { + allFlags, + flagsWithValues, + longFlags, + multipleFlags, + } + } + + async getCommandById(id: string): Promise { + const commands = await this.fetchCommandInfo() + return commands.find((cmd) => cmd.id === id) || null + } +} + +class BashCompletionHelper { + private bashProcess: ChildProcess | null = null + private lastCommand = '' + private output = '' + private stderr = '' + + async cleanup(): Promise { + if (this.bashProcess) { + this.bashProcess.kill('SIGKILL') + this.bashProcess = null + } + } + + parseCompletionOutput(output: string): string[] { + // Look for our specific completion output pattern + const lines = output.split('\n') + + for (const line of lines) { + if (line.includes('COMPLETIONS:')) { + // Extract completions from our echo output + const match = line.match(/COMPLETIONS:\s*(.*)/) + if (match && match[1]) { + const completionsStr = match[1].trim() + if (completionsStr) { + return completionsStr.split(/\s+/).filter((c) => c.length > 0) + } + } + } + } + + // Fallback to the old parsing method + const completions: string[] = [] + for (const line of lines) { + const trimmedLine = line.trim() + + // Skip empty lines and command echoes + if (!trimmedLine || trimmedLine.startsWith('$') || trimmedLine.startsWith('sf org create scratch')) { + continue + } + + // Check if this line contains multiple completions separated by whitespace + if (trimmedLine.includes('--') || /\s+[a-z-]+\s+/.test(trimmedLine)) { + // Split by multiple whitespaces to get completion items + const tokens = trimmedLine + .split(/\s{2,}/) + .map((t) => t.trim()) + .filter((t) => t.length > 0) + + for (const token of tokens) { + // Extract individual flags/values from each token + const subTokens = token.split(/\s+/) + for (const subToken of subTokens) { + if (subToken.startsWith('--') || (subToken.startsWith('-') && subToken.length === 2)) { + completions.push(subToken) + } else if (/^[a-z][a-z-]*[a-z]?$/.test(subToken)) { + // For flag values like "developer", "enterprise", etc. + completions.push(subToken) + } + } + } + } + } + + // Remove duplicates and return + return [...new Set(completions)] + } + + async sendCommand(command: string): Promise { + if (!this.bashProcess || !this.bashProcess.stdin) { + throw new Error('Bash session not started') + } + + // Store the command for completion + this.lastCommand = command + + // Clear previous output + this.output = '' + this.stderr = '' + + // Send the command (without newline) + this.bashProcess.stdin.write(command) + + // Small delay to ensure command is written + await sleep(100) + } + + async startBashSession(): Promise { + return new Promise((resolve, reject) => { + // this needs to be a non-interactive process to avoid conflicts with the current shell environment. + this.bashProcess = spawn('bash', [], { + detached: false, + env: {...process.env, PS1: '$ '}, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + this.output = '' + this.stderr = '' + + this.bashProcess.stdout?.on('data', (data) => { + this.output += data.toString() + }) + + this.bashProcess.stderr?.on('data', (data) => { + this.stderr += data.toString() + }) + + this.bashProcess.on('error', reject) + + // Wait for bash to initialize and source completion scripts + setTimeout(() => { + // Enable bash completion features for non-interactive mode + this.bashProcess?.stdin?.write(`set +h\n`) + this.bashProcess?.stdin?.write(`shopt -s expand_aliases\n`) + this.bashProcess?.stdin?.write(`shopt -s extglob\n`) + + // Source the SF completion scripts + const homeDir = process.env.HOME + const completionSetup = `${homeDir}/.cache/sf/autocomplete/bash_setup` + this.bashProcess?.stdin?.write(`source ${completionSetup}\n`) + + setTimeout(() => { + resolve() + }, 500) + }, 1000) + }) + } + + async triggerCompletion(): Promise { + if (!this.bashProcess || !this.bashProcess.stdin) { + throw new Error('Bash session not started') + } + + // Clear previous output + this.output = '' + + // Use compgen to simulate completion more reliably + // This approach directly calls the completion function + this.bashProcess.stdin.write('\n') // Clear the current line + await sleep(100) + + // Set COMP_LINE and COMP_POINT and call completion function directly + const currentLine = this.lastCommand || '' + this.bashProcess.stdin.write( + `COMP_LINE="${currentLine}" COMP_POINT=${currentLine.length} COMP_WORDS=(${currentLine + .split(' ') + .map((w) => `"${w}"`) + .join(' ')}) COMP_CWORD=$((${currentLine.split(' ').length} - 1))\n`, + ) + await sleep(100) + + this.bashProcess.stdin.write(`_sf_autocomplete; echo "COMPLETIONS: \${COMPREPLY[@]}"\n`) + + // Wait for completion output + await sleep(1000) + + return this.output + } +} + +describe('Bash Completion E2E Tests', () => { + // Skip tests on unsupported platforms + const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwin' + + let helper: BashCompletionHelper + let commandHelper: CommandInfoHelper + let expectations: TestExpectations + + // Setup command info and refresh cache before all tests + before(async function () { + if (!isLinuxOrMac) { + this.skip() + } + + this.timeout(15_000) + + commandHelper = new CommandInfoHelper() + + try { + // Fetch command metadata + const command = await commandHelper.getCommandById('org:create:scratch') + if (!command) { + this.skip() // Skip if command not found + } + + expectations = commandHelper.generateExpectations(command) + // console.log('Generated expectations:', expectations) + + // Refresh autocomplete cache + await execAsync('sf autocomplete --refresh-cache') + } catch (error) { + console.warn('Could not setup test environment:', error) + this.skip() + } + }) + + beforeEach(() => { + helper = new BashCompletionHelper() + }) + + afterEach(async () => { + await helper.cleanup() + }) + + describe('sf org create scratch', function () { + this.timeout(15_000) // Longer timeout for E2E tests + + it('completes both long and short flags with single dash', async () => { + await helper.startBashSession() + await helper.sendCommand('sf org create scratch -') + const output = await helper.triggerCompletion() + const completions = helper.parseCompletionOutput(output) + + // console.log('Raw output:', JSON.stringify(output)) + // console.log('Completions found:', completions) + + // Check that all expected flags are present + const expectedFlags = expectations.allFlags + const foundFlags = expectedFlags.filter((flag) => completions.includes(flag)) + + expect(foundFlags.length).to.equal( + expectedFlags.length, + `Expected all flags ${expectedFlags.join(', ')} but found: ${foundFlags.join(', ')}. Missing: ${expectedFlags.filter((f) => !foundFlags.includes(f)).join(', ')}`, + ) + }) + + it('completes only long flags with double dash', async () => { + await helper.startBashSession() + await helper.sendCommand('sf org create scratch --') + const output = await helper.triggerCompletion() + const completions = helper.parseCompletionOutput(output) + + // console.log('Completions found:', completions) + + // Should include all long flags + const expectedLongFlags = expectations.longFlags + const foundLongFlags = expectedLongFlags.filter((flag) => completions.includes(flag)) + + // Should NOT include any short flags + const allShortFlags = expectations.allFlags.filter((flag) => flag.startsWith('-') && !flag.startsWith('--')) + const foundShortFlags = allShortFlags.filter((flag) => completions.includes(flag)) + + expect(foundLongFlags.length).to.equal( + expectedLongFlags.length, + `Expected all long flags ${expectedLongFlags.join(', ')} but found: ${foundLongFlags.join(', ')}. Missing: ${expectedLongFlags.filter((f) => !foundLongFlags.includes(f)).join(', ')}`, + ) + + expect(foundShortFlags.length).to.equal(0, `Should not find short flags but found: ${foundShortFlags.join(', ')}`) + }) + + it('completes known flag values', async function () { + // Find the first flag with known values to test + const flagsWithValues = Object.keys(expectations.flagsWithValues) + + if (flagsWithValues.length === 0) { + this.skip() // Skip if no flags have known values + } + + const testFlag = flagsWithValues[0] + const expectedValues = expectations.flagsWithValues[testFlag] + + await helper.startBashSession() + await helper.sendCommand(`sf org create scratch --${testFlag} `) + const output = await helper.triggerCompletion() + const completions = helper.parseCompletionOutput(output) + + // console.log('Completions found:', completions) + + const foundValues = expectedValues.filter((value) => completions.includes(value)) + + expect(foundValues.length).to.equal( + expectedValues.length, + `Expected all values for --${testFlag}: ${expectedValues.join(', ')} but found: ${foundValues.join(', ')}. Missing: ${expectedValues.filter((v) => !foundValues.includes(v)).join(', ')}`, + ) + }) + + it('completes flag values when other flags are present', async function () { + // Find the first flag with known values to test + const flagsWithValues = Object.keys(expectations.flagsWithValues) + + if (flagsWithValues.length === 0) { + this.skip() // Skip if no flags have known values + } + + const testFlag = flagsWithValues[0] + const expectedValues = expectations.flagsWithValues[testFlag] + + await helper.startBashSession() + await helper.sendCommand(`sf org create scratch --json --${testFlag} `) + const output = await helper.triggerCompletion() + const completions = helper.parseCompletionOutput(output) + + // console.log('Completions found:', completions) + + const foundValues = expectedValues.filter((value) => completions.includes(value)) + + expect(foundValues.length).to.equal( + expectedValues.length, + `Expected all values for --${testFlag} with other flags present: ${expectedValues.join(', ')} but found: ${foundValues.join(', ')}. Missing: ${expectedValues.filter((v) => !foundValues.includes(v)).join(', ')}`, + ) + }) + }) +}) From 9565c962d78b56b1922228fc44b777d75a64b7e7 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:45:17 -0300 Subject: [PATCH 04/17] test: remove ut, update script --- package.json | 2 +- test/commands/autocomplete/create.test.ts | 94 ----------------------- 2 files changed, 1 insertion(+), 95 deletions(-) diff --git a/package.json b/package.json index 05750f38..68b19c3d 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "prepack": "yarn build && oclif manifest && oclif readme", "prepare": "husky && yarn build", "pretest": "yarn build && tsc -p test --noEmit", - "test": "mocha --forbid-only \"test/**/*.test.ts\"", + "test": "mocha --forbid-only \"test/**/*.test.ts\" --ignore \"test/e2e/**\"", "test:e2e:bash-spaces": "mocha --forbid-only test/e2e/bash-completion.test.ts", "version": "oclif readme && git add README.md" }, diff --git a/test/commands/autocomplete/create.test.ts b/test/commands/autocomplete/create.test.ts index c4455ce3..d30bcb52 100644 --- a/test/commands/autocomplete/create.test.ts +++ b/test/commands/autocomplete/create.test.ts @@ -105,100 +105,6 @@ foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json complete -o default -F _oclif-example_autocomplete oclif-example\n`) }) - it('#bashCompletionFunction with spaces', async () => { - const spacedConfig = new Config({root}) - - await spacedConfig.load() - spacedConfig.topicSeparator = ' ' - // : any is required for the next two lines otherwise ts will complain about _manifest and bashCompletionFunction being private down below - const spacedCmd: any = new Create([], spacedConfig) - const spacedPlugin: any = new Plugin({root}) - spacedCmd.config.plugins = [spacedPlugin] - spacedPlugin._manifest = () => - 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 - -# This function joins an array using a character passed in -# e.g. ARRAY=(one two three) -> join_by ":" \${ARRAY[@]} -> "one:two:three" -function join_by { local IFS="$1"; shift; echo "$*"; } - -_oclif-example_autocomplete() -{ - - local cur="\${COMP_WORDS[COMP_CWORD]}" opts normalizedCommand colonPrefix IFS=$' \\t\\n' - COMPREPLY=() - - local commands=" -autocomplete --skip-instructions -autocomplete:foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json -foo --bar --baz --dangerous --brackets --double-quotes --multi-line --json -" - - function __trim_colon_commands() - { - # Turn $commands into an array - commands=("\${commands[@]}") - - if [[ -z "$colonPrefix" ]]; then - colonPrefix="$normalizedCommand:" - fi - - # Remove colon-word prefix from $commands - commands=( "\${commands[@]/$colonPrefix}" ) - - for i in "\${!commands[@]}"; do - if [[ "\${commands[$i]}" == "$normalizedCommand" ]]; then - # If the currently typed in command is a topic command we need to remove it to avoid suggesting it again - unset "\${commands[$i]}" - else - # Trim subcommands from each command - commands[$i]="\${commands[$i]%%:*}" - fi - done - } - - if [[ "$cur" != "-"* ]]; then - # Command - __COMP_WORDS=( "\${COMP_WORDS[@]:1}" ) - - # The command typed by the user but separated by colons (e.g. "mycli command subcom" -> "command:subcom") - normalizedCommand="$( printf "%s" "$(join_by ":" "\${__COMP_WORDS[@]}")" )" - - # The command hirarchy, with colons, leading up to the last subcommand entered (e.g. "mycli com subcommand subsubcom" -> "com:subcommand:") - colonPrefix="\${normalizedCommand%"\${normalizedCommand##*:}"}" - - if [[ -z "$normalizedCommand" ]]; then - # If there is no normalizedCommand yet the user hasn't typed in a full command - # So we should trim all subcommands & flags from $commands so we can suggest all top level commands - opts=$(printf "%s " "\${commands[@]}" | grep -Eo '^[a-zA-Z0-9_-]+') - else - # Filter $commands to just the ones that match the $normalizedCommand and turn into an array - commands=( $(compgen -W "$commands" -- "\${normalizedCommand}") ) - # Trim higher level and subcommands from the subcommands to suggest - __trim_colon_commands "$colonPrefix" - - opts=$(printf "%s " "\${commands[@]}") # | grep -Eo '^[a-zA-Z0-9_-]+' - fi - ${'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)}")" )" - - # 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 - - COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) -} - -complete -F _oclif-example_autocomplete oclif-example\n`) - }) - it('#zshCompletionFunction', () => { /* eslint-disable no-useless-escape */ expect(cmd.zshCompletionFunction).to.eq(`#compdef oclif-example From 1d8922115eb8e5f5d152e5db4ee743e656d3fb3f Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:49:55 -0300 Subject: [PATCH 05/17] chore: fix workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 85ecfca4..be875975 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: - name: Build branch run: yarn compile - name: Link plugin - run: sf plugin link --no-install + run: sf plugins link --no-install - name: Run tests run: yarn test:e2e:bash-spaces From 182934a7221ff282811a93908e144b6e050553f1 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sat, 2 Aug 2025 21:59:43 -0300 Subject: [PATCH 06/17] chore: claude debug pls --- .github/workflows/test.yml | 17 +++++++++++++++++ test/e2e/bash-completion.test.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be875975..387e8d01 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,9 @@ jobs: e2e-bash-spaces-tests: needs: linux-unit-tests runs-on: ubuntu-latest + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 - name: Setup node @@ -33,6 +36,20 @@ jobs: run: yarn compile - name: Link plugin run: sf plugins link --no-install + - name: Generate autocomplete cache + run: sf autocomplete --refresh-cache + - name: Install bash completion + run: | + # Ensure bash completion is available + mkdir -p ~/.cache/sf/autocomplete + # Source the completion setup if it exists + if [ -f ~/.cache/sf/autocomplete/bash_setup ]; then + echo "Bash completion setup found" + cat ~/.cache/sf/autocomplete/bash_setup + else + echo "No bash completion setup found" + ls -la ~/.cache/sf/autocomplete/ || echo "Cache directory not found" + fi - name: Run tests run: yarn test:e2e:bash-spaces diff --git a/test/e2e/bash-completion.test.ts b/test/e2e/bash-completion.test.ts index 791f7b2a..4d95330a 100644 --- a/test/e2e/bash-completion.test.ts +++ b/test/e2e/bash-completion.test.ts @@ -238,6 +238,13 @@ class BashCompletionHelper { // Source the SF completion scripts const homeDir = process.env.HOME const completionSetup = `${homeDir}/.cache/sf/autocomplete/bash_setup` + + // Debug: Check if completion setup exists + this.bashProcess?.stdin?.write(`echo "DEBUG: Checking completion setup at ${completionSetup}"\n`) + this.bashProcess?.stdin?.write( + `if [ -f "${completionSetup}" ]; then echo "DEBUG: Completion setup found"; else echo "DEBUG: Completion setup NOT found"; fi\n`, + ) + this.bashProcess?.stdin?.write(`source ${completionSetup}\n`) setTimeout(() => { @@ -272,6 +279,11 @@ class BashCompletionHelper { this.bashProcess.stdin.write(`_sf_autocomplete; echo "COMPLETIONS: \${COMPREPLY[@]}"\n`) + // Debug: Also output the completion function result + this.bashProcess.stdin.write(`echo "DEBUG: COMPREPLY length: \${#COMPREPLY[@]}"\n`) + this.bashProcess.stdin.write(`echo "DEBUG: COMP_WORDS were: \${COMP_WORDS[@]}"\n`) + this.bashProcess.stdin.write(`echo "DEBUG: Current completion function exists: "; type _sf_autocomplete\n`) + // Wait for completion output await sleep(1000) @@ -305,7 +317,16 @@ describe('Bash Completion E2E Tests', () => { } expectations = commandHelper.generateExpectations(command) - // console.log('Generated expectations:', expectations) + + // Debug info for CI + if (process.env.CI) { + console.log('CI Environment detected') + console.log('Generated expectations:', expectations) + console.log('Command found:', command ? 'yes' : 'no') + if (command) { + console.log('Command flags count:', Object.keys(command.flags || {}).length) + } + } // Refresh autocomplete cache await execAsync('sf autocomplete --refresh-cache') @@ -332,8 +353,10 @@ describe('Bash Completion E2E Tests', () => { const output = await helper.triggerCompletion() const completions = helper.parseCompletionOutput(output) - // console.log('Raw output:', JSON.stringify(output)) - // console.log('Completions found:', completions) + if (process.env.CI || completions.length === 0) { + console.log('Raw output:', JSON.stringify(output)) + console.log('Completions found:', completions) + } // Check that all expected flags are present const expectedFlags = expectations.allFlags From e329a61bed52ee407063de22d9d910d28ad09bf4 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:19:09 -0300 Subject: [PATCH 07/17] chore: claude debug pls 2 --- .github/workflows/test.yml | 14 +++---- test/e2e/bash-completion.test.ts | 65 ++++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 387e8d01..24e6477d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,14 +7,14 @@ on: jobs: yarn-lockfile-check: uses: salesforcecli/github-workflows/.github/workflows/lockFileCheck.yml@main - linux-unit-tests: - needs: yarn-lockfile-check - uses: salesforcecli/github-workflows/.github/workflows/unitTestsLinux.yml@main - windows-unit-tests: - needs: linux-unit-tests - uses: salesforcecli/github-workflows/.github/workflows/unitTestsWindows.yml@main + # linux-unit-tests: + # needs: yarn-lockfile-check + # uses: salesforcecli/github-workflows/.github/workflows/unitTestsLinux.yml@main + # windows-unit-tests: + # needs: linux-unit-tests + # uses: salesforcecli/github-workflows/.github/workflows/unitTestsWindows.yml@main e2e-bash-spaces-tests: - needs: linux-unit-tests + # needs: linux-unit-tests runs-on: ubuntu-latest defaults: run: diff --git a/test/e2e/bash-completion.test.ts b/test/e2e/bash-completion.test.ts index 4d95330a..b40ebfc0 100644 --- a/test/e2e/bash-completion.test.ts +++ b/test/e2e/bash-completion.test.ts @@ -219,21 +219,33 @@ class BashCompletionHelper { this.stderr = '' this.bashProcess.stdout?.on('data', (data) => { - this.output += data.toString() + const str = data.toString() + this.output += str + if (process.env.CI) { + console.log('STDOUT:', str) + } }) this.bashProcess.stderr?.on('data', (data) => { - this.stderr += data.toString() + const str = data.toString() + this.stderr += str + if (process.env.CI) { + console.log('STDERR:', str) + } }) this.bashProcess.on('error', reject) // Wait for bash to initialize and source completion scripts setTimeout(() => { + // Test basic responsiveness first + this.bashProcess?.stdin?.write(`echo "DEBUG: Starting bash initialization"\n`) + // Enable bash completion features for non-interactive mode this.bashProcess?.stdin?.write(`set +h\n`) this.bashProcess?.stdin?.write(`shopt -s expand_aliases\n`) this.bashProcess?.stdin?.write(`shopt -s extglob\n`) + this.bashProcess?.stdin?.write(`echo "DEBUG: Bash options set"\n`) // Source the SF completion scripts const homeDir = process.env.HOME @@ -245,7 +257,12 @@ class BashCompletionHelper { `if [ -f "${completionSetup}" ]; then echo "DEBUG: Completion setup found"; else echo "DEBUG: Completion setup NOT found"; fi\n`, ) - this.bashProcess?.stdin?.write(`source ${completionSetup}\n`) + this.bashProcess?.stdin?.write( + `source ${completionSetup} 2>&1 || echo "DEBUG: Failed to source completion setup"\n`, + ) + + // Test that output capture is working + this.bashProcess?.stdin?.write(`echo "DEBUG: Bash session initialized successfully"\n`) setTimeout(() => { resolve() @@ -259,16 +276,21 @@ class BashCompletionHelper { throw new Error('Bash session not started') } - // Clear previous output + // Clear previous output and capture everything this.output = '' - // Use compgen to simulate completion more reliably - // This approach directly calls the completion function - this.bashProcess.stdin.write('\n') // Clear the current line + // Add extensive debugging + this.bashProcess.stdin.write(`echo "=== COMPLETION DEBUG START ==="\n`) await sleep(100) + // Check if completion function exists + this.bashProcess.stdin.write(`echo "Checking completion function:"\n`) + this.bashProcess.stdin.write(`type _sf_autocomplete 2>&1 || echo "Function not found"\n`) + await sleep(200) + // Set COMP_LINE and COMP_POINT and call completion function directly const currentLine = this.lastCommand || '' + this.bashProcess.stdin.write(`echo "Setting completion variables for: ${currentLine}"\n`) this.bashProcess.stdin.write( `COMP_LINE="${currentLine}" COMP_POINT=${currentLine.length} COMP_WORDS=(${currentLine .split(' ') @@ -277,15 +299,26 @@ class BashCompletionHelper { ) await sleep(100) - this.bashProcess.stdin.write(`_sf_autocomplete; echo "COMPLETIONS: \${COMPREPLY[@]}"\n`) - - // Debug: Also output the completion function result - this.bashProcess.stdin.write(`echo "DEBUG: COMPREPLY length: \${#COMPREPLY[@]}"\n`) - this.bashProcess.stdin.write(`echo "DEBUG: COMP_WORDS were: \${COMP_WORDS[@]}"\n`) - this.bashProcess.stdin.write(`echo "DEBUG: Current completion function exists: "; type _sf_autocomplete\n`) - - // Wait for completion output - await sleep(1000) + // Show the variables + this.bashProcess.stdin.write(`echo "COMP_LINE: $COMP_LINE"\n`) + this.bashProcess.stdin.write(`echo "COMP_POINT: $COMP_POINT"\n`) + this.bashProcess.stdin.write(`echo "COMP_WORDS: \${COMP_WORDS[@]}"\n`) + this.bashProcess.stdin.write(`echo "COMP_CWORD: $COMP_CWORD"\n`) + await sleep(200) + + // Try to call the completion function + this.bashProcess.stdin.write(`echo "Calling completion function..."\n`) + this.bashProcess.stdin.write(`_sf_autocomplete 2>&1 || echo "Completion function failed: $?"\n`) + await sleep(200) + + // Show results + this.bashProcess.stdin.write(`echo "COMPREPLY length: \${#COMPREPLY[@]}"\n`) + this.bashProcess.stdin.write(`echo "COMPREPLY contents: \${COMPREPLY[@]}"\n`) + this.bashProcess.stdin.write(`echo "COMPLETIONS: \${COMPREPLY[@]}"\n`) + this.bashProcess.stdin.write(`echo "=== COMPLETION DEBUG END ==="\n`) + + // Wait for all output + await sleep(1500) return this.output } From 9e9071d473cc1bd9865de2339d44a3094acf36d8 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 13:12:52 -0300 Subject: [PATCH 08/17] chore: refactor + fix silent cmd err --- .github/workflows/test.yml | 14 ----- test/e2e/bash-completion.test.ts | 95 +++++++------------------------- 2 files changed, 21 insertions(+), 88 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24e6477d..0c7cd5dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,20 +36,6 @@ jobs: run: yarn compile - name: Link plugin run: sf plugins link --no-install - - name: Generate autocomplete cache - run: sf autocomplete --refresh-cache - - name: Install bash completion - run: | - # Ensure bash completion is available - mkdir -p ~/.cache/sf/autocomplete - # Source the completion setup if it exists - if [ -f ~/.cache/sf/autocomplete/bash_setup ]; then - echo "Bash completion setup found" - cat ~/.cache/sf/autocomplete/bash_setup - else - echo "No bash completion setup found" - ls -la ~/.cache/sf/autocomplete/ || echo "Cache directory not found" - fi - name: Run tests run: yarn test:e2e:bash-spaces diff --git a/test/e2e/bash-completion.test.ts b/test/e2e/bash-completion.test.ts index b40ebfc0..342e1664 100644 --- a/test/e2e/bash-completion.test.ts +++ b/test/e2e/bash-completion.test.ts @@ -198,12 +198,6 @@ class BashCompletionHelper { // Clear previous output this.output = '' this.stderr = '' - - // Send the command (without newline) - this.bashProcess.stdin.write(command) - - // Small delay to ensure command is written - await sleep(100) } async startBashSession(): Promise { @@ -221,7 +215,7 @@ class BashCompletionHelper { this.bashProcess.stdout?.on('data', (data) => { const str = data.toString() this.output += str - if (process.env.CI) { + if (process.env.DEBUG_COMPLETION) { console.log('STDOUT:', str) } }) @@ -229,7 +223,7 @@ class BashCompletionHelper { this.bashProcess.stderr?.on('data', (data) => { const str = data.toString() this.stderr += str - if (process.env.CI) { + if (process.env.DEBUG_COMPLETION) { console.log('STDERR:', str) } }) @@ -238,31 +232,19 @@ class BashCompletionHelper { // Wait for bash to initialize and source completion scripts setTimeout(() => { - // Test basic responsiveness first - this.bashProcess?.stdin?.write(`echo "DEBUG: Starting bash initialization"\n`) + this.bashProcess?.stdin?.write(`echo "INIT: Starting bash setup"\n`) // Enable bash completion features for non-interactive mode this.bashProcess?.stdin?.write(`set +h\n`) this.bashProcess?.stdin?.write(`shopt -s expand_aliases\n`) this.bashProcess?.stdin?.write(`shopt -s extglob\n`) - this.bashProcess?.stdin?.write(`echo "DEBUG: Bash options set"\n`) // Source the SF completion scripts const homeDir = process.env.HOME const completionSetup = `${homeDir}/.cache/sf/autocomplete/bash_setup` + this.bashProcess?.stdin?.write(`source ${completionSetup}\n`) - // Debug: Check if completion setup exists - this.bashProcess?.stdin?.write(`echo "DEBUG: Checking completion setup at ${completionSetup}"\n`) - this.bashProcess?.stdin?.write( - `if [ -f "${completionSetup}" ]; then echo "DEBUG: Completion setup found"; else echo "DEBUG: Completion setup NOT found"; fi\n`, - ) - - this.bashProcess?.stdin?.write( - `source ${completionSetup} 2>&1 || echo "DEBUG: Failed to source completion setup"\n`, - ) - - // Test that output capture is working - this.bashProcess?.stdin?.write(`echo "DEBUG: Bash session initialized successfully"\n`) + this.bashProcess?.stdin?.write(`echo "INIT: Bash setup complete"\n`) setTimeout(() => { resolve() @@ -276,21 +258,14 @@ class BashCompletionHelper { throw new Error('Bash session not started') } - // Clear previous output and capture everything + // Clear previous output this.output = '' - // Add extensive debugging - this.bashProcess.stdin.write(`echo "=== COMPLETION DEBUG START ==="\n`) - await sleep(100) - - // Check if completion function exists - this.bashProcess.stdin.write(`echo "Checking completion function:"\n`) - this.bashProcess.stdin.write(`type _sf_autocomplete 2>&1 || echo "Function not found"\n`) - await sleep(200) + // Clear any previous state + this.bashProcess.stdin.write(`unset COMPREPLY\n`) - // Set COMP_LINE and COMP_POINT and call completion function directly + // Set completion variables and call function directly const currentLine = this.lastCommand || '' - this.bashProcess.stdin.write(`echo "Setting completion variables for: ${currentLine}"\n`) this.bashProcess.stdin.write( `COMP_LINE="${currentLine}" COMP_POINT=${currentLine.length} COMP_WORDS=(${currentLine .split(' ') @@ -299,45 +274,27 @@ class BashCompletionHelper { ) await sleep(100) - // Show the variables - this.bashProcess.stdin.write(`echo "COMP_LINE: $COMP_LINE"\n`) - this.bashProcess.stdin.write(`echo "COMP_POINT: $COMP_POINT"\n`) - this.bashProcess.stdin.write(`echo "COMP_WORDS: \${COMP_WORDS[@]}"\n`) - this.bashProcess.stdin.write(`echo "COMP_CWORD: $COMP_CWORD"\n`) - await sleep(200) - - // Try to call the completion function - this.bashProcess.stdin.write(`echo "Calling completion function..."\n`) - this.bashProcess.stdin.write(`_sf_autocomplete 2>&1 || echo "Completion function failed: $?"\n`) - await sleep(200) - - // Show results - this.bashProcess.stdin.write(`echo "COMPREPLY length: \${#COMPREPLY[@]}"\n`) - this.bashProcess.stdin.write(`echo "COMPREPLY contents: \${COMPREPLY[@]}"\n`) + // Call completion function and capture results + this.bashProcess.stdin.write(`echo "COMP: Calling completion for: ${currentLine}"\n`) + this.bashProcess.stdin.write(`_sf_autocomplete 2>/dev/null || echo "COMP: Function failed"\n`) this.bashProcess.stdin.write(`echo "COMPLETIONS: \${COMPREPLY[@]}"\n`) - this.bashProcess.stdin.write(`echo "=== COMPLETION DEBUG END ==="\n`) - // Wait for all output - await sleep(1500) + // Wait for completion output + await sleep(1000) return this.output } } -describe('Bash Completion E2E Tests', () => { - // Skip tests on unsupported platforms - const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwin' +const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwin' +;(isLinuxOrMac ? describe : describe.skip)('Bash Completion E2E Tests', () => { let helper: BashCompletionHelper let commandHelper: CommandInfoHelper let expectations: TestExpectations // Setup command info and refresh cache before all tests before(async function () { - if (!isLinuxOrMac) { - this.skip() - } - this.timeout(15_000) commandHelper = new CommandInfoHelper() @@ -351,14 +308,9 @@ describe('Bash Completion E2E Tests', () => { expectations = commandHelper.generateExpectations(command) - // Debug info for CI - if (process.env.CI) { - console.log('CI Environment detected') - console.log('Generated expectations:', expectations) - console.log('Command found:', command ? 'yes' : 'no') - if (command) { - console.log('Command flags count:', Object.keys(command.flags || {}).length) - } + // Debug info for CI (minimal) + if (process.env.DEBUG_COMPLETION && process.env.CI) { + console.log(`CI: Found ${Object.keys(command.flags || {}).length} flags for org:create:scratch`) } // Refresh autocomplete cache @@ -386,7 +338,8 @@ describe('Bash Completion E2E Tests', () => { const output = await helper.triggerCompletion() const completions = helper.parseCompletionOutput(output) - if (process.env.CI || completions.length === 0) { + // Only show debug output if test fails + if (completions.length === 0) { console.log('Raw output:', JSON.stringify(output)) console.log('Completions found:', completions) } @@ -407,8 +360,6 @@ describe('Bash Completion E2E Tests', () => { const output = await helper.triggerCompletion() const completions = helper.parseCompletionOutput(output) - // console.log('Completions found:', completions) - // Should include all long flags const expectedLongFlags = expectations.longFlags const foundLongFlags = expectedLongFlags.filter((flag) => completions.includes(flag)) @@ -441,8 +392,6 @@ describe('Bash Completion E2E Tests', () => { const output = await helper.triggerCompletion() const completions = helper.parseCompletionOutput(output) - // console.log('Completions found:', completions) - const foundValues = expectedValues.filter((value) => completions.includes(value)) expect(foundValues.length).to.equal( @@ -467,8 +416,6 @@ describe('Bash Completion E2E Tests', () => { const output = await helper.triggerCompletion() const completions = helper.parseCompletionOutput(output) - // console.log('Completions found:', completions) - const foundValues = expectedValues.filter((value) => completions.includes(value)) expect(foundValues.length).to.equal( From 5a20531b3f76b3ea51e892a922e9a3f4e22209e9 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 20:26:39 -0300 Subject: [PATCH 09/17] chore: refactor to support cmd summaries in comp --- src/autocomplete/bash-spaces.ts | 745 +++++++++++++++++++++----------- 1 file changed, 493 insertions(+), 252 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index d7408044..5b4c6d82 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -31,305 +31,514 @@ export default class BashCompWithSpaces { const commandsWithFlags = this.generateCommandsWithFlags() const flagCompletionCases = this.generateFlagCompletionCases() const multipleFlagsCases = this.generateMultipleFlagsCases() + const topicCompletions = this.generateTopicCompletions() + const topicsMetadata = this.generateTopicsMetadata() + const commandSummaries = this.generateCommandSummaries() return `#!/usr/bin/env bash +# bash completion for ${this.config.bin} -*- shell-script -*- + +__${this.config.bin}_debug() +{ + if [[ -n \${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "\${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__${this.config.bin}_init_completion() +{ + COMPREPLY=() + if declare -F _get_comp_words_by_ref >/dev/null 2>&1; then + _get_comp_words_by_ref "$@" cur prev words cword + else + # Manual initialization when bash-completion is not available + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + words=("\${COMP_WORDS[@]}") + cword=$COMP_CWORD + fi +} + +__${this.config.bin}_handle_completion_types() { + __${this.config.bin}_debug "__${this.config.bin}_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + local tab=$'\\t' comp + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + # Strip any description + comp=\${comp%%$tab*} + # Only consider the completions that match + if [[ $comp == "$cur"* ]]; then + COMPREPLY+=("$comp") + fi + done < <(printf "%s\\n" "\${completions[@]}") + ;; + + *) + # Type: complete (normal completion) + __${this.config.bin}_handle_standard_completion_case + ;; + esac +} + +__${this.config.bin}_handle_standard_completion_case() { + local tab=$'\\t' comp + + # Short circuit to optimize if we don't have descriptions + if [[ "\${completions[*]}" != *$tab* ]]; then + IFS=$'\\n' read -ra COMPREPLY -d '' < <(compgen -W "\${completions[*]}" -- "$cur") + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + # Strip any description before checking the length + comp=\${compline%%$tab*} + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + COMPREPLY+=("$compline") + if ((\${#comp}>longest)); then + longest=\${#comp} + fi + done < <(printf "%s\\n" "\${completions[@]}") + + # If there is a single completion left, remove the description text + if ((\${#COMPREPLY[*]} == 1)); then + __${this.config.bin}_debug "COMPREPLY[0]: \${COMPREPLY[0]}" + comp="\${COMPREPLY[0]%%$tab*}" + __${this.config.bin}_debug "Removed description from single completion, which is now: $comp" + COMPREPLY[0]=$comp + else # Format the descriptions + __${this.config.bin}_format_comp_descriptions $longest + fi +} + +__${this.config.bin}_format_comp_descriptions() +{ + local tab=$'\\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in \${!COMPREPLY[*]}; do + comp=\${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __${this.config.bin}_debug "Original comp: $comp" + desc=\${comp#*$tab} + comp=\${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = \${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - \${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((\${#desc} > maxdesclength)); then + desc=\${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __${this.config.bin}_debug "Final comp: $comp" + fi + done +} + # This function joins an array using a character passed in # e.g. ARRAY=(one two three) -> join_by ":" \${ARRAY[@]} -> "one:two:three" function join_by { local IFS="$1"; shift; echo "$*"; } + _${this.config.bin}_autocomplete() { - local cur="\${COMP_WORDS[COMP_CWORD]}" opts normalizedCommand colonPrefix IFS=$' \\t\\n' - local prev="\${COMP_WORDS[COMP_CWORD-1]}" - COMPREPLY=() + local cur prev words cword split + COMPREPLY=() + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __${this.config.bin}_init_completion -n =: || return + fi + __${this.config.bin}_debug + __${this.config.bin}_debug "========= starting completion logic ==========" + __${this.config.bin}_debug "cur is \${cur}, words[*] is \${words[*]}, #words[@] is \${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("\${words[@]:0:$cword+1}") + __${this.config.bin}_debug "Truncated words[*]: \${words[*]}," - local commands=" + local commands=" ${commandsWithFlags} " - # Function to check if a flag can be specified multiple times - function __is_multiple_flag() - { - local cmd="$1" - local flag="$2" - case "$cmd" in -${multipleFlagsCases} - *) - return 1 - ;; - esac - } + local topics=" +${topicsMetadata} +" - function __trim_colon_commands() - { - # Turn $commands into an array - commands=("\${commands[@]}") + local command_summaries=" +${commandSummaries} +" - if [[ -z "$colonPrefix" ]]; then - colonPrefix="$normalizedCommand:" + local completions=() + __${this.config.bin}_get_completions + + # Force specific completion options + if [[ $(type -t compopt) == builtin ]]; then + compopt -o nosort + compopt +o default fi + + __${this.config.bin}_handle_completion_types +} - # Remove colon-word prefix from $commands - commands=( "\${commands[@]/$colonPrefix}" ) - - for i in "\${!commands[@]}"; do - if [[ "\${commands[$i]}" == "$normalizedCommand" ]]; then - # If the currently typed in command is a topic command we need to remove it to avoid suggesting it again - unset "\${commands[$i]}" - else - # Trim subcommands from each command - commands[$i]="\${commands[$i]%%:*}" - fi - done - } +__${this.config.bin}_get_completions() { + local tab=$'\\t' + completions=() - # Check if we're completing a flag value by looking for the last flag that expects a value - local last_flag_expecting_value="" - local should_complete_flag_value=false - - # Check if the previous word is a flag (simple case) - if [[ "$prev" =~ ^(-[a-zA-Z]|--[a-zA-Z0-9-]+)$ ]]; then - last_flag_expecting_value="$prev" - should_complete_flag_value=true - else - # Look backwards through the words to find the last flag that might expect a value - for ((i=COMP_CWORD-1; i>=1; i--)); do - local word="\${COMP_WORDS[i]}" - if [[ "$word" =~ ^(-[a-zA-Z]|--[a-zA-Z0-9-]+)$ ]]; then - # Found a flag, check if it expects a value and doesn't have one yet - local flag_has_value=false - if [[ $((i+1)) -lt COMP_CWORD ]]; then - local next_word="\${COMP_WORDS[$((i+1))]}" - if [[ "$next_word" != -* && -n "$next_word" ]]; then - flag_has_value=true - fi - fi - - # If this flag doesn't have a value yet, it might be expecting one - if [[ "$flag_has_value" == false ]]; then - last_flag_expecting_value="$word" - should_complete_flag_value=true - break - fi - elif [[ "$word" != -* ]]; then - # Hit a non-flag word, stop looking - break - fi - done - fi - - if [[ "$should_complete_flag_value" == true ]]; then - # Get the command path (everything except flags and their values) - local cmd_words=() + # Get current position in command + local cmd_parts=("\${words[@]:1}") # Remove '${this.config.bin}' from beginning + local num_parts=\${#cmd_parts[@]} + + # If current word is empty but we have a space, we're completing the next argument + if [[ -z "$cur" && $cword -gt 1 ]]; then + num_parts=$((cword - 1)) + else + # If we're typing a word, we're still on that position + num_parts=$((cword - 1)) + fi + + __${this.config.bin}_debug "cmd_parts: \${cmd_parts[*]}" + __${this.config.bin}_debug "num_parts: $num_parts" + __${this.config.bin}_debug "cword: $cword" + __${this.config.bin}_debug "cur: '$cur'" + + # Get clean command words (without flags) + local clean_cmd_parts=() local i=1 - while [[ i -lt COMP_CWORD ]]; do - if [[ "\${COMP_WORDS[i]}" =~ ^--[a-zA-Z0-9-]+$ ]]; then - # Found a long flag, skip it and its potential value - if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* ]]; then - ((i++)) # Skip the flag value - fi - elif [[ "\${COMP_WORDS[i]}" =~ ^-[a-zA-Z]$ ]]; then - # Found a short flag, skip it and its potential value - if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* ]]; then - ((i++)) # Skip the flag value + while [[ i -lt cword ]]; do + local word="\${words[i]}" + if [[ "$word" =~ ^--[a-zA-Z0-9-]+$ ]]; then + # Found a long flag, skip it and its potential value + if [[ $((i+1)) -lt cword && "\${words[$((i+1))]}" != -* && -n "\${words[$((i+1))]}" ]]; then + ((i++)) # Skip the flag value + fi + elif [[ "$word" =~ ^-[a-zA-Z]$ ]]; then + # Found a short flag, skip it and its potential value + if [[ $((i+1)) -lt cword && "\${words[$((i+1))]}" != -* && -n "\${words[$((i+1))]}" ]]; then + ((i++)) # Skip the flag value + fi + elif [[ "$word" != -* ]]; then + # This is a command word (not a flag) + clean_cmd_parts+=("$word") fi - elif [[ "\${COMP_WORDS[i]}" != -* ]]; then - # This is a command word (not a flag) - cmd_words+=("\${COMP_WORDS[i]}") - fi - ((i++)) + ((i++)) done - # Build colon-separated command, then convert to space-separated for case matching - local colonCommand="$( printf "%s" "$(join_by ":" "\${cmd_words[@]}")" )" - normalizedCommand="\${colonCommand//:/ }" - # Handle flag value completion (only if the flag actually has values to complete) - local has_flag_values=false - local prev="$last_flag_expecting_value" - case "$normalizedCommand" in -${flagCompletionCases} - *) - has_flag_values=false - ;; - esac + local cmd_key="\$(join_by " " "\${clean_cmd_parts[@]}")" + __${this.config.bin}_debug "cmd_key: '$cmd_key'" - # If no flag values found, fall through to regular flag completion - if [[ "$has_flag_values" == false ]]; then - # Treat this as regular flag completion instead - normalizedCommand="$colonCommand" - # Fall through to flag completion below (don't return here) - else - # We found flag values, return them and exit early - COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) - return 0 + # Check if we should complete flag values + if __${this.config.bin}_is_completing_flag_value "$cmd_key"; then + local prev_word="\${words[$((cword - 1))]}" + local values + values=$(__${this.config.bin}_get_flag_values "$prev_word" "$cmd_key") + if [[ -n "$values" ]]; then + IFS=',' read -ra value_array <<< "$values" + local value + for value in "\${value_array[@]}"; do + completions+=("\${value}\${tab}\${prev_word} option value") + done + return + fi + # If no known values, don't suggest anything (let user type) + return + fi + + # Check if we should suggest flags + if __${this.config.bin}_should_suggest_flags "$cmd_key"; then + __${this.config.bin}_get_flag_completions "$cmd_key" + return fi - fi - - # Handle command completion - if [[ "$cur" != "-"* ]]; then - # Command completion - __COMP_WORDS=( "\${COMP_WORDS[@]:1}" ) - - # Filter out any flags from the command words - local clean_words=() - for word in "\${__COMP_WORDS[@]}"; do - if [[ "$word" != -* ]]; then - clean_words+=("$word") - fi - done - - # The command typed by the user but separated by colons (e.g. "mycli command subcom" -> "command:subcom") - normalizedCommand="$( printf "%s" "$(join_by ":" "\${clean_words[@]}")" )" - # The command hierarchy, with colons, leading up to the last subcommand entered - colonPrefix="\${normalizedCommand%"\${normalizedCommand##*:}"}" + # Command completion + __${this.config.bin}_get_command_completions +} - if [[ -z "$normalizedCommand" ]]; then - # If there is no normalizedCommand yet the user hasn't typed in a full command - # So we should trim all subcommands & flags from $commands so we can suggest all top level commands - opts=$(printf "%s " "\${commands[@]}" | grep -Eo '^[a-zA-Z0-9_-]+') - else - # Filter $commands to just the ones that match the $normalizedCommand and turn into an array - commands=( $(compgen -W "$commands" -- "\${normalizedCommand}") ) - # Trim higher level and subcommands from the subcommands to suggest - __trim_colon_commands "$colonPrefix" +__${this.config.bin}_is_completing_flag_value() { + # Check if we're completing a value for an option flag + local prev_word="\${words[$((cword - 1))]}" + local cmd_key="$1" + + # Look backwards through the words to find the last flag that might expect a value + for ((i=cword-1; i>=1; i--)); do + local word="\${words[i]}" + if [[ "$word" =~ ^(-[a-zA-Z]|--[a-zA-Z0-9-]+)$ ]]; then + # Found a flag, check if it expects a value and doesn't have one yet + local flag_has_value=false + if [[ $((i+1)) -lt cword ]]; then + local next_word="\${words[$((i+1))]}" + if [[ "$next_word" != -* && -n "$next_word" ]]; then + flag_has_value=true + fi + fi + + # If this flag doesn't have a value yet, it might be expecting one + if [[ "$flag_has_value" == false ]]; then + # Check if this is an option flag (not boolean) + if __${this.config.bin}_flag_expects_value "$word" "$cmd_key"; then + return 0 + fi + fi + break + elif [[ "$word" != -* ]]; then + # Hit a non-flag word, stop looking + break + fi + done + return 1 +} - opts=$(printf "%s " "\${commands[@]}") +__${this.config.bin}_flag_expects_value() { + local flag_name="$1" + local cmd_key="$2" + + # Find the command in $commands and check if the flag is an option type + local cmd_line + cmd_line=$(printf "%s\\n" "$commands" | grep "^\$(echo "$cmd_key" | tr ' ' ':') ") + if [[ -n "$cmd_line" ]]; then + # Check if flag is present (option flags are listed, boolean flags are not in our simplified format) + if [[ "$cmd_line" =~ $flag_name ]]; then + # For now, assume all flags listed expect values (we'll enhance this with metadata later) + return 0 + fi fi - else - # Handle flag completion OR fallthrough from boolean flag case above - # Flag completion + return 1 +} + +__${this.config.bin}_get_flag_values() { + local flag_name="$1" + local cmd_key="$2" - # DEBUG: Dump completion state to temp file - echo "=== DEBUG FLAG COMPLETION ===" > /tmp/sf_completion_debug.log - echo "COMP_WORDS: \${COMP_WORDS[@]}" >> /tmp/sf_completion_debug.log - echo "COMP_CWORD: $COMP_CWORD" >> /tmp/sf_completion_debug.log - echo "cur: $cur" >> /tmp/sf_completion_debug.log - echo "prev: $prev" >> /tmp/sf_completion_debug.log + # This would be populated with actual flag values from command metadata + # For now, return empty to let the existing flag completion logic handle it + echo "" + return 1 +} + +__${this.config.bin}_should_suggest_flags() { + local cmd_key="$1" - # Get the command path (everything except flags and their values) - local cmd_words=() - local i=1 - while [[ i -lt COMP_CWORD ]]; do - if [[ "\${COMP_WORDS[i]}" =~ ^--[a-zA-Z0-9-]+$ ]]; then - # Found a long flag, skip it and its potential value - if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* ]]; then - ((i++)) # Skip the flag value + # Only suggest flags when user explicitly types a dash + if [[ "$cur" == -* ]]; then + # Check if we have a complete command to show flags for + local cmd_line + cmd_line=$(printf "%s\\n" "$commands" | grep "^\$(echo "$cmd_key" | tr ' ' ':') ") + if [[ -n "$cmd_line" ]]; then + return 0 fi - elif [[ "\${COMP_WORDS[i]}" =~ ^-[a-zA-Z]$ ]]; then - # Found a short flag, skip it and its potential value - if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* ]]; then - ((i++)) # Skip the flag value - fi - elif [[ "\${COMP_WORDS[i]}" != -* ]]; then - # This is a command word (not a flag) - cmd_words+=("\${COMP_WORDS[i]}") - fi - ((i++)) - done - normalizedCommand="$( printf "%s" "$(join_by ":" "\${cmd_words[@]}")" )" - echo "cmd_words: \${cmd_words[@]}" >> /tmp/sf_completion_debug.log - echo "normalizedCommand: $normalizedCommand" >> /tmp/sf_completion_debug.log + fi + + return 1 +} - # Get already used flags to avoid suggesting them again +__${this.config.bin}_get_flag_completions() { + local cmd_key="$1" + + # Get already used flags local used_flags=() local i=1 - while [[ i -lt COMP_CWORD ]]; do - if [[ "\${COMP_WORDS[i]}" =~ ^--[a-zA-Z0-9-]+$ ]]; then - used_flags+=("\${COMP_WORDS[i]}") - # Only skip next word if it's actually a flag value (not starting with - and not empty) - if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* && -n "\${COMP_WORDS[$((i+1))]}" ]]; then - ((i++)) - fi - elif [[ "\${COMP_WORDS[i]}" =~ ^-[a-zA-Z]$ ]]; then - used_flags+=("\${COMP_WORDS[i]}") - # Only skip next word if it's actually a flag value (not starting with - and not empty) - if [[ $((i+1)) -lt COMP_CWORD && "\${COMP_WORDS[$((i+1))]}" != -* && -n "\${COMP_WORDS[$((i+1))]}" ]]; then - ((i++)) + while [[ i -lt cword ]]; do + local word="\${words[i]}" + if [[ "$word" =~ ^(-[a-zA-Z]|--[a-zA-Z0-9-]+)$ ]]; then + used_flags+=("$word") + # Skip flag value if present + if [[ $((i+1)) -lt cword && "\${words[$((i+1))]}" != -* && -n "\${words[$((i+1))]}" ]]; then + ((i++)) + fi fi - fi - ((i++)) + ((i++)) done - echo "used_flags: \${used_flags[@]}" >> /tmp/sf_completion_debug.log - - # Find the command in $commands and extract its flags - local cmd_line=$(printf "%s\\n" "\${commands[@]}" | grep "^$normalizedCommand ") + # Find the command and get its flags + local cmd_line + cmd_line=$(printf "%s\\n" "$commands" | grep "^\$(echo "$cmd_key" | tr ' ' ':') ") if [[ -n "$cmd_line" ]]; then - # Extract flags from the command line - local all_flags=$(echo "$cmd_line" | sed -n "s/^$normalizedCommand //p") - echo "cmd_line: $cmd_line" >> /tmp/sf_completion_debug.log - echo "all_flags: $all_flags" >> /tmp/sf_completion_debug.log - - # Build a mapping of short to long flags for equivalency checking - local flag_pairs=() - local temp_flags=($all_flags) - local j=0 - while [[ j -lt \${#temp_flags[@]} ]]; do - if [[ "\${temp_flags[j]}" =~ ^-[a-zA-Z]$ && $((j+1)) -lt \${#temp_flags[@]} && "\${temp_flags[$((j+1))]}" =~ ^--[a-zA-Z0-9-]+$ ]]; then - flag_pairs+=("\${temp_flags[j]}:\${temp_flags[$((j+1))]}") - ((j += 2)) - else - ((j++)) - fi - done - - # Filter out already used flags (including equivalent short/long forms) - local available_flags=() - for flag in $all_flags; do - local flag_found=false + # Extract flags from the command line (simplified - we'll enhance this) + local all_flags + all_flags=$(echo "$cmd_line" | sed -n "s/^\$(echo "$cmd_key" | tr ' ' ':') //p") - # Check if this flag can be specified multiple times - if __is_multiple_flag "$normalizedCommand" "$flag"; then - # Multiple flags are always available - echo "Flag $flag is multiple for command $normalizedCommand" >> /tmp/sf_completion_debug.log - flag_found=false - else - # Check direct match - for used_flag in "\${used_flags[@]}"; do - if [[ "$flag" == "$used_flag" ]]; then - flag_found=true - break - fi - done - - # Check equivalent short/long form - if [[ "$flag_found" == false ]]; then - for pair in "\${flag_pairs[@]}"; do - local short_flag="\${pair%:*}" - local long_flag="\${pair#*:}" - for used_flag in "\${used_flags[@]}"; do - if [[ "$flag" == "$short_flag" && "$used_flag" == "$long_flag" ]] || [[ "$flag" == "$long_flag" && "$used_flag" == "$short_flag" ]]; then - flag_found=true - break 2 + # Add flag completions with descriptions + local flag + for flag in $all_flags; do + # Check if flag is already used + local already_used=false + local used_flag + for used_flag in "\${used_flags[@]}"; do + if [[ "$used_flag" == "$flag" ]]; then + already_used=true + break fi - done done - fi - fi - - if [[ "$flag_found" == false ]]; then - available_flags+=("$flag") + + if [[ "$already_used" == false ]]; then + local tab=$'\\t' + completions+=("\${flag}\${tab}\${flag} flag") + fi + done + fi +} + +__${this.config.bin}_get_command_completions() { + local tab=$'\\t' + + # Get current position in command - need to handle empty cur properly + local clean_cmd_parts=() + local i=1 + + # Build clean command parts by filtering out flags and their values + while [[ i -lt cword ]]; do + local word="\${words[i]}" + if [[ "$word" =~ ^--[a-zA-Z0-9-]+$ ]]; then + # Found a long flag, skip it and its potential value + if [[ $((i+1)) -lt cword && "\${words[$((i+1))]}" != -* && -n "\${words[$((i+1))]}" ]]; then + ((i++)) # Skip the flag value + fi + elif [[ "$word" =~ ^-[a-zA-Z]$ ]]; then + # Found a short flag, skip it and its potential value + if [[ $((i+1)) -lt cword && "\${words[$((i+1))]}" != -* && -n "\${words[$((i+1))]}" ]]; then + ((i++)) # Skip the flag value + fi + elif [[ "$word" != -* && -n "$word" ]]; then + # This is a command word (not a flag and not empty) + clean_cmd_parts+=("$word") fi - done - - echo "flag_pairs: \${flag_pairs[@]}" >> /tmp/sf_completion_debug.log - echo "available_flags: \${available_flags[@]}" >> /tmp/sf_completion_debug.log - opts=$(printf "%s " "\${available_flags[@]}") + ((i++)) + done + + # If we're completing an empty word and not at the beginning, include partially typed cur + if [[ -n "$cur" && "$cur" != -* ]]; then + # We're in the middle of typing a command/topic name + local current_path="\$(join_by ":" "\${clean_cmd_parts[@]}")" else - echo "No cmd_line found for: $normalizedCommand" >> /tmp/sf_completion_debug.log - opts="" + # We're completing the next command/topic after a space + local current_path="\$(join_by ":" "\${clean_cmd_parts[@]}")" fi - echo "final opts: $opts" >> /tmp/sf_completion_debug.log - fi - - COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) + __${this.config.bin}_debug "clean_cmd_parts: \${clean_cmd_parts[*]}" + __${this.config.bin}_debug "current_path: '$current_path'" + + if [[ -z "$current_path" ]]; then + # At root level - show topics +${topicCompletions} + else + # Show matching commands and subtopics + local matching_commands + matching_commands=$(printf "%s\\n" "$commands" | grep "^$current_path:" | head -20) + + __${this.config.bin}_debug "matching_commands for '$current_path:':" + __${this.config.bin}_debug "$matching_commands" + + if [[ -n "$matching_commands" ]]; then + while IFS= read -r cmd_line; do + local cmd_id="\${cmd_line%% *}" + # Remove the current path prefix + local remaining="\${cmd_id#$current_path:}" + # Get just the next segment + local next_segment="\${remaining%%:*}" + + __${this.config.bin}_debug "Processing cmd_id: '$cmd_id', remaining: '$remaining', next_segment: '$next_segment'" + + # Get description for this completion (whether topic or command) + local completion_desc="\${next_segment}" # default fallback + + if [[ -n "$next_segment" && "$next_segment" != "$remaining" ]]; then + # This is a topic/subtopic - look up in topics metadata + local topic_path="\${current_path}:\${next_segment}" + local topic_line + topic_line=$(printf "%s\\\\n" "$topics" | grep "^$topic_path ") + if [[ -n "$topic_line" ]]; then + # Extract description (everything after the topic path and space) + completion_desc="\${topic_line#$topic_path }" + else + completion_desc="\${next_segment} commands" + fi + else + # This is a final command - look up in command summaries + local cmd_path="\${current_path}:\${next_segment}" + local cmd_line + cmd_line=$(printf "%s\\\\n" "$command_summaries" | grep "^$cmd_path ") + if [[ -n "$cmd_line" ]]; then + # Extract summary (everything after the command path and space) + completion_desc="\${cmd_line#$cmd_path }" + else + completion_desc="\${next_segment}" + fi + fi + + completions+=("\${next_segment}\${tab}\${completion_desc}") + done <<< "$matching_commands" + + # Remove duplicates + local unique_completions=() + local seen_completions=() + for comp in "\${completions[@]}"; do + local comp_name="\${comp%%\$'\\t'*}" + local already_seen=false + for seen in "\${seen_completions[@]}"; do + if [[ "$seen" == "$comp_name" ]]; then + already_seen=true + break + fi + done + if [[ "$already_seen" == false ]]; then + unique_completions+=("$comp") + seen_completions+=("$comp_name") + fi + done + completions=("\${unique_completions[@]}") + fi + fi } -complete -F _${this.config.bin}_autocomplete ${this.config.bin} -${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autocomplete ${alias}`).join('\n') ?? ''} +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F _${this.config.bin}_autocomplete ${this.config.bin} +else + complete -o default -o nospace -F _${this.config.bin}_autocomplete ${this.config.bin} +fi +${this.config.binAliases?.map((alias) => `if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F _${this.config.bin}_autocomplete ${alias} +else + complete -o default -o nospace -F _${this.config.bin}_autocomplete ${alias} +fi`).join('\n') ?? ''} ` } @@ -430,6 +639,38 @@ ${this.config.binAliases?.map((alias) => `complete -F _${this.config.bin}_autoco return cases.join('\n') } + private generateTopicCompletions(): string { + const topicLines: string[] = [] + + // Get root level topics + const rootTopics = this.topics.filter(t => !t.name.includes(':')) + + for (const topic of rootTopics) { + const description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` + topicLines.push(` completions+=("${topic.name}\${tab}${description}")`) + } + + return topicLines.join('\n') + } + + private generateTopicsMetadata(): string { + return this.topics + .map((topic) => { + const description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` + return `${topic.name} ${description}` + }) + .join('\n') + } + + private generateCommandSummaries(): string { + return this.commands + .map((cmd) => { + const summary = cmd.summary || cmd.id + return `${cmd.id} ${summary}` + }) + .join('\n') + } + private getCommands(): CommandCompletion[] { const cmds: CommandCompletion[] = [] From a0595ad349c0a1b857c9bb9113fb7b17f87c17d6 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 20:48:03 -0300 Subject: [PATCH 10/17] chore: rename + ignore deprecated cmd aliases --- src/autocomplete/bash-spaces.ts | 46 +++++++++++++++++---------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 5b4c6d82..99ba6a09 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -31,7 +31,7 @@ export default class BashCompWithSpaces { const commandsWithFlags = this.generateCommandsWithFlags() const flagCompletionCases = this.generateFlagCompletionCases() const multipleFlagsCases = this.generateMultipleFlagsCases() - const topicCompletions = this.generateTopicCompletions() + const rootTopicsCompletion = this.generateRootLevelTopics() const topicsMetadata = this.generateTopicsMetadata() const commandSummaries = this.generateCommandSummaries() @@ -458,7 +458,7 @@ __${this.config.bin}_get_command_completions() { if [[ -z "$current_path" ]]; then # At root level - show topics -${topicCompletions} +${rootTopicsCompletion} else # Show matching commands and subtopics local matching_commands @@ -639,7 +639,7 @@ fi`).join('\n') ?? ''} return cases.join('\n') } - private generateTopicCompletions(): string { + private generateRootLevelTopics(): string { const topicLines: string[] = [] // Get root level topics @@ -688,26 +688,28 @@ fi`).join('\n') ?? ''} summary, }) - for (const a of c.aliases) { - cmds.push({ - flags, - id: a, - summary, - }) - - const split = a.split(':') - let topic = split[0] - - // Add missing topics for aliases - for (let i = 0; i < split.length - 1; i++) { - if (!this.topics.some((t) => t.name === topic)) { - this.topics.push({ - description: `${topic.replaceAll(':', ' ')} commands`, - name: topic, - }) + if (!c.deprecateAliases) { + for (const a of c.aliases) { + cmds.push({ + flags, + id: a, + summary, + }) + + const split = a.split(':') + let topic = split[0] + + // Add missing topics for aliases + for (let i = 0; i < split.length - 1; i++) { + if (!this.topics.some((t) => t.name === topic)) { + this.topics.push({ + description: `${topic.replaceAll(':', ' ')} commands`, + name: topic, + }) + } + + topic += `:${split[i + 1]}` } - - topic += `:${split[i + 1]}` } } } From a1376960b35403bffb163a37b8824a9f45cb6112 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 21:00:54 -0300 Subject: [PATCH 11/17] feat: flag comp now includes summaries --- src/autocomplete/bash-spaces.ts | 40 ++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 99ba6a09..c3efa705 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -34,6 +34,7 @@ export default class BashCompWithSpaces { const rootTopicsCompletion = this.generateRootLevelTopics() const topicsMetadata = this.generateTopicsMetadata() const commandSummaries = this.generateCommandSummaries() + const flagMetadata = this.generateFlagMetadata() return `#!/usr/bin/env bash @@ -206,6 +207,10 @@ ${topicsMetadata} local command_summaries=" ${commandSummaries} +" + + local flag_metadata=" +${flagMetadata} " local completions=() @@ -411,7 +416,18 @@ __${this.config.bin}_get_flag_completions() { if [[ "$already_used" == false ]]; then local tab=$'\\t' - completions+=("\${flag}\${tab}\${flag} flag") + local flag_desc="\${flag}" # default fallback + + # Look up flag description from metadata + local flag_key="\$(echo \"$cmd_key\" | tr ' ' ':') \${flag}" + local flag_line + flag_line=$(printf "%s\\\\n" "$flag_metadata" | grep "^$flag_key ") + if [[ -n "$flag_line" ]]; then + # Extract description (everything after the flag key and space) + flag_desc="\${flag_line#$flag_key }" + fi + + completions+=("\${flag}\${tab}\${flag_desc}") fi done fi @@ -671,6 +687,28 @@ fi`).join('\n') ?? ''} .join('\n') } + private generateFlagMetadata(): string { + const flagEntries: string[] = [] + + for (const cmd of this.commands) { + for (const [flagName, flag] of Object.entries(cmd.flags)) { + if (flag.hidden) continue + + const description = this.sanitizeSummary(flag.summary || flag.description || `${flagName} flag`) + + // Add long flag form + flagEntries.push(`${cmd.id} --${flagName} ${description}`) + + // Add short flag form if it exists + if (flag.char) { + flagEntries.push(`${cmd.id} -${flag.char} ${description}`) + } + } + } + + return flagEntries.join('\n') + } + private getCommands(): CommandCompletion[] { const cmds: CommandCompletion[] = [] From 73f70d8de1276c332c92e66cd057d02ceaa3b963 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 21:15:52 -0300 Subject: [PATCH 12/17] perf: improve flag completion time --- src/autocomplete/bash-spaces.ts | 47 ++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index c3efa705..469feca5 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -34,7 +34,7 @@ export default class BashCompWithSpaces { const rootTopicsCompletion = this.generateRootLevelTopics() const topicsMetadata = this.generateTopicsMetadata() const commandSummaries = this.generateCommandSummaries() - const flagMetadata = this.generateFlagMetadata() + const flagMetadataBlocks = this.generateFlagMetadataBlocks() return `#!/usr/bin/env bash @@ -209,9 +209,7 @@ ${topicsMetadata} ${commandSummaries} " - local flag_metadata=" -${flagMetadata} -" +${flagMetadataBlocks} local completions=() __${this.config.bin}_get_completions @@ -418,13 +416,19 @@ __${this.config.bin}_get_flag_completions() { local tab=$'\\t' local flag_desc="\${flag}" # default fallback - # Look up flag description from metadata - local flag_key="\$(echo \"$cmd_key\" | tr ' ' ':') \${flag}" - local flag_line - flag_line=$(printf "%s\\\\n" "$flag_metadata" | grep "^$flag_key ") - if [[ -n "$flag_line" ]]; then - # Extract description (everything after the flag key and space) - flag_desc="\${flag_line#$flag_key }" + # Look up flag description from command-specific metadata + local cmd_var_name="flag_metadata_\$(echo \"$cmd_key\" | tr ' :' '_')" + if [[ -n "\${!cmd_var_name}" ]]; then + # Use pure bash pattern matching - much faster than grep + local metadata=$'\\n'"\${!cmd_var_name}" # Add newline at start for consistent matching + # Look for lines starting with the flag followed by space + local pattern=$'\\n'\${flag}' ' + if [[ "$metadata" == *\${pattern}* ]]; then + # Extract the line starting with our flag + local after_flag="\${metadata#*\${pattern}}" + # Get just the first line (description) + flag_desc="\${after_flag%%$'\\n'*}" + fi fi completions+=("\${flag}\${tab}\${flag_desc}") @@ -687,26 +691,37 @@ fi`).join('\n') ?? ''} .join('\n') } - private generateFlagMetadata(): string { - const flagEntries: string[] = [] + private generateFlagMetadataBlocks(): string { + const blocks: string[] = [] for (const cmd of this.commands) { + const flagEntries: string[] = [] + for (const [flagName, flag] of Object.entries(cmd.flags)) { if (flag.hidden) continue const description = this.sanitizeSummary(flag.summary || flag.description || `${flagName} flag`) // Add long flag form - flagEntries.push(`${cmd.id} --${flagName} ${description}`) + flagEntries.push(`--${flagName} ${description}`) // Add short flag form if it exists if (flag.char) { - flagEntries.push(`${cmd.id} -${flag.char} ${description}`) + flagEntries.push(`-${flag.char} ${description}`) } } + + if (flagEntries.length > 0) { + // Create a valid bash variable name from command ID + const varName = `flag_metadata_${cmd.id.replaceAll(/[^a-zA-Z0-9]/g, '_')}` + + blocks.push(` local ${varName}=" +${flagEntries.join('\n')} +"`) + } } - return flagEntries.join('\n') + return blocks.join('\n\n') } private getCommands(): CommandCompletion[] { From beec6492db950e3028386dcf522b9658ddee1ea7 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 21:25:55 -0300 Subject: [PATCH 13/17] fix: improve string sanitization for bash --- src/autocomplete/bash-spaces.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 469feca5..121fef46 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -810,8 +810,8 @@ ${flagEntries.join('\n')} return ejs .render(summary, {config: this.config}) - .replaceAll(/(["`])/g, '\\\\\\$1') // backticks and double-quotes require triple-backslashes - .replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes + .replaceAll(/(["`])/g, '\\$1') // backticks and double-quotes require backslashes + // .replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes .split('\n')[0] // only use the first line } } From 400b5120acc5dc7fde928fd236f5ce5dcddf95ac Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 23:17:37 -0300 Subject: [PATCH 14/17] fix: complete known flag values --- src/autocomplete/bash-spaces.ts | 66 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 121fef46..c7c79001 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -278,7 +278,7 @@ __${this.config.bin}_get_completions() { IFS=',' read -ra value_array <<< "$values" local value for value in "\${value_array[@]}"; do - completions+=("\${value}\${tab}\${prev_word} option value") + completions+=("\${value}") done return fi @@ -334,14 +334,24 @@ __${this.config.bin}_flag_expects_value() { local flag_name="$1" local cmd_key="$2" - # Find the command in $commands and check if the flag is an option type - local cmd_line - cmd_line=$(printf "%s\\n" "$commands" | grep "^\$(echo "$cmd_key" | tr ' ' ':') ") - if [[ -n "$cmd_line" ]]; then - # Check if flag is present (option flags are listed, boolean flags are not in our simplified format) - if [[ "$cmd_line" =~ $flag_name ]]; then - # For now, assume all flags listed expect values (we'll enhance this with metadata later) - return 0 + # Use the flag metadata to check if flag expects a value + local cmd_var_name="flag_metadata_\$(echo \"$cmd_key\" | tr ' :' '_')" + if [[ -n "\${!cmd_var_name}" ]]; then + # Use pure bash pattern matching to find the flag + local metadata=$'\\n'"\${!cmd_var_name}" + local pattern=$'\\n'\${flag_name}' ' + if [[ "$metadata" == *\${pattern}* ]]; then + # Flag is present in metadata, check if it's an option flag by looking for "|" (has values) + local after_flag="\${metadata#*\${pattern}}" + local flag_line="\${after_flag%%$'\\n'*}" + # If the flag line contains "|", it's an option flag with values + if [[ "$flag_line" == *"|"* ]]; then + return 0 # Flag has option values, definitely expects a value + else + # Flag exists but no "|" - could be boolean or option without predefined values + # For now, assume it expects a value if it's in our metadata (conservative approach) + return 0 + fi fi fi return 1 @@ -351,8 +361,28 @@ __${this.config.bin}_get_flag_values() { local flag_name="$1" local cmd_key="$2" - # This would be populated with actual flag values from command metadata - # For now, return empty to let the existing flag completion logic handle it + # Extract flag option values from the enhanced metadata + local cmd_var_name="flag_metadata_\$(echo \"$cmd_key\" | tr ' :' '_')" + if [[ -n "\${!cmd_var_name}" ]]; then + # Use pure bash pattern matching to find the flag + local metadata=$'\\n'"\${!cmd_var_name}" + local pattern=$'\\n'\${flag_name}' ' + if [[ "$metadata" == *\${pattern}* ]]; then + # Extract the flag line + local after_flag="\${metadata#*\${pattern}}" + local flag_line="\${after_flag%%$'\\n'*}" + + # Check if flag has option values (contains "|") + if [[ "$flag_line" == *"|"* ]]; then + # Extract values after "|" + local values="\${flag_line#*|}" + echo "$values" + return 0 + fi + fi + fi + + # No predefined values found echo "" return 1 } @@ -427,7 +457,9 @@ __${this.config.bin}_get_flag_completions() { # Extract the line starting with our flag local after_flag="\${metadata#*\${pattern}}" # Get just the first line (description) - flag_desc="\${after_flag%%$'\\n'*}" + local flag_line="\${after_flag%%$'\\n'*}" + # Remove option values (everything after "|") for display + flag_desc="\${flag_line%%|*}" fi fi @@ -702,12 +734,18 @@ fi`).join('\n') ?? ''} const description = this.sanitizeSummary(flag.summary || flag.description || `${flagName} flag`) + // Append option values if they exist + let metadataEntry = description + if (flag.type === 'option' && flag.options && flag.options.length > 0) { + metadataEntry += `|${flag.options.join(',')}` + } + // Add long flag form - flagEntries.push(`--${flagName} ${description}`) + flagEntries.push(`--${flagName} ${metadataEntry}`) // Add short flag form if it exists if (flag.char) { - flagEntries.push(`-${flag.char} ${description}`) + flagEntries.push(`-${flag.char} ${metadataEntry}`) } } From 45b2f2ea04986426b3f74afee4ea3aad28100173 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Sun, 3 Aug 2025 23:55:14 -0300 Subject: [PATCH 15/17] fix: some flag position bugs --- src/autocomplete/bash-spaces.ts | 75 +++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index c7c79001..ed5ceaa3 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -341,15 +341,19 @@ __${this.config.bin}_flag_expects_value() { local metadata=$'\\n'"\${!cmd_var_name}" local pattern=$'\\n'\${flag_name}' ' if [[ "$metadata" == *\${pattern}* ]]; then - # Flag is present in metadata, check if it's an option flag by looking for "|" (has values) + # Flag is present in metadata, check if it expects a value local after_flag="\${metadata#*\${pattern}}" local flag_line="\${after_flag%%$'\\n'*}" - # If the flag line contains "|", it's an option flag with values - if [[ "$flag_line" == *"|"* ]]; then - return 0 # Flag has option values, definitely expects a value + + # Check flag type from metadata + if [[ "$flag_line" == *"|@boolean"* ]]; then + return 1 # Boolean flags don't expect values + elif [[ "$flag_line" == *"|@option"* ]] || [[ "$flag_line" == *"|@option-multiple"* ]]; then + return 0 # Option flags expect values + elif [[ "$flag_line" == *"|"* ]]; then + return 0 # Has option values, definitely expects a value else - # Flag exists but no "|" - could be boolean or option without predefined values - # For now, assume it expects a value if it's in our metadata (conservative approach) + # No type marker - fallback: assume option flags expect values return 0 fi fi @@ -374,10 +378,15 @@ __${this.config.bin}_get_flag_values() { # Check if flag has option values (contains "|") if [[ "$flag_line" == *"|"* ]]; then - # Extract values after "|" + # Extract values after first "|" but before any "|@" type markers local values="\${flag_line#*|}" - echo "$values" - return 0 + # Remove type markers (everything from "|@" onwards) + values="\${values%%|@*}" + # Only return values if they exist and don't start with "@" + if [[ -n "$values" && "$values" != @* ]]; then + echo "$values" + return 0 + fi fi fi fi @@ -432,15 +441,32 @@ __${this.config.bin}_get_flag_completions() { # Add flag completions with descriptions local flag for flag in $all_flags; do - # Check if flag is already used + # Check if flag is already used (skip if multiple allowed) local already_used=false - local used_flag - for used_flag in "\${used_flags[@]}"; do - if [[ "$used_flag" == "$flag" ]]; then - already_used=true - break + local flag_allows_multiple=false + + # Check if this flag allows multiple values using pure bash pattern matching + local cmd_var_name="flag_metadata_\$(echo \"$cmd_key\" | tr ' :' '_')" + local metadata=$'\\n'"\${!cmd_var_name}" + local pattern=$'\\n'\${flag}' ' + if [[ "$metadata" == *\${pattern}* ]]; then + local after_flag="\${metadata#*\${pattern}}" + local flag_line="\${after_flag%%$'\\n'*}" + if [[ "$flag_line" == *"|@option-multiple"* ]]; then + flag_allows_multiple=true fi - done + fi + + # Only check for previous usage if flag doesn't allow multiple + if [[ "$flag_allows_multiple" == false ]]; then + local used_flag + for used_flag in "\${used_flags[@]}"; do + if [[ "$used_flag" == "$flag" ]]; then + already_used=true + break + fi + done + fi if [[ "$already_used" == false ]]; then local tab=$'\\t' @@ -458,7 +484,7 @@ __${this.config.bin}_get_flag_completions() { local after_flag="\${metadata#*\${pattern}}" # Get just the first line (description) local flag_line="\${after_flag%%$'\\n'*}" - # Remove option values (everything after "|") for display + # Remove everything after first "|" (option values and type markers) for display flag_desc="\${flag_line%%|*}" fi fi @@ -734,12 +760,25 @@ fi`).join('\n') ?? ''} const description = this.sanitizeSummary(flag.summary || flag.description || `${flagName} flag`) - // Append option values if they exist + // Build metadata entry with flag type information let metadataEntry = description + + // Add option values if they exist if (flag.type === 'option' && flag.options && flag.options.length > 0) { metadataEntry += `|${flag.options.join(',')}` } + // Add flag type marker: @boolean or @option or @option-multiple + if (flag.type === 'boolean') { + metadataEntry += '|@boolean' + } else if (flag.type === 'option') { + if ((flag as any).multiple) { + metadataEntry += '|@option-multiple' + } else { + metadataEntry += '|@option' + } + } + // Add long flag form flagEntries.push(`--${flagName} ${metadataEntry}`) From ac5b1a62f335f96f9e6a9f7c96f42195515e75e6 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Mon, 4 Aug 2025 20:27:21 -0300 Subject: [PATCH 16/17] fix: handle cotopics and fix e2e tests --- src/autocomplete/bash-spaces.ts | 121 +++++++++++++-- test/e2e/bash-completion.test.ts | 249 ++++++++++++++++++++----------- 2 files changed, 273 insertions(+), 97 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index ed5ceaa3..2f7110f9 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -20,6 +20,7 @@ export default class BashCompWithSpaces { protected config: Config private commands: CommandCompletion[] private topics: Topic[] + private _coTopics?: string[] constructor(config: Config) { this.config = config @@ -27,6 +28,23 @@ export default class BashCompWithSpaces { this.commands = this.getCommands() } + private get coTopics(): string[] { + if (this._coTopics) return this._coTopics + + const coTopics: string[] = [] + + for (const topic of this.topics) { + for (const cmd of this.commands) { + if (topic.name === cmd.id) { + coTopics.push(topic.name) + } + } + } + + this._coTopics = coTopics + return this._coTopics + } + public generate(): string { const commandsWithFlags = this.generateCommandsWithFlags() const flagCompletionCases = this.generateFlagCompletionCases() @@ -542,10 +560,26 @@ ${rootTopicsCompletion} local matching_commands matching_commands=$(printf "%s\\n" "$commands" | grep "^$current_path:" | head -20) + # Also include matching topics (for derived topics like org:assign from org:assign:permset) + local matching_topics + matching_topics=$(printf "%s\\n" "$topics" | grep "^$current_path:" | head -20) + + # Combine commands and topics + local all_matches + if [[ -n "$matching_commands" && -n "$matching_topics" ]]; then + all_matches=$(printf "%s\\n%s\\n" "$matching_commands" "$matching_topics") + elif [[ -n "$matching_commands" ]]; then + all_matches="$matching_commands" + elif [[ -n "$matching_topics" ]]; then + all_matches="$matching_topics" + fi + __${this.config.bin}_debug "matching_commands for '$current_path:':" __${this.config.bin}_debug "$matching_commands" + __${this.config.bin}_debug "matching_topics for '$current_path:':" + __${this.config.bin}_debug "$matching_topics" - if [[ -n "$matching_commands" ]]; then + if [[ -n "$all_matches" ]]; then while IFS= read -r cmd_line; do local cmd_id="\${cmd_line%% *}" # Remove the current path prefix @@ -583,7 +617,7 @@ ${rootTopicsCompletion} fi completions+=("\${next_segment}\${tab}\${completion_desc}") - done <<< "$matching_commands" + done <<< "$all_matches" # Remove duplicates local unique_completions=() @@ -719,25 +753,51 @@ fi`).join('\n') ?? ''} private generateRootLevelTopics(): string { const topicLines: string[] = [] + const addedItems = new Set() // Get root level topics const rootTopics = this.topics.filter(t => !t.name.includes(':')) for (const topic of rootTopics) { - const description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` - topicLines.push(` completions+=("${topic.name}\${tab}${description}")`) + if (!addedItems.has(topic.name)) { + const description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` + topicLines.push(` completions+=("${topic.name}\${tab}${description}")`) + addedItems.add(topic.name) + } + } + + // Also add root-level commands (commands without colons) - avoid duplicates + const rootCommands = this.commands.filter(c => !c.id.includes(':')) + + for (const command of rootCommands) { + if (!addedItems.has(command.id)) { + const description = command.summary || `${command.id} command` + topicLines.push(` completions+=("${command.id}\${tab}${description}")`) + addedItems.add(command.id) + } } return topicLines.join('\n') } private generateTopicsMetadata(): string { - return this.topics - .map((topic) => { - const description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` - return `${topic.name} ${description}` - }) - .join('\n') + const topicsMetadata: string[] = [] + + for (const topic of this.topics) { + let description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` + + // If this is a coTopic (both topic and command), prefer the command description + if (this.coTopics.includes(topic.name)) { + const command = this.commands.find(cmd => cmd.id === topic.name) + if (command && command.summary) { + description = command.summary + } + } + + topicsMetadata.push(`${topic.name} ${description}`) + } + + return topicsMetadata.join('\n') } private generateCommandSummaries(): string { @@ -849,12 +909,47 @@ ${flagEntries.join('\n')} } private getTopics(): Topic[] { - const topics = this.config.topics + // First get explicitly defined topics + const explicitTopics = 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 }) + + // Then derive additional topics from command structure + const derivedTopics = new Map() + + // Get commands directly to avoid circular dependency + const commands = this.config.getPluginsList() + .flatMap(p => p.commands) + .filter(c => !c.hidden) + + for (const command of commands) { + const parts = command.id.split(':') + // Generate all intermediate topic paths + for (let i = 1; i < parts.length; i++) { + const topicPath = parts.slice(0, i).join(':') + if (!derivedTopics.has(topicPath)) { + // Check if this topic already exists in explicit topics + const existingTopic = explicitTopics.find(t => t.name === topicPath) + if (existingTopic) { + derivedTopics.set(topicPath, { + description: existingTopic.description || `${topicPath.replaceAll(':', ' ')} commands`, + name: topicPath + }) + } else { + // Create a new topic for this path + derivedTopics.set(topicPath, { + description: `${topicPath.replaceAll(':', ' ')} commands`, + name: topicPath + }) + } + } + } + } + + // Combine and sort all topics + const allTopics = Array.from(derivedTopics.values()) .sort((a, b) => { if (a.name < b.name) { return -1 @@ -877,7 +972,7 @@ ${flagEntries.join('\n')} } }) - return topics + return allTopics } private sanitizeSummary(summary?: string): string { diff --git a/test/e2e/bash-completion.test.ts b/test/e2e/bash-completion.test.ts index 342e1664..86718fe6 100644 --- a/test/e2e/bash-completion.test.ts +++ b/test/e2e/bash-completion.test.ts @@ -118,6 +118,44 @@ class CommandInfoHelper { const commands = await this.fetchCommandInfo() return commands.find((cmd) => cmd.id === id) || null } + + // Get all actual root-level completions (topics + root commands) from command metadata + async getRootLevelCompletions(): Promise { + const commands = await this.fetchCommandInfo() + const rootItems = new Set() + + for (const command of commands) { + if (command.id && typeof command.id === 'string') { + const parts = command.id.split(':') + if (parts.length > 1) { + // This is a topic with subcommands - add the root topic + rootItems.add(parts[0]) + } else { + // This is a root-level command - add it directly + rootItems.add(command.id) + } + } + } + + return Array.from(rootItems).sort() + } + + async getTopicCommands(topic: string): Promise { + const commands = await this.fetchCommandInfo() + const topicCommands = new Set() + + for (const command of commands) { + if (command.id && typeof command.id === 'string') { + const parts = command.id.split(':') + if (parts.length >= 2 && parts[0] === topic) { + // This is a command under the specified topic + topicCommands.add(parts[1]) + } + } + } + + return Array.from(topicCommands).sort() + } } class BashCompletionHelper { @@ -133,9 +171,11 @@ class BashCompletionHelper { } } - parseCompletionOutput(output: string): string[] { + parseCompletionOutput(output: string): {completions: string[], descriptions: string[]} { // Look for our specific completion output pattern const lines = output.split('\n') + const completions: string[] = [] + const descriptions: string[] = [] for (const line of lines) { if (line.includes('COMPLETIONS:')) { @@ -144,47 +184,72 @@ class BashCompletionHelper { if (match && match[1]) { const completionsStr = match[1].trim() if (completionsStr) { - return completionsStr.split(/\s+/).filter((c) => c.length > 0) + return { + completions: completionsStr.split(/\s+/).filter((c) => c.length > 0), + descriptions: [] + } } } } } - // Fallback to the old parsing method - const completions: string[] = [] + // Parse bash completion output with descriptions for (const line of lines) { const trimmedLine = line.trim() // Skip empty lines and command echoes - if (!trimmedLine || trimmedLine.startsWith('$') || trimmedLine.startsWith('sf org create scratch')) { + if (!trimmedLine || trimmedLine.startsWith('$') || trimmedLine.startsWith('sf ')) { continue } - // Check if this line contains multiple completions separated by whitespace - if (trimmedLine.includes('--') || /\s+[a-z-]+\s+/.test(trimmedLine)) { - // Split by multiple whitespaces to get completion items - const tokens = trimmedLine - .split(/\s{2,}/) - .map((t) => t.trim()) - .filter((t) => t.length > 0) + // Look for completion lines with descriptions in parentheses + // Format: --flag-name (Description text) + const completionMatch = trimmedLine.match(/^(--?[\w-]+)\s+\((.+)\)\s*$/) + if (completionMatch) { + completions.push(completionMatch[1]) + descriptions.push(completionMatch[2]) + continue + } + + // Look for topic/command completions with descriptions + // Format: topic-name\tDescription text + const topicMatch = trimmedLine.match(/^([a-z][\w-]*)\s+(.+)$/) + if (topicMatch && !topicMatch[1].startsWith('-')) { + completions.push(topicMatch[1]) + descriptions.push(topicMatch[2]) + continue + } + + // Look for simple flag completions without descriptions + if (trimmedLine.match(/^--?[\w-]+$/)) { + completions.push(trimmedLine) + continue + } + + // Look for simple flag values (no dashes) + if (trimmedLine.match(/^[a-zA-Z][\w]*$/)) { + completions.push(trimmedLine) + continue + } + // Check for multiple completions on one line (space-separated) + if (trimmedLine.includes('--') || /\s+[a-z-]+\s+/.test(trimmedLine)) { + const tokens = trimmedLine.split(/\s+/).filter((t) => t.length > 0) for (const token of tokens) { - // Extract individual flags/values from each token - const subTokens = token.split(/\s+/) - for (const subToken of subTokens) { - if (subToken.startsWith('--') || (subToken.startsWith('-') && subToken.length === 2)) { - completions.push(subToken) - } else if (/^[a-z][a-z-]*[a-z]?$/.test(subToken)) { - // For flag values like "developer", "enterprise", etc. - completions.push(subToken) - } + if (token.startsWith('--') || (token.startsWith('-') && token.length === 2)) { + completions.push(token) + } else if (/^[a-zA-Z][\w]*$/.test(token)) { + completions.push(token) } } } } // Remove duplicates and return - return [...new Set(completions)] + return { + completions: [...new Set(completions)], + descriptions: [...new Set(descriptions)] + } } async sendCommand(command: string): Promise { @@ -286,9 +351,9 @@ class BashCompletionHelper { } } -const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwin' +const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwin'; -;(isLinuxOrMac ? describe : describe.skip)('Bash Completion E2E Tests', () => { +(isLinuxOrMac ? describe : describe.skip)('Bash Completion E2E Tests', () => { let helper: BashCompletionHelper let commandHelper: CommandInfoHelper let expectations: TestExpectations @@ -329,99 +394,115 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi await helper.cleanup() }) - describe('sf org create scratch', function () { - this.timeout(15_000) // Longer timeout for E2E tests + describe('command and topics', function () { + this.timeout(15_000) + // Note: Descriptions are visible in interactive completion but not in COMPREPLY array + // This is expected behavior - the test validates that completions work correctly - it('completes both long and short flags with single dash', async () => { + it('completes all root-level topics and commands', async () => { + const expectedTopics = await commandHelper.getRootLevelCompletions() + await helper.startBashSession() - await helper.sendCommand('sf org create scratch -') + await helper.sendCommand('sf ') const output = await helper.triggerCompletion() - const completions = helper.parseCompletionOutput(output) + const result = helper.parseCompletionOutput(output) - // Only show debug output if test fails - if (completions.length === 0) { - console.log('Raw output:', JSON.stringify(output)) - console.log('Completions found:', completions) - } + // Assert that ALL root topics are present in completions + const foundTopics = expectedTopics.filter((topic) => result.completions.includes(topic)) + const missingTopics = expectedTopics.filter((topic) => !result.completions.includes(topic)) + + // Sort both arrays for comparison since order may differ + const expectedSorted = [...expectedTopics].sort() + const actualSorted = [...result.completions].sort() - // Check that all expected flags are present - const expectedFlags = expectations.allFlags - const foundFlags = expectedFlags.filter((flag) => completions.includes(flag)) - - expect(foundFlags.length).to.equal( - expectedFlags.length, - `Expected all flags ${expectedFlags.join(', ')} but found: ${foundFlags.join(', ')}. Missing: ${expectedFlags.filter((f) => !foundFlags.includes(f)).join(', ')}`, - ) + expect(actualSorted).to.deep.equal(expectedSorted, `Expected all ${expectedTopics.length} root topics: ${expectedTopics.join(', ')} but found: ${foundTopics.join(', ')}. Missing: ${missingTopics.join(', ')}`) }) - it('completes only long flags with double dash', async () => { + it('completes commands', async () => { + // Get all actual org commands from command metadata + const expectedCommands = await commandHelper.getTopicCommands('org') + await helper.startBashSession() - await helper.sendCommand('sf org create scratch --') + await helper.sendCommand('sf org ') const output = await helper.triggerCompletion() - const completions = helper.parseCompletionOutput(output) - - // Should include all long flags - const expectedLongFlags = expectations.longFlags - const foundLongFlags = expectedLongFlags.filter((flag) => completions.includes(flag)) + const result = helper.parseCompletionOutput(output) - // Should NOT include any short flags - const allShortFlags = expectations.allFlags.filter((flag) => flag.startsWith('-') && !flag.startsWith('--')) - const foundShortFlags = allShortFlags.filter((flag) => completions.includes(flag)) + // Assert that ALL org commands are present in completions + const expectedSorted = [...expectedCommands].sort() + const actualSorted = [...result.completions].sort() - expect(foundLongFlags.length).to.equal( - expectedLongFlags.length, - `Expected all long flags ${expectedLongFlags.join(', ')} but found: ${foundLongFlags.join(', ')}. Missing: ${expectedLongFlags.filter((f) => !foundLongFlags.includes(f)).join(', ')}`, + expect(actualSorted).to.deep.equal(expectedSorted, + `Expected all ${expectedCommands.length} org commands: ${expectedCommands.join(', ')} but found: ${result.completions.join(', ')}` ) - - expect(foundShortFlags.length).to.equal(0, `Should not find short flags but found: ${foundShortFlags.join(', ')}`) }) + }) - it('completes known flag values', async function () { - // Find the first flag with known values to test - const flagsWithValues = Object.keys(expectations.flagsWithValues) + describe('flags', function () { + this.timeout(15_000) // Longer timeout for E2E tests - if (flagsWithValues.length === 0) { - this.skip() // Skip if no flags have known values - } + it('completes both long and short flags on single dash', async () => { + await helper.startBashSession() + await helper.sendCommand('sf org create scratch -') + const output = await helper.triggerCompletion() + const result = helper.parseCompletionOutput(output) - const testFlag = flagsWithValues[0] - const expectedValues = expectations.flagsWithValues[testFlag] + // Check that all expected flags are present + const expectedFlags = expectations.allFlags.sort() + const foundFlags = [...result.completions].sort() + expect(foundFlags).to.deep.equal(expectedFlags, `Expected all flags ${expectedFlags.join(', ')} but found: ${foundFlags.join(', ')}.`) + }) + + it('completes only long flags with descriptions on double dash', async () => { + await helper.startBashSession() + await helper.sendCommand('sf org create scratch --') + const output = await helper.triggerCompletion() + const result = helper.parseCompletionOutput(output) + + // Should include all long flags + const expectedLongFlags = expectations.longFlags.sort() + const foundFlags = result.completions.sort() + expect(foundFlags).to.deep.equal(expectedLongFlags, `Expected only long flags ${expectedLongFlags.join(', ')} but found: ${foundFlags.join(', ')}.`) + }) + it('completes known flag values', async function () { await helper.startBashSession() - await helper.sendCommand(`sf org create scratch --${testFlag} `) + await helper.sendCommand(`sf org create scratch --edition `) const output = await helper.triggerCompletion() - const completions = helper.parseCompletionOutput(output) + const result = helper.parseCompletionOutput(output) - const foundValues = expectedValues.filter((value) => completions.includes(value)) + const expectedValues = ['developer', 'enterprise', 'group', 'professional', 'partner-developer', 'partner-enterprise', 'partner-group', 'partner-professional'].sort() + const foundValues = result.completions.sort() expect(foundValues.length).to.equal( expectedValues.length, - `Expected all values for --${testFlag}: ${expectedValues.join(', ')} but found: ${foundValues.join(', ')}. Missing: ${expectedValues.filter((v) => !foundValues.includes(v)).join(', ')}`, + `Expected all values for --edition: ${expectedValues.join(', ')} but found: ${foundValues.join(', ')}. Missing: ${expectedValues.filter((v) => !foundValues.includes(v)).join(', ')}`, ) }) - it('completes flag values when other flags are present', async function () { - // Find the first flag with known values to test - const flagsWithValues = Object.keys(expectations.flagsWithValues) + it('completes flags that can be specified multiple times', async function () { + // Test with `sf project deploy start --metadata` which supports being passed multiple times. + await helper.startBashSession() + await helper.sendCommand('sf project deploy start --metadata ApexClass --m') + const output = await helper.triggerCompletion() + const result = helper.parseCompletionOutput(output) - if (flagsWithValues.length === 0) { - this.skip() // Skip if no flags have known values - } + const expectedFlags = ['--manifest', '--metadata', '--metadata-dir'] + const actualSorted = [...result.completions].sort() - const testFlag = flagsWithValues[0] - const expectedValues = expectations.flagsWithValues[testFlag] + expect(actualSorted).to.deep.equal(expectedFlags, 'Should find flags starting with m') + }) + it('completes flags even when boolean flag is present', async () => { await helper.startBashSession() - await helper.sendCommand(`sf org create scratch --json --${testFlag} `) + await helper.sendCommand('sf org create scratch --json --') const output = await helper.triggerCompletion() - const completions = helper.parseCompletionOutput(output) + const result = helper.parseCompletionOutput(output) - const foundValues = expectedValues.filter((value) => completions.includes(value)) + // Should still suggest other flags after boolean --json flag + const expectedFlags = expectations.longFlags.filter(flag => flag !== '--json').sort() + const foundFlags = result.completions.sort() - expect(foundValues.length).to.equal( - expectedValues.length, - `Expected all values for --${testFlag} with other flags present: ${expectedValues.join(', ')} but found: ${foundValues.join(', ')}. Missing: ${expectedValues.filter((v) => !foundValues.includes(v)).join(', ')}`, - ) + expect(foundFlags).to.be.deep.equal(expectedFlags, 'Should suggest other flags after boolean flag') }) }) }) From 0b11d83cc53bdd02d3147b48d7119f4064122532 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez <6853656+cristiand391@users.noreply.github.com> Date: Mon, 4 Aug 2025 20:32:18 -0300 Subject: [PATCH 17/17] chore: fix lint --- src/autocomplete/bash-spaces.ts | 238 +++++++++++-------------------- test/e2e/bash-completion.test.ts | 71 +++++---- 2 files changed, 128 insertions(+), 181 deletions(-) diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 2f7110f9..b7af9aeb 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -18,9 +18,9 @@ type Topic = { export default class BashCompWithSpaces { protected config: Config + private _coTopics?: string[] private commands: CommandCompletion[] private topics: Topic[] - private _coTopics?: string[] constructor(config: Config) { this.config = config @@ -47,13 +47,12 @@ export default class BashCompWithSpaces { public generate(): string { const commandsWithFlags = this.generateCommandsWithFlags() - const flagCompletionCases = this.generateFlagCompletionCases() - const multipleFlagsCases = this.generateMultipleFlagsCases() const rootTopicsCompletion = this.generateRootLevelTopics() const topicsMetadata = this.generateTopicsMetadata() const commandSummaries = this.generateCommandSummaries() const flagMetadataBlocks = this.generateFlagMetadataBlocks() + /* eslint-disable no-useless-escape */ return `#!/usr/bin/env bash # bash completion for ${this.config.bin} -*- shell-script -*- @@ -646,11 +645,17 @@ if [[ $(type -t compopt) = "builtin" ]]; then else complete -o default -o nospace -F _${this.config.bin}_autocomplete ${this.config.bin} fi -${this.config.binAliases?.map((alias) => `if [[ $(type -t compopt) = "builtin" ]]; then +${ + this.config.binAliases + ?.map( + (alias) => `if [[ $(type -t compopt) = "builtin" ]]; then complete -o default -F _${this.config.bin}_autocomplete ${alias} else complete -o default -o nospace -F _${this.config.bin}_autocomplete ${alias} -fi`).join('\n') ?? ''} +fi`, + ) + .join('\n') ?? '' +} ` } @@ -666,6 +671,15 @@ fi`).join('\n') ?? ''} return flags.join(' ') } + private generateCommandSummaries(): string { + return this.commands + .map((cmd) => { + const summary = cmd.summary || cmd.id + return `${cmd.id} ${summary}` + }) + .join('\n') + } + private generateCommandsWithFlags(): string { return this.commands .map((c) => { @@ -676,88 +690,61 @@ fi`).join('\n') ?? ''} .join('\n') } - private generateFlagCompletionCases(): string { - const cases: string[] = [] + private generateFlagMetadataBlocks(): string { + const blocks: string[] = [] for (const cmd of this.commands) { - const flagCases: string[] = [] + const flagEntries: string[] = [] for (const [flagName, flag] of Object.entries(cmd.flags)) { if (flag.hidden) continue - if (flag.type === 'option' && flag.options) { - const options = flag.options.join(' ') - - // Handle both long and short flag forms - if (flag.char) { - flagCases.push( - ` if [[ "$prev" == "--${flagName}" || "$prev" == "-${flag.char}" ]]; then`, - ` opts="${options}"`, - ` has_flag_values=true`, - ` fi`, - ) - } else { - flagCases.push( - ` if [[ "$prev" == "--${flagName}" ]]; then`, - ` opts="${options}"`, - ` has_flag_values=true`, - ` fi`, - ) - } - } - } - - if (flagCases.length > 0) { - // Convert colon-separated command IDs to space-separated for SF CLI format - const spaceId = cmd.id.replaceAll(':', ' ') - cases.push(` "${spaceId}")`, ...flagCases, ` ;;`) - } - } + const description = this.sanitizeSummary(flag.summary || flag.description || `${flagName} flag`) - return cases.join('\n') - } + // Build metadata entry with flag type information + let metadataEntry = description - private generateMultipleFlagsCases(): string { - const cases: string[] = [] + // Add option values if they exist + if (flag.type === 'option' && flag.options && flag.options.length > 0) { + metadataEntry += `|${flag.options.join(',')}` + } - for (const cmd of this.commands) { - const multipleFlags: string[] = [] + // Add flag type marker: @boolean or @option or @option-multiple + if (flag.type === 'boolean') { + metadataEntry += '|@boolean' + } else if (flag.type === 'option') { + metadataEntry += (flag as any).multiple ? '|@option-multiple' : '|@option' + } - for (const [flagName, flag] of Object.entries(cmd.flags)) { - if (flag.hidden) continue + // Add long flag form + flagEntries.push(`--${flagName} ${metadataEntry}`) - if ((flag as any).multiple) { - // Handle both long and short flag forms - if (flag.char) { - multipleFlags.push(`"--${flagName}"`, `"-${flag.char}"`) - } else { - multipleFlags.push(`"--${flagName}"`) - } + // Add short flag form if it exists + if (flag.char) { + flagEntries.push(`-${flag.char} ${metadataEntry}`) } } - if (multipleFlags.length > 0) { - // Use colon-separated command IDs to match how normalizedCommand is built in flag completion - const flagChecks = multipleFlags.map((flag) => `[[ "$flag" == ${flag} ]]`).join(' || ') - cases.push( - ` "${cmd.id}")`, - ` if ${flagChecks}; then return 0; fi`, - ` return 1`, - ` ;;`, - ) + if (flagEntries.length > 0) { + // Create a valid bash variable name from command ID + const varName = `flag_metadata_${cmd.id.replaceAll(/[^a-zA-Z0-9]/g, '_')}` + + blocks.push(` local ${varName}=" +${flagEntries.join('\n')} +"`) } } - return cases.join('\n') + return blocks.join('\n\n') } private generateRootLevelTopics(): string { const topicLines: string[] = [] const addedItems = new Set() - + // Get root level topics - const rootTopics = this.topics.filter(t => !t.name.includes(':')) - + const rootTopics = this.topics.filter((t) => !t.name.includes(':')) + for (const topic of rootTopics) { if (!addedItems.has(topic.name)) { const description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` @@ -765,10 +752,10 @@ fi`).join('\n') ?? ''} addedItems.add(topic.name) } } - + // Also add root-level commands (commands without colons) - avoid duplicates - const rootCommands = this.commands.filter(c => !c.id.includes(':')) - + const rootCommands = this.commands.filter((c) => !c.id.includes(':')) + for (const command of rootCommands) { if (!addedItems.has(command.id)) { const description = command.summary || `${command.id} command` @@ -776,89 +763,28 @@ fi`).join('\n') ?? ''} addedItems.add(command.id) } } - + return topicLines.join('\n') } private generateTopicsMetadata(): string { const topicsMetadata: string[] = [] - + for (const topic of this.topics) { let description = topic.description || `${topic.name.replaceAll(':', ' ')} commands` - + // If this is a coTopic (both topic and command), prefer the command description if (this.coTopics.includes(topic.name)) { - const command = this.commands.find(cmd => cmd.id === topic.name) + const command = this.commands.find((cmd) => cmd.id === topic.name) if (command && command.summary) { description = command.summary } } - + topicsMetadata.push(`${topic.name} ${description}`) } - - return topicsMetadata.join('\n') - } - private generateCommandSummaries(): string { - return this.commands - .map((cmd) => { - const summary = cmd.summary || cmd.id - return `${cmd.id} ${summary}` - }) - .join('\n') - } - - private generateFlagMetadataBlocks(): string { - const blocks: string[] = [] - - for (const cmd of this.commands) { - const flagEntries: string[] = [] - - for (const [flagName, flag] of Object.entries(cmd.flags)) { - if (flag.hidden) continue - - const description = this.sanitizeSummary(flag.summary || flag.description || `${flagName} flag`) - - // Build metadata entry with flag type information - let metadataEntry = description - - // Add option values if they exist - if (flag.type === 'option' && flag.options && flag.options.length > 0) { - metadataEntry += `|${flag.options.join(',')}` - } - - // Add flag type marker: @boolean or @option or @option-multiple - if (flag.type === 'boolean') { - metadataEntry += '|@boolean' - } else if (flag.type === 'option') { - if ((flag as any).multiple) { - metadataEntry += '|@option-multiple' - } else { - metadataEntry += '|@option' - } - } - - // Add long flag form - flagEntries.push(`--${flagName} ${metadataEntry}`) - - // Add short flag form if it exists - if (flag.char) { - flagEntries.push(`-${flag.char} ${metadataEntry}`) - } - } - - if (flagEntries.length > 0) { - // Create a valid bash variable name from command ID - const varName = `flag_metadata_${cmd.id.replaceAll(/[^a-zA-Z0-9]/g, '_')}` - - blocks.push(` local ${varName}=" -${flagEntries.join('\n')} -"`) - } - } - - return blocks.join('\n\n') + return topicsMetadata.join('\n') } private getCommands(): CommandCompletion[] { @@ -910,20 +836,20 @@ ${flagEntries.join('\n')} private getTopics(): Topic[] { // First get explicitly defined topics - const explicitTopics = this.config.topics - .filter((topic: Interfaces.Topic) => { - const hasChild = this.config.topics.some((subTopic) => subTopic.name.includes(`${topic.name}:`)) - return hasChild - }) - + const explicitTopics = this.config.topics.filter((topic: Interfaces.Topic) => { + const hasChild = this.config.topics.some((subTopic) => subTopic.name.includes(`${topic.name}:`)) + return hasChild + }) + // Then derive additional topics from command structure const derivedTopics = new Map() - + // Get commands directly to avoid circular dependency - const commands = this.config.getPluginsList() - .flatMap(p => p.commands) - .filter(c => !c.hidden) - + const commands = this.config + .getPluginsList() + .flatMap((p) => p.commands) + .filter((c) => !c.hidden) + for (const command of commands) { const parts = command.id.split(':') // Generate all intermediate topic paths @@ -931,25 +857,25 @@ ${flagEntries.join('\n')} const topicPath = parts.slice(0, i).join(':') if (!derivedTopics.has(topicPath)) { // Check if this topic already exists in explicit topics - const existingTopic = explicitTopics.find(t => t.name === topicPath) + const existingTopic = explicitTopics.find((t) => t.name === topicPath) if (existingTopic) { derivedTopics.set(topicPath, { description: existingTopic.description || `${topicPath.replaceAll(':', ' ')} commands`, - name: topicPath + name: topicPath, }) } else { // Create a new topic for this path derivedTopics.set(topicPath, { description: `${topicPath.replaceAll(':', ' ')} commands`, - name: topicPath + name: topicPath, }) } } } } - + // Combine and sort all topics - const allTopics = Array.from(derivedTopics.values()) + const allTopics = [...derivedTopics.values()] .sort((a, b) => { if (a.name < b.name) { return -1 @@ -980,10 +906,12 @@ ${flagEntries.join('\n')} return '' } - return ejs - .render(summary, {config: this.config}) - .replaceAll(/(["`])/g, '\\$1') // backticks and double-quotes require backslashes - // .replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes - .split('\n')[0] // only use the first line + return ( + ejs + .render(summary, {config: this.config}) + .replaceAll(/(["`])/g, '\\$1') // backticks and double-quotes require backslashes + // .replaceAll(/([[\]])/g, '\\\\$1') // square brackets require double-backslashes + .split('\n')[0] + ) // only use the first line } } diff --git a/test/e2e/bash-completion.test.ts b/test/e2e/bash-completion.test.ts index 86718fe6..54ff81f9 100644 --- a/test/e2e/bash-completion.test.ts +++ b/test/e2e/bash-completion.test.ts @@ -123,7 +123,7 @@ class CommandInfoHelper { async getRootLevelCompletions(): Promise { const commands = await this.fetchCommandInfo() const rootItems = new Set() - + for (const command of commands) { if (command.id && typeof command.id === 'string') { const parts = command.id.split(':') @@ -136,14 +136,14 @@ class CommandInfoHelper { } } } - - return Array.from(rootItems).sort() + + return [...rootItems].sort() } async getTopicCommands(topic: string): Promise { const commands = await this.fetchCommandInfo() const topicCommands = new Set() - + for (const command of commands) { if (command.id && typeof command.id === 'string') { const parts = command.id.split(':') @@ -153,8 +153,8 @@ class CommandInfoHelper { } } } - - return Array.from(topicCommands).sort() + + return [...topicCommands].sort() } } @@ -171,7 +171,7 @@ class BashCompletionHelper { } } - parseCompletionOutput(output: string): {completions: string[], descriptions: string[]} { + parseCompletionOutput(output: string): {completions: string[]; descriptions: string[]} { // Look for our specific completion output pattern const lines = output.split('\n') const completions: string[] = [] @@ -186,7 +186,7 @@ class BashCompletionHelper { if (completionsStr) { return { completions: completionsStr.split(/\s+/).filter((c) => c.length > 0), - descriptions: [] + descriptions: [], } } } @@ -221,13 +221,13 @@ class BashCompletionHelper { } // Look for simple flag completions without descriptions - if (trimmedLine.match(/^--?[\w-]+$/)) { + if (/^--?[\w-]+$/.test(trimmedLine)) { completions.push(trimmedLine) continue } // Look for simple flag values (no dashes) - if (trimmedLine.match(/^[a-zA-Z][\w]*$/)) { + if (/^[a-zA-Z][\w]*$/.test(trimmedLine)) { completions.push(trimmedLine) continue } @@ -248,7 +248,7 @@ class BashCompletionHelper { // Remove duplicates and return return { completions: [...new Set(completions)], - descriptions: [...new Set(descriptions)] + descriptions: [...new Set(descriptions)], } } @@ -351,9 +351,9 @@ class BashCompletionHelper { } } -const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwin'; +const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwin' -(isLinuxOrMac ? describe : describe.skip)('Bash Completion E2E Tests', () => { +;(isLinuxOrMac ? describe : describe.skip)('Bash Completion E2E Tests', () => { let helper: BashCompletionHelper let commandHelper: CommandInfoHelper let expectations: TestExpectations @@ -401,7 +401,7 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi it('completes all root-level topics and commands', async () => { const expectedTopics = await commandHelper.getRootLevelCompletions() - + await helper.startBashSession() await helper.sendCommand('sf ') const output = await helper.triggerCompletion() @@ -410,18 +410,21 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi // Assert that ALL root topics are present in completions const foundTopics = expectedTopics.filter((topic) => result.completions.includes(topic)) const missingTopics = expectedTopics.filter((topic) => !result.completions.includes(topic)) - + // Sort both arrays for comparison since order may differ const expectedSorted = [...expectedTopics].sort() const actualSorted = [...result.completions].sort() - expect(actualSorted).to.deep.equal(expectedSorted, `Expected all ${expectedTopics.length} root topics: ${expectedTopics.join(', ')} but found: ${foundTopics.join(', ')}. Missing: ${missingTopics.join(', ')}`) + expect(actualSorted).to.deep.equal( + expectedSorted, + `Expected all ${expectedTopics.length} root topics: ${expectedTopics.join(', ')} but found: ${foundTopics.join(', ')}. Missing: ${missingTopics.join(', ')}`, + ) }) - it('completes commands', async () => { + it('completes commands', async () => { // Get all actual org commands from command metadata const expectedCommands = await commandHelper.getTopicCommands('org') - + await helper.startBashSession() await helper.sendCommand('sf org ') const output = await helper.triggerCompletion() @@ -431,8 +434,9 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi const expectedSorted = [...expectedCommands].sort() const actualSorted = [...result.completions].sort() - expect(actualSorted).to.deep.equal(expectedSorted, - `Expected all ${expectedCommands.length} org commands: ${expectedCommands.join(', ')} but found: ${result.completions.join(', ')}` + expect(actualSorted).to.deep.equal( + expectedSorted, + `Expected all ${expectedCommands.length} org commands: ${expectedCommands.join(', ')} but found: ${result.completions.join(', ')}`, ) }) }) @@ -449,7 +453,10 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi // Check that all expected flags are present const expectedFlags = expectations.allFlags.sort() const foundFlags = [...result.completions].sort() - expect(foundFlags).to.deep.equal(expectedFlags, `Expected all flags ${expectedFlags.join(', ')} but found: ${foundFlags.join(', ')}.`) + expect(foundFlags).to.deep.equal( + expectedFlags, + `Expected all flags ${expectedFlags.join(', ')} but found: ${foundFlags.join(', ')}.`, + ) }) it('completes only long flags with descriptions on double dash', async () => { @@ -461,16 +468,28 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi // Should include all long flags const expectedLongFlags = expectations.longFlags.sort() const foundFlags = result.completions.sort() - expect(foundFlags).to.deep.equal(expectedLongFlags, `Expected only long flags ${expectedLongFlags.join(', ')} but found: ${foundFlags.join(', ')}.`) + expect(foundFlags).to.deep.equal( + expectedLongFlags, + `Expected only long flags ${expectedLongFlags.join(', ')} but found: ${foundFlags.join(', ')}.`, + ) }) - it('completes known flag values', async function () { + it('completes known flag values', async () => { await helper.startBashSession() await helper.sendCommand(`sf org create scratch --edition `) const output = await helper.triggerCompletion() const result = helper.parseCompletionOutput(output) - const expectedValues = ['developer', 'enterprise', 'group', 'professional', 'partner-developer', 'partner-enterprise', 'partner-group', 'partner-professional'].sort() + const expectedValues = [ + 'developer', + 'enterprise', + 'group', + 'professional', + 'partner-developer', + 'partner-enterprise', + 'partner-group', + 'partner-professional', + ].sort() const foundValues = result.completions.sort() expect(foundValues.length).to.equal( @@ -479,7 +498,7 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi ) }) - it('completes flags that can be specified multiple times', async function () { + it('completes flags that can be specified multiple times', async () => { // Test with `sf project deploy start --metadata` which supports being passed multiple times. await helper.startBashSession() await helper.sendCommand('sf project deploy start --metadata ApexClass --m') @@ -499,7 +518,7 @@ const isLinuxOrMac = process.platform === 'linux' || process.platform === 'darwi const result = helper.parseCompletionOutput(output) // Should still suggest other flags after boolean --json flag - const expectedFlags = expectations.longFlags.filter(flag => flag !== '--json').sort() + const expectedFlags = expectations.longFlags.filter((flag) => flag !== '--json').sort() const foundFlags = result.completions.sort() expect(foundFlags).to.be.deep.equal(expectedFlags, 'Should suggest other flags after boolean flag')