Useful Bash functions - part 3 (HPR Show 2448)

Dave Morriss


Table of Contents

Overview

This is the third show about Bash functions. These are a little more advanced than in the earlier shows, and I thought I’d share them in case they are useful to anyone.

As before it would be interesting to receive feedback on these functions and would be great if other Bash users contributed ideas of their own.

Example Functions

The read_value function

The purpose of this function is to output a prompt and read a string. The string is written to a nominated variable and a default string can be provided if required.

A typical call might be:

$ read_value 'What is your name? ' name
What is your name? Herbert
$ echo $name
Herbert

Here, the first argument is the prompt and the second is the name of the variable to receive the answer.

If the default is used then the reply is pre-filled with it:

$ read_value 'Where do you live? ' country USA
Where do you live? USA

This prompt can be deleted and another value used instead.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#===  FUNCTION  ================================================================
#         NAME: read_value
#  DESCRIPTION: Read a value from STDIN and handle errors.
#   PARAMETERS: 1 - Prompt string for the read
#               2 - Name of variable to receive the result
#               3 - Default value (optional)
#      RETURNS: 1 on error, otherwise 0
#===============================================================================
read_value () {
    local prompt="${1:?Usage: read_value prompt outputname [default]}"
    local outputname="${2:?Usage: read_value prompt outputname [default]}"
    local default="${3:-}"
    local var

    #
    # Make an option for the 'read' if there's a default
    #
    if [[ -n $default ]]; then
        default="-i '$default'"
    fi

    #
    # Read and handle CTRL-D (EOF). Use 'eval' to deal with the argument being
    # a variable
    #
    eval "read -r -e $default -p '$prompt' var"
    res="$?"
    if [[ $res -ne 0 ]]; then
        echo "Read aborted"
        return 1
    fi

    #
    # Return the value in the nominated variable
    #
    eval "$outputname='$var'"
    return 0
}

The function is not very complex and has a number of similarities with the various iterations of the yes_no function encountered in earlier episodes. See the Links section below for links to these episodes.

We will not dwell too long on this function as a consequence of these similarities. However, it does something the previous functions didn’t do, it returns a string to the caller.

Bash functions can’t return anything but numeric values (via return), unlike higher level languages. This function writes a global variable with the value it has requested. It could have been designed to always write to the same global variable, but this is ugly. I wanted the caller to be able to nominate the variable to receive the result.

The main purpose of the script is to call the Bash built-in command ‘read’ with various options. If there is a default string provided we need to turn that into an option preceded by ‘-i’. This is achieved on lines 18-20 where we store the result back in the variable ‘default’.

The read is performed on line 26, and we use the ‘eval’ command to do this. This used because we need to make Bash scan the line twice, and the way eval works allows us to achieve this.

The eval command takes its arguments (in this case a string), concatenates them and executes the result in the current environment. The string it is presented with in this case is first processed by Bash where it performs the various types of expansion.

If we look at an example this might help to clarify the issue (the function has to be made available to Bash using the source command before this will work):

$ read_value 'What is your surname? ' surname 'Not provided'

Here, the variable default will contain ‘Not provided’ (without the quotes, which get stripped when the command is parsed by Bash), and that will be converted to “-i 'Not provided'”. The argument to eval will then be expanded to:

read -r -e -i 'Not provided' -p 'What is your surname? ' var

This command will then be executed.

If we had not used eval and had instead written:

read -r -e $default -p "$prompt" var

The contents of default would not have been parsed by Bash. Using eval causes two scans of the command. First time the parameters are substituted, and the second time the command is executed.

The following is the result of running the function on the command line with tracing on (set -x):

$ set -x

$ read_value 'What is your surname? ' surname 'Not provided'
+ read_value 'What is your surname? ' surname 'Not provided'
+ local 'prompt=What is your surname? '
+ local outputname=surname
+ local 'default=Not provided'
+ local var
+ [[ -n Not provided ]]
+ default='-i '\''Not provided'\'''
+ eval 'read -r -e -i '\''Not provided'\'' -p '\''What is your surname? '\'' var'
++ read -r -e -i 'Not provided' -p 'What is your surname? ' var
What is your surname? Putin
+ res=0
+ [[ 0 -ne 0 ]]
+ eval 'surname='\''Putin'\'''
++ surname=Putin
+ return 0

