picocli icon indicating copy to clipboard operation
picocli copied to clipboard

Autocompletion for positional parameters on `~`

Open rsenden opened this issue 2 years ago • 3 comments

We're trying to have our application properly handle paths starting with ~ to represent the user home directory. It seems like bash is already taking care of expanding ~ references with the proper path when invoking our application. The work-around mentioned in https://github.com/remkop/picocli/issues/437#issuecomment-412038752 only seems to be necessary when running our application from the IDE and probably when running from shells that don't natively support ~.

However, in all of the situations listed above, it seems like the auto-completion script doesn't support paths starting with ~ for positional parameters; for options auto-completion seems to work fine. The auto-complete debug output looks completely similar when trying to autocomplete either ~/test-autocomplete/ or /home/rsenden/test-autocomplete/, apart from the function returning 1 for the path with ~ instead of 0 when using the absolute path; see below.

I don't have sufficient experience with auto-completion scripts to understand exactly what's causing this difference, and what would need to be done to properly support paths starting with ~. Any idea?

Sample autocomplete function:

function _picocli_fcli_config_truststore_set() {
  # Get completion data
  local curr_word=${COMP_WORDS[COMP_CWORD]}
  local prev_word=${COMP_WORDS[COMP_CWORD-1]}

  local commands=""
  local flag_opts="-h --help"
  local arg_opts="--env-prefix --log-file --log-level -o --output --store --output-to-file -p --truststore-password -t --truststore-type"
  local logLevel_option_args=("TRACE" "DEBUG" "INFO" "WARN" "ERROR") # --log-level values
  local formatoptions_option_args=("csv" "csv-plain" "json" "json-flat" "table" "table-plain" "tree" "tree-flat" "xml" "xml-flat" "yaml" "yaml-flat" "expr" "json-properties") # --output values

  type compopt &>/dev/null && compopt +o default

  case ${prev_word} in
    --env-prefix)
      return
      ;;
    --log-file)
      return
      ;;
    --log-level)
      local IFS=$'\n'
      COMPREPLY=( $( compReplyArray "${logLevel_option_args[@]}" ) )
      return $?
      ;;
    -o|--output)
      local IFS=$'\n'
      COMPREPLY=( $( compReplyArray "${formatoptions_option_args[@]}" ) )
      return $?
      ;;
    --store)
      return
      ;;
    --output-to-file)
      return
      ;;
    -p|--truststore-password)
      return
      ;;
    -t|--truststore-type)
      return
      ;;
  esac

  if [[ "${curr_word}" == -* ]]; then
    COMPREPLY=( $(compgen -W "${flag_opts} ${arg_opts}" -- "${curr_word}") )
  else
    local positionals=""
    local currIndex
    currIndex=$(currentPositionalIndex "set" "${arg_opts}" "${flag_opts}")
    if (( currIndex >= 0 && currIndex <= 0 )); then
      local IFS=$'\n'
      type compopt &>/dev/null && compopt -o filenames
      positionals=$( compgen -f -- "${curr_word}" ) # files
    fi
    local IFS=$'\n'
    COMPREPLY=( $(compgen -W "${commands// /$'\n'}${IFS}${positionals}" -- "${curr_word}") )
  fi
}

Output with /home/rsenden/test-autocomplete/ as input:

+ _picocli_fcli_config_truststore_set
+ local curr_word=/home/rsenden/test-autocomplete/
+ local prev_word=set
+ local commands=
+ local 'flag_opts=-h --help'
+ local 'arg_opts=--env-prefix --log-file --log-level -o --output --store --output-to-file -p --truststore-password -t --truststore-type'
+ logLevel_option_args=("TRACE" "DEBUG" "INFO" "WARN" "ERROR")
+ local logLevel_option_args
+ formatoptions_option_args=("csv" "csv-plain" "json" "json-flat" "table" "table-plain" "tree" "tree-flat" "xml" "xml-flat" "yaml" "yaml-flat" "expr" "json-properties")
+ local formatoptions_option_args
+ type compopt
+ compopt +o default
+ case ${prev_word} in
+ [[ /home/rsenden/test-autocomplete/ == -* ]]
+ local positionals=
+ local currIndex
++ currentPositionalIndex set '--env-prefix --log-file --log-level -o --output --store --output-to-file -p --truststore-password -t --truststore-type' '-h --help'
++ local commandName=set
++ local 'optionsWithArgs=--env-prefix --log-file --log-level -o --output --store --output-to-file -p --truststore-password -t --truststore-type'
++ local 'booleanOptions=-h --help'
++ local previousWord
++ local result=0
+++ seq 3 -1 0
++ for i in $(seq $((COMP_CWORD - 1)) -1 0)
++ previousWord=set
++ '[' set = set ']'
++ break
++ echo 0
+ currIndex=0
+ ((  currIndex >= 0 && currIndex <= 0  ))
+ local 'IFS=
'
+ type compopt
+ compopt -o filenames
++ compgen -f -- /home/rsenden/test-autocomplete/
+ positionals='/home/rsenden/test-autocomplete/file1.txt
/home/rsenden/test-autocomplete/file2.txt'
+ local 'IFS=
'
+ COMPREPLY=($(compgen -W "${commands// /'
'}${IFS}${positionals}" -- "${curr_word}"))
++ compgen -W '
/home/rsenden/test-autocomplete/file1.txt
/home/rsenden/test-autocomplete/file2.txt' -- /home/rsenden/test-autocomplete/
+ return 0

Output with ~/test-autocomplete/ as input:

+ _picocli_fcli_config_truststore_set
+ local 'curr_word=~/test-autocomplete/'
+ local prev_word=set
+ local commands=
+ local 'flag_opts=-h --help'
+ local 'arg_opts=--env-prefix --log-file --log-level -o --output --store --output-to-file -p --truststore-password -t --truststore-type'
+ logLevel_option_args=("TRACE" "DEBUG" "INFO" "WARN" "ERROR")
+ local logLevel_option_args
+ formatoptions_option_args=("csv" "csv-plain" "json" "json-flat" "table" "table-plain" "tree" "tree-flat" "xml" "xml-flat" "yaml" "yaml-flat" "expr" "json-properties")
+ local formatoptions_option_args
+ type compopt
+ compopt +o default
+ case ${prev_word} in
+ [[ ~/test-autocomplete/ == -* ]]
+ local positionals=
+ local currIndex
++ currentPositionalIndex set '--env-prefix --log-file --log-level -o --output --store --output-to-file -p --truststore-password -t --truststore-type' '-h --help'
++ local commandName=set
++ local 'optionsWithArgs=--env-prefix --log-file --log-level -o --output --store --output-to-file -p --truststore-password -t --truststore-type'
++ local 'booleanOptions=-h --help'
++ local previousWord
++ local result=0
+++ seq 3 -1 0
++ for i in $(seq $((COMP_CWORD - 1)) -1 0)
++ previousWord=set
++ '[' set = set ']'
++ break
++ echo 0
+ currIndex=0
+ ((  currIndex >= 0 && currIndex <= 0  ))
+ local 'IFS=
'
+ type compopt
+ compopt -o filenames
++ compgen -f -- '~/test-autocomplete/'
+ positionals='~/test-autocomplete/file1.txt
~/test-autocomplete/file2.txt'
+ local 'IFS=
'
+ COMPREPLY=($(compgen -W "${commands// /'
'}${IFS}${positionals}" -- "${curr_word}"))
++ compgen -W '
~/test-autocomplete/file1.txt
~/test-autocomplete/file2.txt' -- '~/test-autocomplete/'
+ return 1

rsenden avatar Jul 18 '23 15:07 rsenden

So, as mentioned above, auto-completion for ~/ does work fine for options but not for positional parameters. The main difference in the completion script is that for options taking a File, completion candidates are generated using a simple:

COMPREPLY=( $( compgen -f -- "${curr_word}" ) ) # files

For positional parameters, the same compgen command is used to generate the contents of the positionals variable, which is then used in another compgen command which apparently doesn't support ~/:

    if (( currIndex >= 0 && currIndex <= 0 )); then
      local IFS=$'\n'
      type compopt &>/dev/null && compopt -o filenames
      positionals=$( compgen -f -- "${curr_word}" ) # files
    fi
    local IFS=$'\n'
    COMPREPLY=( $(compgen -W "${commands// /$'\n'}${IFS}${positionals}" -- "${curr_word}") )

Adding the -f option to the latter compgen command allows for proper handling of ~/:

    COMPREPLY=( $(compgen -f -W "${commands// /$'\n'}${IFS}${positionals}" -- "${curr_word}") )

However, given that this statement is part of the generic function footer, adding the -f option would likely result in file-based auto-completion being offered on all positional parameters, which we don't want. Any ideas how to properly fix this issue?

rsenden avatar Jul 21 '23 08:07 rsenden

One idea is to replace the generic footer function with one that has the -f option if the positional parameters are of type File or Path.

remkop avatar Jul 22 '23 05:07 remkop

@remkop Thanks for the suggestion. Just to confirm, the reason that the if-block handling file completions falls through to the footer, instead of returning immediately if there are any file matches, is to allow multiple completion options to be combined, correct? For example, for a positional file parameter with arity 0..*, we may need to combine file completion options with --option and sub-command completion options. So, just adding a return statement to the if-block is not an option.

Instead of replacing the generic footer function, I think the following might be easier as it better fits into current architecture:

  • In the generic header, define an empty string or array variable named something like extraCompGenOpts
  • In the if-block handling file completion options, append/add array entry -f
  • In the generic footer, include the contents of the extraCompGenOpts variable in the compgen command

I'll look at having a PR created for this.

rsenden avatar Jul 27 '23 05:07 rsenden