Why does a string equal zero in bash test - bash

I have an array which I am using to generate a list which a user can choose from made like this.
list=(a b c d)
n=0
for x in ${list[#]}
do echo $n\)$x
n=$((n++)
done
read -p "Pick an item:" choice
I want to allow only valid options to be chosen so I am checking like this.
if [[ $choice -gt ${#list[#]} && $choice -lt -1 ]]
then ...
else
echo "not a valid choice"
The issue I am having is all strings evaluate at equal to zero. ie [[ "I am a duck" -eq 0 ]] is True, as is (( "I am a duck" == 0 )). Is there a way to make all string and number comparison evaluate to false? I know I can check for a string with [[ $choice =~ [A-Za-z]+ ]], but I am wondering if there is a way without regular expressions?
EDIT
Sorry, I should have tested the "I am a duck" statement before I put it down. It doesn't like the spaces. However, "I_am_a_duck" does evaluate to 0. This explained by chepner below.

-gt, because it is intended to compare integers, triggers the same behavior for strings as seen in an arithmetic expression: the string is treated as the name of a parameter, and that parameter is expanded (repeat the process if necessary) until you get an integer. If there is no parameter by that name, 0 is substituted.
That said, you could weasel your way out of the problem by number your items starting at one and using
if (( choice < 1 || choice > ${#list[#]} )); then
echo "not a valid choice"
since now any non-integer inputs will be treated as 0, which is indeed less than the lower limit of 1.

I would use select for this and not deal with behaviour of strings in arithmetic contexts (as explained in chepner's answer) at all:
list=(a b c d)
PS3='Pick an item: '
select opt in "${list[#]}"; do
case $opt in
[abcd]) echo "Choice: $opt ($REPLY)" ;;
*) echo "Illegal choice" ;;
esac
done
This will keep looping; to continue after a valid selection, there has to be a break somewhere. The main benefit is select taking care of invalid inputs for you, and you don't have to build the menu yourself.

Related

Is there any better way to repeatedly call function for each case statement

I wrote a bash script to automate lamp installing in my server, they work great but there's a problem regarding its readability.
# Asking if want to install PHP or build from source
read -p "This script will install PHP, do you want to install it (y/n)?" phpchoice
case "$phpchoice" in
[Yy]* ) isinstallphp=yes;;
[Nn]* ) isinstallphp=no;;
* ) echo "invalid";;
esac
if [ isinstallphp == "yes" ]; then
# Prompt for php version
options=("php5.6" "php7.0" "php7.1" "php7.2" "php7.3" "php7.4" "php8.0" "php8.1")
function do_something () {
echo -e "${Ye}You picked ${Wh}$php_opt${Ye}, will be installing that instead${Nc}"
}
select php_opt in "${options[#]}"; do
case "$php_opt,$REPLY" in
php5.6,*|*,php5.6) do_something; break ;;
php7.0,*|*,php7.0) do_something; break ;;
php7.1,*|*,php7.1) do_something; break ;;
php7.2,*|*,php7.2) do_something; break ;;
php7.3,*|*,php7.3) do_something; break ;;
php7.4,*|*,php7.4) do_something; break ;;
php8.0,*|*,php8.0) do_something; break ;;
php8.1,*|*,php8.1) do_something; break ;;
esac
done
fi
The case statement part where I put do_something function looks messy, all the function do is echo which php option user choose in color. Is there any way to shorten the code ?
The case statement part where I put do_something function looks messy, all the function do is echo which php option user choose in color. Is there any way to shorten the code ?
It appears that you are accommodating the dual possibilities that the user chooses an option by typing its number or by typing the item text. One simplification would be to require the user to make their selection by item number only, in which case you would only need to confirm that $php_opt was not set to NULL. The case statement would not be required at all.
If you want to retain the full input functionality of the current script, then you can still do better than the current code. Instead of a long case statement covering all the options, check whether $php_opt is NULL, and if it is, check whether $REPLY is equal to one of the options. There is a variety of ways you could implement that, but I like this one:
validate_option() {
local choice=$1
while [[ $# -gt 1 ]]; do
shift
[[ "$choice" = "$1" ]] && return 0
done
return 1
}
# ...
select php_opt in "${options[#]}"; do
if [[ -n "$php_opt" ]] || validate_option "$REPLY" "${options[#]}"; then
do_something
fi
done
I find that a lot clearer. Note also that the validate_option function is reusable, and that this approach is completely driven by the option list, so you don't need to modify this code if the option list changes.
Addendum
You additionally raised a question about your given do_something function not printing the selected option when the user enters the option value instead of its number. This will have been a behavior of your original code, too. It arises from the fact that the select command sets the specified variable (php_opt in your case) to NULL in the event that the user enters a non-empty response that is not one of the menu item numbers.
If you want to avoid that, and perhaps also to have the selected value in the form of the option string for other, later processing, then you probably want to address that issue in the body of the select statement. Something like this variation might do, for example:
select php_opt in "${options[#]}"; do
if [[ -n "$php_opt" ]] ||
{ php_opt=$REPLY; validate_option "$php_opt" "${options[#]}"; }; then
do_something
fi
done
That copies the user-entered text into $php_opt in the event that the user entered something other than an option number. Do note that that behavior change might have other effects later in the script, too.
What you're checking is if $php_opt or $REPLY is in the $options array:
array_contains() {
local -n ary=$1
local elem=$2
local IFS=$'\034'
[[ "${IFS}${ary[*]}$IFS" == *"${IFS}${elem}$IFS"* ]]
}
#...
select php_opt in "${options[#]}"; do
if array_contains options "$php_opt" || array_contains options "$REPLY"; then
do_something
break
fi
done
local -n requires bash v4.3+
Octal 034 is the ASCII "FS" character, it's unlikely to be in your data
select sets the given variable name to the selected string, only if a valid selection is made. If an invalid selection is made (ie. not one of the available numbers), $php_opt will be empty. Even if a valid selection was made in a previous iteration, it will be reset to empty.
Hence you just need to test if $php_opt is empty or not:
select php_opt in "${options[#]}"; do
# check for literal reply
reply=${REPLY,,}
for i in "${options[#]}"; do
if [[ "$i" == "$reply" ]]; then
php_opt=$reply
break
fi
done
if [[ "$php_opt" ]]; then
do_something
break
else
echo "$REPLY: invalid selection"
fi
done
Or with case:
select php_opt in "${options[#]}"; do
# check for literal reply
reply=${REPLY,,}
for i in "${options[#]}"; do
if [[ "$i" == "$reply" ]]; then
php_opt=$reply
break
fi
done
case $php_opt in
'') echo "$REPLY: invalid selection";;
*) do_something; break
esac
done
From help select:
If the line consists of the number corresponding to one of the displayed words, then NAME is set to that word. If the line is empty, WORDS and the prompt are redisplayed. If EOF is read, the command completes. Any other value read causes NAME to be set to null. The line read is saved in the variable REPLY.
Also note that you are missing $ in your if statement.
edit: as per the comment, I added a check for a literal name, as well as a number. It's case insensitive, PHP7.0 is valid input. Using the original array is easier to maintain, than a second list of patterns in a case statement.
To make things tidier, you can also put do_something after select (so you only need break). Maybe something like [[ "$reply" == [Qq] ]] && exit too. Similarly in your very first case statement, just do exit if the reply is no.

