2

I'm new in Bash and I'm stuck with writing a prompt function which asks the user a given questions and accepts the answers yes, no and cancel. I know that there are already a lot of answers to similar questions here on SO but I wasn't able to find an answer that fulfills all my requirements.

Requirements:

The ask function must

  • accept a question as parameter: ask "are you happy?"
  • accept a answer in [y/n/c]: y, Y, yes, yEs, etc.
  • return a value based on the answer: true for yes, false for no
    • the return value must be assignable to a variable to save it for later: answer=$(ask "...?")
    • the return value must be directly useable by if statements: if ask "...?" then;
  • be able to halt the complete execution of the calling script for abort: exit
  • ask again if the answer was not yes, no or cancel
  • allow a default answer for empty input
  • work in scripts that use set -e as well as set +e

I came up with the following solution which doesn't work properly:

ask() {
  while true; do
    read -p "$1 [Y/n/a] " answer
    case $(echo "$answer" | tr "[A-Z]" "[a-z]") in
      y|yes|"" ) echo "true" ; return 0;;
      n|no     ) echo "false"; return 1;;
      a|abort  ) echo "abort"; exit 1;;
    esac
  done
}

# usage1
if ask "Are you happy?" >/dev/null; then
  # ...
fi

# usage2
answer=$(ask "Are you happy?")

For example, the abort does only work when I use -e but then logically the no also causes the script to halt.