$ set +x; echo "$surname"
+ set +x
Putin

The lines that start with a $ are commands I typed, and those beginning with a + are the output of Bash’s trace mode. I added blank lines after each command (plus any output it generated).

  • You can see the function arguments being placed in local variables.
  • The default value is saved
  • When the eval is shown all of the variables used are expanded, then the resulting command is run
  • The prompt is shown, with my response “Putin”
  • The eval that returns the result in variable surname is shown (the variable name is in the local variable var)
  • I turn off the trace (set +x) and echo the result in this variable.

The check_value function

This function was designed to be used in conjunction with read_value to check that the string read in is valid.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#===  FUNCTION  ================================================================
#         NAME: check_value
#  DESCRIPTION: Checks a value against a list of regular expressions
#   PARAMETERS: 1 - the value to be checked
#               2..n - valid Bash regular expressions
#      RETURNS: 0 if the value checks, otherwise 1
#===============================================================================
check_value () {
    local value="${1?Usage: check_value value list_of_regex}"
    local matches=0

    #
    # Drop parameter 1 then there should be more
    #
    shift
    if [[ $# == 0 ]]; then
        echo "Usage: check_value value list_of_regex"
        return 1
    fi

    #
    # Loop through the regex args checking the value, counting matches
    #
    while [[ $# -ge 1 ]]
    do
        if [[ $value =~ $1 ]]; then
            (( matches++ ))
        fi
        shift
    done

    #
    # No matches, then the value is bad
    #
    if [[ $matches == 0 ]]; then
        return 1
    else
        return 0
    fi
}

The idea is that read_value has been used to get a string from the user of a script. This string may need to be checked to see whether it conforms to a pattern. For example, I have written a script that helps me manage the HPR shows I am in the process of writing. At some point I will have chosen a slot in the queue and want to record that the show number is hpr9876 or whatever. I might want even to perform the check against a list of possibilities. I would give the incoming string to check_value and get it to compare against a list of regular expressions.

  • The function takes the value to be checked as the first argument followed by one or more (Bash-style) regular expressions. Note that we use a variant of the usual parameter substitution in the local command here1.
  • After saving the first argument in the variable called value the next thing the function does (line 15) is to drop the first argument from the parameter list.
  • Then (lines 16-19) it checks the $# variable (number of arguments) to see if there are any regular expressions. If not it prints an error message and exits with a ‘false’ value.
  • A while loop (lines 24-30) then processes each argument until there are no more. Each time it compares the argument (assuming it’s a regular expression) against the variable called value, incrementing the matches variable if they match. After the test shift is used to drop that argument.
  • By line 35 the matches variable should be zero if nothing matched or non-zero if there were any matches. The function returns ‘false’ in the first instance and ‘true’ otherwise.

This might be tested as follows:

$ source read_value.sh
$ source check_value.sh
$ demo () {
    name=
    until read_value "What is your first name? " name && test -n "$name"; do
        :
    done

    if check_value "$name" "^[A-Za-z]+$" "^0[Xx][A-Fa-f0-9]+$"; then
        echo "Hello $name"
    else
        echo "That name isn't valid"
    fi
}
$ demo
What is your first name? Herbert
Hello Herbert
$ demo
What is your first name? 0x1101
Hello 0x1101
$ demo
What is your first name? Jim42
That name isn't valid
$ demo
What is your first name? 0xDEAD42
Hello 0xDEAD42
$ demo
What is your first name? DEAD42
That name isn't valid
  • This defines a temporary function called demo
  • The variable name is created and made empty
  • The function read_value is called in an until loop where it requests the caller’s first name and writes the response to name. It is concatenated2 with a call to the built-in test command using the -n option against the contents of the name variable (substituted into a string). This returns ‘true’ if the string is not empty, in which case the loop stops. If no name is given the loop will repeat. The loop body consists of a null command (colon).
  • A call to check_value follows in an if command. If the check returns ‘true’ then the name variable is echoed, otherwise an error message is given.
  • The call to check_value uses name as the string to check and it matches against two regular expressions. The first checks for at least one upper or lower case alphabetic character, and nothing else (no spaces in the name allowed). The second expects a hexadecimal number which begins with ‘0X’ or ‘0x’ followed by at least one hexadecimal digit.

The read_and_check function

This function uses the two previous functions to read a value and check it is valid. It loops until a valid reply is received or the process is aborted with CTRL-D.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#===  FUNCTION  ================================================================
#         NAME: read_and_check
#  DESCRIPTION: Reads a value (see read_value) and checks it (see check_value)
#               against an arbitrary long list of Bash regular expressions
#   PARAMETERS: 1 - Prompt string for the read
#               2 - Name of variable to receive the result
#               3 - Default value (optional)
#               4..n - Valid regular expressions
#      RETURNS: Nothing
#===============================================================================
read_and_check () {
    local prompt="${1:?Usage: read_and_check prompt outputname [default] list_of_regex}"
    local outputname="${2:?Usage: read_and_check prompt outputname [default] list_of_regex}"
    local default="${3:-}"

    if ! read_value "$prompt" "$outputname" "$default"; then
        return 1
    fi
    shift 3
    until check_value "${!outputname}" "$@"
    do
        echo "Invalid input: ${!outputname}"
        if ! read_value "$prompt" "$outputname" "$default"; then
            return 1
        fi
    done

    return 0
}
  • The function expects a prompt (for read_value), the name of a variable to receive the result, an optional default value and a list of regular expressions.
  • The first thing the function does is to call read_value (line 16) with the relevant parameters. It does this in an if command because if the prompt is aborted with CTRL-D then a ‘false’ value is returned, so we abort read_and_check if so.
  • Next, on line 19, the shift command deletes the first three arguments so we can pass the remainder to check_value.
  • Then check_value is called in an until loop (lines 20-26) checking the value returned against the list of regular expressions. If the check is passed the loop ends and the function exits. If not then an error message is written and read_value called again (with a check for CTRL-D as before).
  • A complicating factor in this function is that the local variable outputname contains the name of the (global) variable to receive the value from the user. When we want to examine the contents of this variable we have to use indirect expansion of the form ${!outputname}. This means examine the contents of outputname and use what is there as the name of a variable whose value we require. We need to do this when handing the value returned by read_value to check_value, and when reporting that the value is invalid.

A test of this function might be as follows:

$ source read_value.sh
$ source check_value.sh
$ source read_and_check.sh
$ read_and_check "Enter slot: " slot "" '^(hpr[0-9]+)?$'
Enter slot: HPR42
Invalid input: HPR42
Enter slot:
$ echo "/$slot/"
//
  • As before the functions are sourced to ensure Bash knows about them. You only need to do this once for each one if testing them. If using these functions in a script you’d probably just copy all three into the script, which would achieve the same.
  • Then read_and_check is called to collect an HPR slot number (I use this in my episode preparation toolkit). The variable slot is to hold the result, and there is no default.
  • The regular expression accepts lower-case ‘hpr’ followed by a number, or nothing at all.
  • The first input is rejected because it uses upper-case letters, and the second null input is accepted with no problem.
  • The echo shows that variable slot is empty.

Conclusion

These three functions are adequate for my use; I use them in a number of scripts I have written. They are not 100% bullet-proof. For example, if a regular expression is mistyped things could fail messily.

The business of passing information back from a function is messy, though it works. It can be streamlined by the use of nameref variables, which we will look at another time.

Please comment or email me with any improvements or changes you think would make these functions better.


  1. The command

    local value="${1?Usage: check_value value list_of_regex}"

    is used in this function. The parameter substitution expression ${parameter:?word} means that the value of word is used as an error message and the script exits if the parameter is null or unset. Since we might want to use a parameter which is null we use the variant ${parameter?word} which merely checks for existence.

  2. Calling it concatenation is not strictly true. This is what is known as an AND list in Bash. Its generic form is: command1 && command2, and command2 is executed if and only if command1 returns an exit status of zero.