9
\$\begingroup\$

I've written a Bash function that can ask a simple yes/no question.

  • The "yes/no" prompt is translated to the user's language (the question must be localised by the caller).
  • Input is accepted in the same language.
  • Language falls back to English if message catalogue is not available.
  • The question is repeated until a valid response is entered.
  • One reply may be specified as default if the user just gives an empty string, which is presented in capital letters, as is the convention - or if the language doesn't have uppercase/lowercase distinction, then it's highlighted with [⋯].
  • If no default is specified, empty input is not valid.
  • It returns success or failure so it can be used directly as a command in conditionals.
  • Shellcheck reports no issues.
#!/bin/bash

# ask_with_default message [0|1]
# First argument is the question to ask
# Second argument, if present, is 0 or 1 to default to yes or no, respectively
ask_with_default() {
    local messages true false answer default
    readarray -t messages < <(locale LC_MESSAGES)
    true=${messages[2]:=yes}
    false=${messages[3]:=no}
    default=${2-}
    case "$default" in
        [01])
            local tf=(true false)
            local -n d=${tf[$default]}
            if [ "${d,,}" != "${d^^}" ]
            then
                # upcase the default
                true=${true,,}
                false=${false,,}
                d=${d^^}
            else
                # alt. highlight if upper case is same as lower
                d="[$d]"  # or "$d(*)" etc
            fi
            ;;
        '')
            # no default
            ;;
        *)
            # Invalid, so ignore, with a warning
            echo >&2 "Usage: ask_with_default message [0|1]"
            default=
            ;;
    esac

    while true
    read -rp "$1 ($true/$false) " answer
    do if [[ "$answer" =~ ${messages[0]:=^[yY]} ]]
       then
           return 0
       elif [[ "$answer" =~ ${messages[1]:=^[nN]} ]]
       then
            return 1
       elif ! [ "$answer" ] && [ "$default" ]
       then
            return "$default"
       fi
    done
}

