4

I wrote a bash script that takes a command as the first positional parameter and uses a case construct as a dispatch similar to the following:

do_command() {
  # responds to invocation `$0 command ...`
}

do_copy() {
  # respond to invocation: `$0 copy...`
}

do_imperative() {
  # respond to invocation: `$0 imperative ...`
}

cmd=$1
shift
case $cmd in
command)
  do_command $*
  ;;
copy)
  do_copy $*
  ;;
imperative)
  do_imperative $*
  ;;
*)
  echo "Usage: $0 [ command | copy | imperative ]" >&2
  ;;
esac

This script decides what function to call based on $1 and then passes the remaining arguments to that function. I would like to add the ability dispatch on distinct partial matches, but I want to do it in an elegant way (elegant defined as a way that is both easy to read and is not so verbose as to be an eyesore or a distraction).

The obvious functioning (but not elegant) solution might be something like this:

case $cmd in
command|comman|comma|comm|com)
  do_command $*
  ;;
copy|cop)
  do_copy $*
  ;;
imperative|imperativ|imperati|imperat|impera|imper|impe|imp|im|i)
  do_imperative $*
  ;;
*)
  echo "Usage: $0 [ command | copy | imperative ]" >&2
  ;;
esac

As you can see, explicitly enumerating all distinct permutations of each command name can get really messy.

For a moment, I thought it might be alright to use a wildcard match like this:

case $cmd in
com*)
  do_command $*
  ;;
cop*)
  do_copy $*
  ;;
i*)
  do_imperative $*
  ;;
*)
  echo "Usage: $0 [ command | copy | imperative ]" >&2
  ;;
esac

This is less of an eyesore. However, this could result in undesirable behavior such as where do_command is called when $1 is given as "comblah" or something else that shouldn't be recognized as a valid argument.

My question is: What is the most elegant (as defined above) way to correctly dispatch such a command where the user can provide any distinct truncated form of the expected commands?

Iron Savior
  • 4,238
  • 3
  • 25
  • 30
  • I'd say that this is rather a programming than a usability problem. My suggestion is to match exact command names such as `cp|copy)`, `command|cmd|comm)` ... instead of interpolating them. You could than add another set of matchers like e.g. `cp*|cop*)`, `command*|cm*)` after those actual command matchers and print a suggestion like "Did you mean one of the following commands: copy|cp " [...]. – try-catch-finally Feb 16 '13 at 11:43

4 Answers4

2

I came up with the following solution, which should work with any bourne-compatible shell:

disambiguate() {
    option="$1"
    shift
    found=""
    all=""
    comma=""
    for candidate in "$@"; do
        case "$candidate" in
            "$option"*)
                found="$candidate"
                all="$all$comma$candidate"
                comma=", "
        esac
    done    
    if [ -z "$found" ] ; then
        echo "Unknown option $option: should be one of $@" >&2
        return 1;
    fi
    if [ "$all" = "$found" ] ; then
        echo "$found"
    else
        echo "Ambigious option $option: may be $all" >&2
        return 1
    fi
}    
foo=$(disambiguate "$1" lorem ipsum dolor dollar)
if [ -z "$foo" ] ; then exit 1; fi
echo "$foo"

Yes, the source code of disambiguate is not pretty, but I hope you won't have to look at this code most of the time.

Anton Kovalenko
  • 20,999
  • 2
  • 37
  • 69
2

Pattern Matching for Command Dispatch in Bash

It seems that a few of you like the idea of using a resolver to find the full command match prior to the dispatching logic. That's might just be the best way to go for large command sets or sets that have long words. I put together the following hacked up mess--it makes 2 passes using built-in parameter expansion substring removal. I seems to work well enough and it keeps the dispatch logic clean of the distraction of resolving partial commands. My bash version is 4.1.5.

