From b27b46471a287c9bcdd2c90600570fa96a77aed5 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 ++ a.patch | 361 ++++++++++++++++++++++++++++++++++++++++++++++++ completion.bash | 98 +++++++++---- goto.bash | 98 ++++++++----- 4 files changed, 503 insertions(+), 62 deletions(-) create mode 100644 .editorconfig create mode 100644 a.patch 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/a.patch b/a.patch new file mode 100644 index 0000000..7d4c168 --- /dev/null +++ b/a.patch @@ -0,0 +1,361 @@ +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..5076aa2 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 +- return 11 ++ echo "goto: $1: configuration file not found" >&2 ++ return 10 + fi + +- local dir_path="$(dir_alias="$1" ${__GOTO_YQ} '.[env(dir_alias)] // ""' ${__GOTO_FILE})" ++ local dir_path=$(dir_alias="$1" ${__GOTO_YQ} 'explode(.)[env(dir_alias)] // ""' ${__GOTO_FILE}) + + if [[ -z "${dir_path}" ]]; then +- return 10 ++ echo "goto: $1: no such entry" >&2 ++ return 11 + 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,30 @@ 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 ++source ${__GOTO_ROOT_DIR}/completion.bash 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..5076aa2 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,30 @@ 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 +source ${__GOTO_ROOT_DIR}/completion.bash