Example usage (I haven't bothered to translate the question here, just for clarity)

if ask_with_default "Is this good?" 0
then
    echo "That makes me happy."
else
    echo "I'm sorry you don't like it."
fi

Here's the yes/no selection in a handful of locales I have available here ():

  • C: (YES/no)
  • cy: (IE/na)
  • gd: (THA/chan eil)
  • kl: (AAP/naagga)
  • el: (ΝΑΙ/όχι)
  • ru_UA: (ДА/нет)
  • ko: ([예]/아니요)
  • ja: ([はい]/いいえ)

Concerns

  • General review of the code - anything that could be clearer, more efficient or in any other way better?
  • Is the use of [⋯] to highlight the default in non-cased scripts clear and understandable for users? Or is there an existing convention I could use instead?
\$\endgroup\$

1 Answer 1

6
\$\begingroup\$

Nice specs, very solid and bulletproof. I found the Review Context quite helpful. Consider putting some of that discussion into the source code.

cryptic syntax

anything that could be clearer

Few people choose to write code using the symbols of Iverson's APL, and arguably the Perl one-liner's resemblance to "modem line noise" led to folks seeking more Algol-like alternatives with pronounceable syntax.

                # upcase the default
                true=${true,,}
                false=${false,,}
                d=${d^^}

I thank you for the comment.

This is laudably efficient, as we don't fork a child process for the idiomatic tr "A-Z" "a-z" or tr "a-z" "A-Z". But as a maintenance engineer I would rather see the tr idiom than try my luck googling documentation for symbols that can pose a challenge to search for. (spoiler alert: Scroll down to §3.5.3 for the answer, or in the man page scroll past 1200 lines of technical material.)

The "unix tools" philosophy is to use "small" programs in a way that will compose nicely. The Bourne / bash language is no longer small, and a significant fraction of its features are accessed via cryptic sigils and symbols. When you feel the need to write an explanatory comment, consider writing in a more conservative subset, taking advantage of additional tools outside the interpreter. Upon encountering an unfamiliar switch like tr -d, it's easy to lookup its man page description since the command is narrowly focused on just one thing.

Minimally, please move the comment so it's immediately above the "upcase" line.

(I do appreciate the attention to local details, keeping the namespace clean. And e.g. the :=yes defaulting, =~ regexes, and the echo to stderr are idiomatic and beautiful.)

nameref

            local -n d=${tf[$default]}

$ help local is unhelpful here, but $ help declare reveals that this is a name reference, a feature added to the language just five years ago. (In contrast the chain for readarray -t was far friendlier.) So we are altering tf, something that wasn't immediately obvious to me.

Running this on a CentOS bash-4.4 would be unfortunate and fairly plausible, so consider testing $BASH_VERSINFO.

while forever

    while true
    read -rp "$1 ($true/$false) " answer
    do ...

Wow. Apparently this works, ok. Users of ALGOL inspired languages routinely use "while foo" statements, where foo might involve a "(bar, baz)" block to stitch multiple statements into a larger block. Here I suppose the newlines act as ; semicolons to do that. Consulting $ help while reveals "while COMMANDS; do ...", with plural rather than singular COMMAND. Thank you for teaching me something. (On reflection I think this is not the first time I have encountered this usage. But I wouldn't call it usual.)

Nonetheless, consider rephrasing this more idiomatically as while read ... || true; do .... (Though with no edits I confess the meaning of the OP code as-is will be immediately apparent to any future maintenance engineer who comes across this, without loss of clarity.)

design of Public API

name

The verb "ask" is certainly accurate if a little vague. It does not imply "predicate".

ask_with_default() {

Often we will see yorn for such a boolean. Or pronounced the same but a little more verbosely, y_or_n. Consider incorporating that into the identifier.

specification

  • The question is repeated until a valid response is entered.

Thank you for making this explicit.

Suppose that app.sh relies on ask_with_default. It seems plausible that someone might do cat stock_responses.txt | app.sh, perhaps as part of a test suite or an installer. A locale mismatch between the two parts of that pipeline leads to tragedy, similar to when /usr/bin/yes interacts badly with a sink that we thought would consume just a fixed number of inputs.

Consider adopting either of these mitigating strategies:

  1. Report "no" (or the default) after a threshold of bad inputs, perhaps a dozen.
  2. Unconditionally sleep 1 when we return none of {0, 1, $default}, for fear of burning up a core.

defaulting

The fact that caller may specify a default or not is kind of interesting, and makes analysis more complex. Consider offering a pair of related functions, with one of them ensuring that there's always a default value. Which would simplify the yes x | app.sh and app.sh < /dev/null cases. (For the immediate EOF case, perhaps app.sh execution is conditional on existence of some file, and so snuck through testing without ever asking a Y/N question, leading to sadness in diverse production environments.)

Alternatively, perhaps you have no "default-free" use cases, and can safely eliminate that code path.

Is the use of [⋯] to highlight the default ... clear?

Yes, seeing a "[y]/n" prompt is usual and makes for a good UX.


This codebase with associated specification is admirably clear and achieves its design goals.

I would be willing to delegate or accept maintenance tasks on it.

\$\endgroup\$
4
  • \$\begingroup\$ Oh, the while true; read; do was a mistake - the true is obviously redundant there. It's a cut+paste error: I meant to put the read line after the do. But perhaps we need to do something when read fails, so perhaps further thought is required there - perhaps exit in that case? \$\endgroup\$ Commented Aug 5 at 18:35
  • \$\begingroup\$ Interestingly, most locales I inspected accept anything beginning +, 1, y or Y for "yes" and anything beginning -, 0, n or N for "no" in addition to the locale-specific values (e.g. the ja_JP "yes" regex is ^([+1yYyY]|はい|ハイ)). I'm not sure what to expect if the localised "no" begins with y, but I guess that's theoretically possible. \$\endgroup\$ Commented Aug 5 at 18:43
  • \$\begingroup\$ I also noticed "S", presumably for the non-ambiguous "Si". I confess I was a little surprised to find my {default, minimal} Ubuntu server complaining about “no locale file”. It’s a failure mode that hadn’t occurred to me. In part because the locale I tend to specify is C, to avoid utf8 parsing so that /usr/bin/sort will work quickly. \$\endgroup\$
    – J_H
    Commented Aug 5 at 19:34
  • 1
    \$\begingroup\$ "if the localised "no" begins with y". I note in passing that in some cultures horizontal head nodding indicates agreement rather than dissent, which can lead to cross-cultural hilarity and ultimately to better understanding. If only programs could understand! \$\endgroup\$
    – J_H
    Commented Aug 5 at 19:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.