Shell Scripting - Numeric Checks and if statement questions

I'm relatively new here and to the coding world. I'm currently taking a class in Shell Scripting and I'm a bit stuck.
I'm trying to do a little extra credit and get the script to check for command line arguments and if none or only 1 is given, prompt the user to input the missing values.
For the most part I've been able to get most of it to work except for when it comes to the numeric check part. I'm not completely sure that I am doing the nested if statements correctly because it's displaying both the "if" echo and the "else" echo.
My script so far:
q=y
# Begins loop
until [[ $q == n ]];do
# Checks command line arguments
if [[ $# -lt 2 ]];then
# Asks for second number if only 1 argument.
if [[ $# == 1 ]];then
read -r -p "Please enter your second number: " y
if [[ y =~ [1-9] ]];then
echo "You've chosen $1 as your first number and $y as your second number."
break
else
echo "This is not a valid value, please try again."
fi
# Asks for both numbers if no arguments.
else
read -r -p "Please enter your first number: " x
if [[ x =~ [1-9] ]];then
break
else
echo "This is not a valid value, please try again."
fi
read -r -p "Please enter your second number: " y
if [[ y =~ [1-9] ]];then
break
else
echo "This is not a valid value, please try again."
fi
echo "You've chosen $x as your first number and $y as your second number."
fi
# If both command line arguments are provided, echo's arguments, and sets arguments as x and y values.
else
echo "You've chosen $1 as your first number and $2 as your second number."
x=$1
y=$2
fi
read -r -p "Would you like to try again? (n to exit): " q
done
When I run it I get this for output:
Please enter your first number: 1
This is not a valid value, please try again.
Please enter your second number: 2
This is not a valid value, please try again.
You've chosen 1 as your first number and 2 as your second number.
Please enter your first number:
And will just continue to loop without breaking. Any help/guidance would be greatly appreciated, thank you.
In your expression:
if [[ x =~ [1-9] ]]; then
You are actually comparing the string literal "x" with the regex. What you want is the variable:
if [[ $x =~ [1-9] ]]; then
This will interpolate the variable first in order to compare the variable's value with the regex. I think this change also applies to some of the other comparison expressions in your code.
However, as glenn jackman and user1934428 have commented, this will also match things like foo1bar, which is probably not what you want. To fix this, you can add start/end matchers to your regex. Finally, you may want to match even if the input has leading or trailing spaces. One way to do this is to add some [[:space:]]*'s to match zero or more spaces around your [1-9]:
if [[ $x =~ ^[[:space:]]*[1-9][[:space:]]*$ ]]; then
So, to break down the regex:
^ start of input
[[:space:]]* zero or more whitespaces
[1-9] a single digit, 1-9
[[:space:]]* zero or more whitespaces
$ end of the input
I'm assuming from your question than you only want to match on a single digit, not, for example, 12, or the digit 0. To match those would require a couple more regex tweaks.
and...glob pattern
Just because glen jackman's answer led me down a bash man page adventure 🏄 and I wanted to try them out, this is a glob pattern version (note the == instead of =~):
if [[ $x == *([[:space:]])[1-9]*([[:space:]]) ]]; then
It's basically the same pattern. But notably, glob patterns seem to be implicitly anchored to the start/end of the string being matched (they are tested against the entire string) so they don't need the ^ or $, while regular expressions match against substrings by default, so they do need those additions to avoid foo1bar matching. Anyway, probably more than you cared to know.
Here's an alternate implementation, for your consideration: hit me up with any questions
#!/usr/bin/env bash
get_number() {
local n
while true; do
read -rp "Enter a number between 1 and 9: " n
if [[ $n == [1-9] ]]; then
echo "$n"
return
fi
done
}
case $# in
0) first=$(get_number)
second=$(get_number)
;;
1) first=$1
second=$(get_number)
;;
*) first=$1
second=$2
;;
esac
# or, more compact but harder to grok
[[ -z ${first:=$1} ]] && first=$(get_number)
[[ -z ${second:=$2} ]] && second=$(get_number)
echo "You've chosen $first as your first number and $second as your second number."
This uses:
a function to get a a number from the user, so you don't have so much duplicated code,
a case statement to switch over the $# variable
input validation with the == operator within [[...]] -- this operator is a pattern matching operator, not string equality (unless the right-hand operand is quoted)
Note that [[ $x =~ [1-9] ]] means: "$x contains a character in the range 1 to 9" -- it does not mean that the variable is a single digit. If x=foo1bar, then the regex test passes.

bug? bash select-- typed data is returned as "unbound variable"

I'm running Linux 3.10.0-693.2.2.el7.x86_64, and for the life of me can't figure why this would happen. It appears to be a bug and shellchecker finds no issues.
#!/bin/bash
set -o nounset
### functions
options=(one two three)
select var in "${options[#]}"; do
# make sure it is a valid choice
if (( REPLY <= ${#options[#]} )) && [[ $REPLY =~ ^[0-9]+$ ]]; then
case $var in
one) exit;;
two) df -h /tmp;;
*) echo $var;;
esac
break
else
printf "Invalid selection.\n" >&2
fi
I used set -xv to trouble shoot, but here's the output without it. In production, the options[#] will be set by a command, and the number they return will be dynamic. So I want the menu to execute a command in *) on $var-- but I have to check for an out of bounds selection in REPLY. Here is the output.
$ bad_select.bash
1) one
2) two
3) three
#? 4
Invalid selection.
#? t
/home/lc5550358/bin/select_menu.wip: line 9: t: unbound variable
The t that I typed? I can also avoid the unbound variable, but entering k=2 or var (the latter is defined in the select). Why and what is the work around
(set -o nounset is needed)?
Inside an arithmetic expression, bash evaluates the value of a variable. Thus, in (( REPLY <= ${#options[#]} )), the value of REPLY is evaluated. If the value of REPLY is t and t is unbound, then that triggers the unbound variable error.
Observe:
$ t=2*3; REPLY=t; echo $(( REPLY + 2 ))
8
$ REPLY=t; t=a+b; a=6; b=c; c=10; echo $(( REPLY + 2 ))
18
$ unset t; REPLY=t; echo $(( REPLY + 2 ))
bash: t: unbound variable
As jm666 explained, one solution is reverse the order of the tests so that arithmetic is done on REPLY only after it is verified that REPLY is an integer.
Alternatively, you can test var instead of REPLY:
#!/bin/bash
set -o nounset
### functions
options=(one two three)
select var in "${options[#]}"; do
# make sure it is a valid choice
if [ "$var" ]; then
case "$var" in
one) exit;;
two) df -h /tmp;;
*) echo "$var";;
esac
break
else
printf "Invalid selection.\n" >&2
fi
done
This works because var is only assigned a value if the user provided a valid response at the prompt. Otherwise, var is set to be null.
Documentation
From the man bash section on arithmetic evaluation:
Shell variables are allowed as operands; parameter expansion is
performed before the expression is evaluated. Within an expression,
shell variables may also be referenced by name without using the
parameter expansion syntax. A shell variable that is null or unset
evaluates to 0 when referenced by name without using the parameter
expansion syn‐ tax. The value of a variable is evaluated as an
arithmetic expression when it is referenced, or when a variable which
has been given the integer attribute using declare -i is assigned a
value. A null value evaluates to 0. A shell variable need not have
its integer attribute turned on to be used in an expression.