winklerrr
  • 13,026
  • 8
  • 71
  • 88
  • 1
    You could kill yourself.... I mean `kill -0`. Or just kill your parent... omg. Anyway, `$(echo "$answer" | tr "[A-Z]" "[a-z]")` it's simpler as `${answer,,}` – KamilCuk Dec 09 '20 at 08:59
  • @KamilCuk but a script with `set -e` would still halt when *no* is answered, right? What exactly does `${answer,,}` do? – winklerrr Dec 09 '20 at 09:01
  • Only a hint: instead of `case $(echo "$answer" | tr "[A-Z]" "[a-z]") in` you can use `case "${answer,,}" in`. – Wiimm Dec 09 '20 at 09:03
  • `still halt when no is answered, right?` Yes. So handle exit status. `${answer,,} do?` lowercase. – KamilCuk Dec 09 '20 at 09:03
  • @Wiimm do you got a link for me where I can read more about it and why this also works? – winklerrr Dec 09 '20 at 09:04
  • It's all in `man bash`. – choroba Dec 09 '20 at 09:04
  • 1
    Or online at [bash manual shell parameter expansion](https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html) :D – KamilCuk Dec 09 '20 at 09:04
  • @KamilCuk how do I handle exit status? Can I handle the exit status in the ask function? Because I don't want the handling to happen in the calling code. (Sorry, I'm completely new to bash) – winklerrr Dec 09 '20 at 09:05
  • 2
    `how do I handle exit status?` `answer=$(seomthing) || ret=$?; if ((ret == 0)); then :; elif ((ret == 1)); then ....`. `Can I handle the exit status in the ask function?` Well, there is no function exit status inside the function, it's after the function returns. `Because I don't want the handling to happen in the calling code` Them if you want `exit 1` to terminate _it all_, you can kill your whole process group with `kill -0` or you can't run in a subshell. `$(..)` starts a subshell. – KamilCuk Dec 09 '20 at 09:09
  • "work in scripts that use set -e as well as set +e" got me interested, but then @KamilCuk's comment above about killing the parent sealed the deal haha. I believe there is more than one way of solving the issue, but the answer below looks to be a straightforward way of doing it – jared_mamrot Dec 09 '20 at 09:32

1 Answers1

3

I believe it would be just overall simpler to work the same way as read works. Remember to pick a unique name for the namereference.

ask() {
  declare -n _ask_var=$2
  local _ask_answer
  while true; do
    read -p "$1 [Y/n/a] " _ask_answer
    case "${_ask_answer,,}" in
      y|yes|"" ) _ask_var="true" ; break; ;;
      n|no     ) _ask_var="false"; break; ;;
      a|abort  ) exit 1; ;;
    esac
  done
}

ask "Are you happy?" answer
if "$answer"; then echo "Yay! Me too!"; fi
KamilCuk
  • 120,984
  • 8
  • 59
  • 111
  • Why do you use `declare` for `_ask_var` but `local` for `_ask_answer`? Couldn't you use `local` for both? – winklerrr Dec 09 '20 at 09:17
  • 1
    Does `local` take `-n` option? Och it can, didn't knew. I believe just a convention - am used to writing `declare -n` when doing a backreference, and using `local` specifically only for function local variables. – KamilCuk Dec 09 '20 at 09:18
  • No, but `declare` also doesn't?! https://ss64.com/bash/declare.html – winklerrr Dec 09 '20 at 09:19
  • You are looking at outdated documentation. I believe namereferences came to be somewhere around bash4. – KamilCuk Dec 09 '20 at 09:20
  • Please help me to understand one more thing about your code: so I don't need to use `return 0` or `return 1` because Bash automatically can interpolate the string `"true"` and `"false"` or why does `if "$answer";` work? – winklerrr Dec 09 '20 at 09:23
  • 1
    `true` and `false` are commands. [true](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/true.html) and similar false. `true` command exits with zero exit status, `false` with nonzero. `if "$answer"` first expands `answer` to `true` or `false`, then executes the command, then `if` chooses path depending on exit status. In normal shells, `true` and `false` are implemented as builtins for speed. You can run `/bin/true`. – KamilCuk Dec 09 '20 at 09:23
  • Thank you for that explanation! That helps me alot! But I'm still unable to find any information about `declare -n`. Also [here](https://linux.die.net/man/1/bash) it is described as `declare [-aAfFilrtux] [-p] [name[=value] ...]`, so no `-n` there? – winklerrr Dec 09 '20 at 09:27
  • 1
    linux.die.net is an _archive_ of man pages. Look at the [official bash manual](https://www.gnu.org/software/bash/manual/bash.html#Bash-Builtin-Commands). `man7.org` has newer man pages online. – KamilCuk Dec 09 '20 at 09:29
  • Thanks! I found it! But to summarize the take-away here: saving the return value of the prompt with a subshell (`answer="$(ask "...?")"`) like I did in my code seems to be a problem with `set -e` when `return 1` and `exit 1` is used, right? So using variable referencing overcomes this problem? I don't need to open a subshell which allows me to exit *it all* normally with `exit 1`? – winklerrr Dec 09 '20 at 09:44
  • `like I did in my code seems to be a problem with set -e, right?`. No, the same result will be without using process substitution. `ask "...?"` will also terminate the shell under `set -e`. _Any command_ (except exceptions...) that returns with non-zero exit status will terminate shell under `set -e`. `which allows me to exit it all normally with exit 1?` `exit` only terminates the subshell. Ie. `a=$(exit; echo 1)` only terminates the subshell started with `$( ... )`. – KamilCuk Dec 09 '20 at 09:46
  • So when I would use `return 1` I would only be able to call the function in an `if` statement (like in this similar code [here](https://stackoverflow.com/a/12202793/3459910))? But I also have the requirement to save the return value in a variable and so I couldn't use `return 1` and instead make use of `break` because otherwise the `set -e` would trigger the exit when the function is called outside of an `if` statement, right? – winklerrr Dec 09 '20 at 09:59
  • Another question about your code: why did you use `if "$answer";` instead of `if [ "$answer" = true ]`? It's mentioned [here](https://stackoverflow.com/a/21210966/3459910) that it would have some disadvantages to use the first method? Is it okay to use, because we know in our case that the variable can only be set to `"true"` or `"false"`? But why do we then need to put quotes around `"$answer"`? Is it really necessary or just good coding style? – winklerrr Dec 09 '20 at 10:14
  • 1
    `So when I would use return 1 I would only be able to call the function in an if statement` It's typicall to `ret=0; command || ret=$?` in `set -e`. Assignment (with no subshells) has zero exit status. `right?` Yes. `why did you use if "$answer"; instead of if [ "$answer" = true ]?` Because I have full control over the variable (it will be either `false` or `true`, never something else) and `if "$answer"` looks nicer to me. Some ppl use integers and do `if ((answer))`. `Is it okay to` Yes. – KamilCuk Dec 09 '20 at 11:41
  • 1
    `why do we then need to put quotes` My fingers are used to typing `"` whenever I write any `$` expansion. I usually code with http://shellcheck.net auto-checking and the tool will complain. `Is it really necessary or just good coding style?` It's hard to judge, for me that's both. If someone else will use your code and will not understand it and he will accidentally set `answer='rm *'`, then `if $answer` will remove all your files. On the other hand, `if "$answer"` will display "command not found" message. For me, the rule is _always_ quote, unless you really know that you may do otherwise. – KamilCuk Dec 09 '20 at 11:43