#!/bin/bash
resolve_cmd() {
  local given=$1
  shift
  local list=($*)
  local inv=(${list[*]##${given}*})
  local OIFS=$IFS; IFS='|'; local pat="${inv[*]}"; IFS=$OIFS
  shopt -s extglob
  echo "${list[*]##+($pat)}"
  shopt -u extglob
}
valid_cmds="start stop status command copy imperative empathy emperor"

m=($(resolve_cmd $1 $valid_cmds))
if [ ${#m[*]} -gt 1 ]; then
  echo "$1 is ambiguous, possible matches: ${m[*]}" >&2
  exit 1
elif [ ${#m[*]} -lt 1 ]; then
  echo "$1 is not a recognized command." >&2
  exit 1
fi
echo "Matched command: $m"
Iron Savior
  • 4,238
  • 3
  • 25
  • 30
0

Update 1:

match is called using (partial) command as first positional parameter, followed by strings to test against. On multiple matches, each partial match will be hinted with uppercase.

# @pos 1 string
# @pos 2+ strings to compare against
# @ret true on one match, false on none|disambiguate match
match() {

    local w input="${1,,}" disa=();
    local len=${#input}; # needed for uppercase hints
    shift;

    for w in $*; do
        [[ "$input" == "$w" ]] && return 0;
        [[ "$w" == "$input"* ]] && disa+=($w);
    done

    if ! (( ${#disa[*]} == 1 )); then
        printf "Matches: "
        for w in ${disa[*]}; do
            printf "$( echo "${w:0:$len}" | tr '[:lower:]' '[:upper:]')${w:${len}} ";
        done
        echo "";
        return 1;
    fi

    return 0;
}

Example of usage. match can be tweaked to print/return the whole non disambiguate command, otherwise do_something_with would require some logic to resolve partial commands. (something like my first answer)

cmds="start stop status command copy imperative empathy emperor"

while true; do
    read -p"> " cmd
    test -z "$cmd" && exit 1;
    match $cmd $cmds && do_something_with "$cmd";
done

First answer: Case approach; would require some logic before use, to solve disambiguate partial matches.

#!/bin/bash
# script.sh

# set extended globbing, in most shells it's not set by default
shopt -s extglob;

do_imperative() {
    echo $*;
}

case $1 in
    i?(m?(p?(e?(r?(a?(t?(i?(v?(e))))))))))
        shift;
        do_imperative $*;
        ;;
    *)
        echo "Error: no match on $1";
        exit 1;
        ;;
esac

exit 0;

i, im, imp up till imperative will match. The shift will set second positional parameter as first, meaning; if the script is called as:

./script.sh imp say hello

will resolve into

do_imperative say hello

If you wish to further resolve short hand commands, use the same approach within the functions as well.

MetalGodwin
  • 3,784
  • 2
  • 17
  • 14
  • How well would this work to disambiguate many different commands? Could you expand the answer to demonstrate? – Iron Savior Sep 06 '18 at 19:26
  • Thanks for the feedback. I misunderstood your question at first due to language; thinking you were after resolving partial commands in a case test (which only supports globbing). Case aren't able to match disambiguate strings and act accordingly. I'll update the post with another approach. – MetalGodwin Sep 09 '18 at 13:00
0

Here's a simple (possibly even elegant) solution which will only work with bash, because it relies on the bash-specific compgen command.

This version assumes that the action functions are always called do_X where X is the command name. Before calling this function, you need to set $commands to a space-separated list of legal commands; the assumption is that legal commands will be simple words, since function names cannot include special characters.

doit () {
    # Do nothing if there is no command
    if (( ! $# )); then return 0; fi;
    local cmd=$1
    shift
    local -a options=($(compgen -W "$commands" "$cmd"));
    case ${#options[@]} in 
        0)
            printf "Unrecognized command '%b'\n" "$cmd" >> /dev/stderr;
            return 1
        ;;
        1)
            # Assume that the action functions all have a consistent name
            "do_$options" "$@"
        ;;
        *)
            printf "Ambigous command '%b'. Possible completions:" "$cmd";
            printf " %s" "${options[@]}";
            printf "\n";
            return 1
        ;;
    esac
}

do_command () { printf "command %s\n" "$@"; }
do_copy () { printf "copy %s\n" "$@"; }
do_imperative () { printf "imperative %s\n" "$@"; }
commands="command copy imperative"

Trial run:

$ doit cop a "b c"
copy a
copy b c
$ doit comfoo a "b c"
Unrecognized command 'comfoo'
$ doit co a "b c"
Ambigous command 'co'. Possible completions: command copy
$ doit i a "b c"

If you were confident that there were no stray do_X variables available, you could use compgen to make the list of commands as well:

command=$(compgen -Afunction do_ | cut -c4-)

Alternatively, you could use the same system to make a resolver, and then handle the returned option with a normal case statement:

# resolve cmd possible-commands
resolve () {
    # Fail silently if there is no command
    if [[ -z $1 ]]; then return 1; fi;
    local cmd=$1
    shift
    local commands="$*"
    local -a options=($(compgen -W "$commands" "$cmd"));
    case ${#options[@]} in 
        0)
            printf "Unrecognized command '%b'\n" "$cmd" >> /dev/stderr;
            return 1
        ;;
        1)
            echo $options
            return 0
        ;;
        *)
            printf "Ambigous command '%b'. Possible completions:" "$cmd";
            printf " %s" "${options[@]}";
            printf "\n";
            return 1
        ;;
    esac
}

$ resolve com command copy imperative && echo OK
command
OK
$ resolve co command copy imperative && echo OK
Ambigous command 'co'. Possible completions: command copy
$ resolve copx command copy imperative && echo OK
Unrecognized command 'copx'

The intent would be to write something like:

cmd=$(resolve "$1" "$commands") || exit 1
case "$cmd" in
  command) 
# ...
rici
  • 234,347
  • 28
  • 237
  • 341