From 7ca3e42cd3d227be85000d44a05d6daa75c05269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herv=C3=A9=20BECHER?= Date: Wed, 8 Apr 2026 20:54:51 +0200 Subject: [PATCH] improve command and completions --- .editorconfig | 8 ++++ completion.bash | 98 ++++++++++++++++++++++++++++++++++-------------- goto.bash | 99 +++++++++++++++++++++++++++++++------------------ 3 files changed, 142 insertions(+), 63 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dc0bd83 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/completion.bash b/completion.bash index a96fc8c..68a370b 100644 --- a/completion.bash +++ b/completion.bash @@ -4,41 +4,85 @@ _comp_cmd_goto() { local cur prev words cword comp_args _comp_initialize -- "$@" || return - # echo "[$cur] [$prev] [${words[@]}] [$cword] [$comp_args]" + # the list of valid options with their argument requirement + # it is updated as the command is parsed + local -A valid_opts=([-a]=true [-r]=true [-g]=true [-e]=false [-l]=false [-t]=false) + # how many positional arguments are expected + local -i expected_positional_args=1 + # optionals and positionals read up to, not including ${cword} (current word index) + local -i opt_args=0 positional_args=0 + # holds an option that requires its argument to be read next + local expected_optarg - local -A opts=([-e]="" [-l]="" [-t]="") + local i word + for (( i = 1; i < cword; i++ )); do + word="${words[${i}]}" - local i - for i in "${!words[@]}"; do - [[ ${words[i]} && $i -ne $cword ]] && unset -v "opts[${words[i]}]" - case "${words[i]}" in - -e) - unset -v 'opts[-l]' 'opts[-t]' - ;; - -l) - unset -v 'opts[-e]' 'opts[-t]' - ;; - -t) - unset -v 'opts[-e]' 'opts[-l]' - ;; - esac + if [[ "${word:0:1}" == '-' ]]; then # reading an option + + # any option is invalid if... + if \ + (( positional_args > 0 )) || # a positional parameter has been specified \ + [[ -n "${expected_optarg}" ]] || # a previous option is expecting an argument \ + ! [[ -v valid_opts["${word}"] ]] # the option is invalid given the previously provided ones \ + then + return + fi + + local arg_required=${valid_opts["${word}"]} + + case "${word:1}" in + a|r) + unset -v 'valid_opts[-g]' 'valid_opts[-e]' 'valid_opts[-l]' 'valid_opts[-t]' + ;; + g|e|l|t) + valid_opts=() + ;; + *) + return + ;; + esac + + if ${arg_required}; then + expected_optarg="${word}" + else + expected_optarg= + fi + + expected_positional_args=0 + + (( opt_args++ )) + elif [[ -n "${expected_optarg}" ]]; then + expected_optarg= + else + (( positional_args++ )) + + if (( positional_args > expected_positional_args )); then + return + fi + fi done - if [[ $cur == -* ]]; then - _comp_compgen -- -W '"${!opts[@]}"' + if [[ "${cur:0:1}" == '-' ]]; then + if [[ -z "${expected_optarg}" ]] && (( positional_args == 0 )); then + _comp_compgen -- -W '"${!valid_opts[@]}"' + fi return fi - case $prev in - -e) - [[ -v 'opts[-e]' ]] && __goto_comp_keys - return - ;; - esac + if [[ "${prev:0:1}" == '-' ]]; then + case "${prev:1}" in + r|g) + __goto_comp_keys + ;; + esac + return + fi - # do goto keys only if we did not have -[elt] - [[ ${words[*]} == *\ -* ]] || __goto_comp_keys + if (( opt_args == 0 && positional_args == 0 )); then + __goto_comp_keys + fi } && complete -F _comp_cmd_goto goto -# ex: filetype=sh \ No newline at end of file +# ex: filetype=sh diff --git a/goto.bash b/goto.bash index 9a32e11..0212010 100644 --- a/goto.bash +++ b/goto.bash @@ -6,14 +6,14 @@ readonly __GOTO_ROOT_DIR=~/.goto # goto configuration file that contains the directory mappings readonly __GOTO_FILE=${__GOTO_ROOT_DIR}/goto.yaml -# path to yq executable +# path to yq executable used by goto readonly __GOTO_YQ=/usr/local/bin/yq __goto_exists() { [[ -f ${__GOTO_FILE} ]] } -__goto_load() { +__goto_load_entries() { # load the directory mappings from the configuration file into the variable reference passed as parameter # $1: name of the result associative array to create @@ -24,7 +24,8 @@ __goto_load() { return fi - local entries entry key value + local -a entries + local entry key value mapfile entries < <(${__GOTO_YQ} 'explode(.) | to_entries[] | "\(.key) \(.value)"' ${__GOTO_FILE}) @@ -43,13 +44,27 @@ __goto_load() { done } +__goto_load_keys() { + # load the directory keys from the configuration file into the variable reference passed as parameter + # $1: name of the result array to create + + local -n data_ref=$1 + data_ref=() + + if ! __goto_exists; then + return + fi + + mapfile data_ref < <(${__GOTO_YQ} 'keys[]' ${__GOTO_FILE}) +} + __goto_comp_keys() { # loads and initializes directory aliases completions for the goto command - local -A data - __goto_load data + local -a data + __goto_load_keys data - _comp_compgen -- -W '"${!data[@]}"' + _comp_compgen -- -W '"${data[@]}"' } __goto_resolve_path() { @@ -63,7 +78,7 @@ __goto_prompt() { # prompt string for the terminal local -A data - __goto_load data + __goto_load_entries data local entry_key entry_value best_match="" best_key="" @@ -71,7 +86,7 @@ __goto_prompt() { entry_value=$(__goto_resolve_path "${data[${entry_key}]}") # select the closest ancestor of the current working directory - if [[ "${PWD}" == "${entry_value}"* ]] && [[ ${#entry_value} -gt ${#best_match} ]]; then + if [[ "${PWD}" == "${entry_value}"?(/*) ]] && [[ ${#entry_value} -gt ${#best_match} ]]; then best_match="${entry_value}" best_key="${entry_key}" fi @@ -91,7 +106,7 @@ __goto_test() { # command: goto -t local -A data - __goto_load data + __goto_load_entries data local entry_key entry_value local -i ret_code=0 @@ -119,7 +134,7 @@ __goto_edit() { # edits the goto configuration file # command: goto -e - nano ${__GOTO_FILE} + ${EDITOR:-nano} ${__GOTO_FILE} } __goto_list() { @@ -127,7 +142,7 @@ __goto_list() { # command: goto -l local -A data - __goto_load data + __goto_load_entries data local -a sorted_keys mapfile -d '' sorted_keys < <(printf '%s\0' "${!data[@]}" | sort -z) @@ -146,16 +161,17 @@ __goto_add() { # $1: directory alias and optional path local dir_alias dir_path + IFS=':' read dir_alias dir_path <<< "$1" if [[ -n "${dir_path}" ]]; then - dir_path=$(__goto_resolve_path "${dir_path}") + dir_path="$(__goto_resolve_path "${dir_path}")" else dir_path="${PWD}" fi if __goto_exists; then - dir_alias="${dir_alias}" dir_path="${dir_path}" ${__GOTO_YQ} -i '. | (.[env(dir_alias)] = env(dir_path))' ${__GOTO_FILE} + dir_alias="${dir_alias}" dir_path="${dir_path}" ${__GOTO_YQ} -i '.[env(dir_alias)] |= env(dir_path)' ${__GOTO_FILE} else dir_alias="${dir_alias}" dir_path="${dir_path}" ${__GOTO_YQ} '.[env(dir_alias)] = env(dir_path)' ${__GOTO_FILE} fi @@ -179,32 +195,35 @@ __goto_get() { # $1: directory alias if ! __goto_exists; then + echo "goto: $1: configuration file not found" >&2 + return 10 + fi + + local dir_path=$(dir_alias="$1" ${__GOTO_YQ} 'explode(.)[env(dir_alias)] // ""' ${__GOTO_FILE}) + + if [[ -z "${dir_path}" ]]; then + echo "goto: $1: no such entry" >&2 return 11 fi - local dir_path="$(dir_alias="$1" ${__GOTO_YQ} '.[env(dir_alias)] // ""' ${__GOTO_FILE})" - - if [[ -z "${dir_path}" ]]; then - return 10 - fi - __goto_resolve_path "${dir_path}" } goto() { # the goto command - local OPTIND OPT + local can_run_main=true + local OPTIND OPT OPTARG - while getopts 'a:r:g:elt' OPT; do + while getopts ':a:r:g:elt' OPT; do case "${OPT}" in a) __goto_add "${OPTARG}" - return + can_run_main=false ;; r) __goto_remove "${OPTARG}" - return + can_run_main=false ;; g) __goto_get "${OPTARG}" @@ -222,8 +241,12 @@ goto() { __goto_test return ;; + :) + echo "goto: missing argument for option '${OPTARG}'" >&2 + return 1 + ;; *) - echo "goto: invalid option ${OPTARG}" >&2 + echo "goto: invalid option '${OPTARG}'" >&2 return 1 ;; esac @@ -236,25 +259,29 @@ goto() { return 1 fi - if (( $# == 0 )); then - cd + if ! ${can_run_main}; then + if (( $# > 0 )); then + echo 'goto: too many arguments' >&2 + return 1 + fi + return fi - if ! __goto_exists; then - echo "goto: $1: configuration file not found" >&2 - return 3 + if (( $# == 0 )); then + echo 'goto: not enough arguments' >&2 + return fi - local dir_path="$(dir_alias="$1" ${__GOTO_YQ} '.[env(dir_alias)] // ""' ${__GOTO_FILE})" + local dir_path + dir_path=$(__goto_get "$1") + local -i ret_code=$? - if [[ -z "${dir_path}" ]]; then - echo "goto: $1: no such entry" >&2 - return 2 + if (( ret_code != 0 )); then + return ${ret_code} fi - cd $(__goto_resolve_path "${dir_path}") + cd ${dir_path} } -# complete -F __goto_completions goto -source ${__GOTO_ROOT_DIR}/completion.bash \ No newline at end of file +. ${__GOTO_ROOT_DIR}/completion.bash