diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 58755e81..0c7cd5dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,9 +7,35 @@ 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 + runs-on: ubuntu-latest + defaults: + run: + shell: bash + 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 plugins link --no-install + - name: Run tests + run: yarn test:e2e:bash-spaces + diff --git a/package.json b/package.json index cba0be2c..68b19c3d 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", @@ -74,7 +72,8 @@ "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" }, "type": "module" diff --git a/src/autocomplete/bash-spaces.ts b/src/autocomplete/bash-spaces.ts index 2bd3ad98..b7af9aeb 100644 --- a/src/autocomplete/bash-spaces.ts +++ b/src/autocomplete/bash-spaces.ts @@ -1,80 +1,917 @@ -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 _coTopics?: string[] + private commands: CommandCompletion[] + private topics: Topic[] + + constructor(config: Config) { + this.config = config + this.topics = this.getTopics() + 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 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 -*- + +__${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 "$*"; } -__autocomplete() + +_${this.config.bin}_autocomplete() { + 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=" +${commandsWithFlags} +" - local cur="\${COMP_WORDS[COMP_CWORD]}" opts normalizedCommand colonPrefix IFS=$' \\t\\n' - COMPREPLY=() + local topics=" +${topicsMetadata} +" - local commands=" - + local command_summaries=" +${commandSummaries} " - function __trim_colon_commands() - { - # Turn $commands into an array - commands=("\${commands[@]}") +${flagMetadataBlocks} - 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=() - if [[ "$cur" != "-"* ]]; then - # Command - __COMP_WORDS=( "\${COMP_WORDS[@]:1}" ) + # 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 - # The command typed by the user but separated by colons (e.g. "mycli command subcom" -> "command:subcom") - normalizedCommand="$( printf "%s" "$(join_by ":" "\${__COMP_WORDS[@]}")" )" + __${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 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 + ((i++)) + done + + local cmd_key="\$(join_by " " "\${clean_cmd_parts[@]}")" + __${this.config.bin}_debug "cmd_key: '$cmd_key'" + + # 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}") + 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 - # The command hirarchy, with colons, leading up to the last subcommand entered (e.g. "mycli com subcommand subsubcom" -> "com:subcommand:") - 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[@]}") # | grep -Eo '^[a-zA-Z0-9_-]+' +__${this.config.bin}_flag_expects_value() { + local flag_name="$1" + local cmd_key="$2" + + # 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 expects a value + local after_flag="\${metadata#*\${pattern}}" + local flag_line="\${after_flag%%$'\\n'*}" + + # 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 + # No type marker - fallback: assume option flags expect values + return 0 + fi + fi fi - else - # Flag + return 1 +} - # 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)}")" )" +__${this.config.bin}_get_flag_values() { + local flag_name="$1" + local cmd_key="$2" + + # 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 first "|" but before any "|@" type markers + local values="\${flag_line#*|}" + # 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 + + # No predefined values found + echo "" + return 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 +__${this.config.bin}_should_suggest_flags() { + local cmd_key="$1" + + # 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 + fi + + return 1 +} - COMPREPLY=($(compgen -W "$opts" -- "\${cur}")) +__${this.config.bin}_get_flag_completions() { + local cmd_key="$1" + + # Get already used flags + local used_flags=() + local i=1 + 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 + ((i++)) + done + + # 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 (simplified - we'll enhance this) + local all_flags + all_flags=$(echo "$cmd_line" | sed -n "s/^\$(echo "$cmd_key" | tr ' ' ':') //p") + + # Add flag completions with descriptions + local flag + for flag in $all_flags; do + # Check if flag is already used (skip if multiple allowed) + local already_used=false + 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 + 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' + local flag_desc="\${flag}" # default fallback + + # 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) + local flag_line="\${after_flag%%$'\\n'*}" + # Remove everything after first "|" (option values and type markers) for display + flag_desc="\${flag_line%%|*}" + fi + fi + + completions+=("\${flag}\${tab}\${flag_desc}") + fi + done + fi } -complete -F __autocomplete +__${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 + ((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 + # We're completing the next command/topic after a space + local current_path="\$(join_by ":" "\${clean_cmd_parts[@]}")" + fi + + __${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 +${rootTopicsCompletion} + else + # Show matching commands and subtopics + 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 "$all_matches" ]]; 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 <<< "$all_matches" + + # 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 +} + +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') ?? '' +} ` + } + + 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 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) => { + const publicFlags = this.genCmdPublicFlags(c).trim() + // Keep colon-separated format for internal bash completion logic + return `${c.id} ${publicFlags}` + }) + .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') { + metadataEntry += (flag as any).multiple ? '|@option-multiple' : '|@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') + } + + private generateRootLevelTopics(): string { + const topicLines: string[] = [] + const addedItems = new Set() + + // Get root level topics + const rootTopics = this.topics.filter((t) => !t.name.includes(':')) -export default script + for (const topic of rootTopics) { + 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 { + 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 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, + }) + + 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]}` + } + } + } + } + } + + return cmds + } + + 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 + }) + + // 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 = [...derivedTopics.values()] + .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 allTopics + } + + private sanitizeSummary(summary?: string): string { + if (summary === undefined) { + 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 + } +} 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 { 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 diff --git a/test/e2e/bash-completion.test.ts b/test/e2e/bash-completion.test.ts new file mode 100644 index 00000000..54ff81f9 --- /dev/null +++ b/test/e2e/bash-completion.test.ts @@ -0,0 +1,527 @@ +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 + } + + // 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 [...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 [...topicCommands].sort() + } +} + +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): {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:')) { + // Extract completions from our echo output + const match = line.match(/COMPLETIONS:\s*(.*)/) + if (match && match[1]) { + const completionsStr = match[1].trim() + if (completionsStr) { + return { + completions: completionsStr.split(/\s+/).filter((c) => c.length > 0), + descriptions: [], + } + } + } + } + } + + // 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 ')) { + continue + } + + // 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 (/^--?[\w-]+$/.test(trimmedLine)) { + completions.push(trimmedLine) + continue + } + + // Look for simple flag values (no dashes) + if (/^[a-zA-Z][\w]*$/.test(trimmedLine)) { + 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) { + 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 { + completions: [...new Set(completions)], + descriptions: [...new Set(descriptions)], + } + } + + 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 = '' + } + + 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) => { + const str = data.toString() + this.output += str + if (process.env.DEBUG_COMPLETION) { + console.log('STDOUT:', str) + } + }) + + this.bashProcess.stderr?.on('data', (data) => { + const str = data.toString() + this.stderr += str + if (process.env.DEBUG_COMPLETION) { + console.log('STDERR:', str) + } + }) + + this.bashProcess.on('error', reject) + + // Wait for bash to initialize and source completion scripts + setTimeout(() => { + 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`) + + // 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`) + + this.bashProcess?.stdin?.write(`echo "INIT: Bash setup complete"\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 = '' + + // Clear any previous state + this.bashProcess.stdin.write(`unset COMPREPLY\n`) + + // Set completion variables and call 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) + + // 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`) + + // Wait for completion output + await sleep(1000) + + return this.output + } +} + +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 () { + 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) + + // 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 + 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('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 all root-level topics and commands', async () => { + const expectedTopics = await commandHelper.getRootLevelCompletions() + + await helper.startBashSession() + await helper.sendCommand('sf ') + const output = await helper.triggerCompletion() + const result = helper.parseCompletionOutput(output) + + // 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(', ')}`, + ) + }) + + 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() + const result = helper.parseCompletionOutput(output) + + // Assert that ALL org commands are present in completions + 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(', ')}`, + ) + }) + }) + + describe('flags', function () { + this.timeout(15_000) // Longer timeout for E2E tests + + 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) + + // 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 () => { + 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 foundValues = result.completions.sort() + + expect(foundValues.length).to.equal( + expectedValues.length, + `Expected all values for --edition: ${expectedValues.join(', ')} but found: ${foundValues.join(', ')}. Missing: ${expectedValues.filter((v) => !foundValues.includes(v)).join(', ')}`, + ) + }) + + 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') + const output = await helper.triggerCompletion() + const result = helper.parseCompletionOutput(output) + + const expectedFlags = ['--manifest', '--metadata', '--metadata-dir'] + const actualSorted = [...result.completions].sort() + + 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 --') + const output = await helper.triggerCompletion() + const result = helper.parseCompletionOutput(output) + + // Should still suggest other flags after boolean --json flag + 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') + }) + }) +}) 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"