case statement and comparison operator

Having trouble with a case statement where the length of a string is evaluated. More in particular the part that isn´t working corresponds with the line containing [[ ${#numPase} -lt 8 ]]).
read numPase
case $numPase in
q|Q) var_cntrl_pase_ok=false
;;
(*[!0-9]*|'')
echo " "
echo "Variable must contain integers. Press key to continue."
read
;;
[[ ${#numPase} -lt 8 ]])
echo " "
echo "Variable must have 8 digits. Press key to continue."
read
;;
esac
NOTE:
Variable numPase takes value from user input.
First case determines if user has pressed q or Q to quit menu loop. Second and third cases are self-explanatory
Why isn´t the 8 digit validation working?
The pattern must be one or more globs separated by vbars. Any text in the pattern will be treated as a glob and never as a command.

Linux Regular Expression

I'm working with shell scripting in Linux. I want to check if the value of MAX_ARCHIVE_AGE is numeric or not. My code is like this:
MAX_ARCHIVE_AGE = "50"
expr="*[0-9]*"
if test -z "$MAX_ARCHIVE_AGE";
then
echo "MAX_ARCHIVE_AGE variable is missing or not initiated"
else
if [ "$MAX_ARCHIVE_AGE" != $expr ]
then
echo "$MAX_ARCHIVE_AGE is not a valid value"
fi
fi
I want to match the value of MAX_ARCHIVE_AGE with my expr. Please help.
For POSIX compatibility, look at case. I also find it more elegant than the corresponding if construct, but the syntax may seem a bit odd when you first see it.
case $MAX_ARCHIVE_AGE in
'' ) echo "empty" >&2 ;;
*[!0-9]* ) echo "not a number" >&2 ;;
esac
By the way, notice the redirection of error messages to standard error with >&2.
Your expr will match anything that contains any digits; it's better to check if it contains only digits, or conversely, to check if it contains any non-digits. To do that, you can write:
if ! [[ "$MAX_ARCHIVE_AGE" ]] ; then
echo "MAX_ARCHIVE_AGE is blank or uninitialized" >&2
elif [[ "$MAX_ARCHIVE_AGE" == *[^0-9]* ]] ; then
echo "$MAX_ARCHIVE_AGE is not a valid value" >&2
fi
Also, note that you would initialize MAX_ARCHIVE_AGE by writing e.g. MAX_ARCHIVE_AGE=50 (no spaces), not MAX_ARCHIVE_AGE = 50. The latter tries to run a program called MAX_ARCHIVE_AGE with the arguments = and 50.

Resources