getops $OPTARG is empty if flag value contains brackets - bash

When I pass a flag containing [...] to my bash script, getops gives me an empty string when I try to grab the value with $OPTARG.
shopt -s nullglob
while getopts ":f:" opt; do
case $opt in
f)
str=$OPTARG
;;
esac
done
echo ${str}
Running the script:
$ script.sh -f [0.0.0.0]
<blank line>
How can I get the original value back inside the script?

Short summary: Double-quote your variable references. And use shellcheck.net.
Long explanation: When you use a variable without double-quotes around it (e.g. echo ${str}), the shell tries to split its value into words, and expand anything that looks like a wildcard expression into a list of matching files. In the case of [0.0.0.0], the brackets make it a wildcard expression that'll match either the character "0" or "." (equivalent to [0.]). If you had a file named "0", it would expand to that string. With no matching file(s), it's normally left unexpanded, but with the nullglob set it expands to ... null.
Turning off nullglob solves the problem if there are no matching files, but isn't really the right way do it. I remember (but can't find right now) a question we had about a script that failed on one particular computer, and it turned out the reason was that one computer happened to have a file that matched a bracket expression in an unquoted variable's value.
The right solution is to put double-quotes around the variable reference. This tells the shell to skip word splitting and wildcard expansion. Here's an interactive example:
$ str='[0.0.0.0]' # Quotes aren't actually needed here, but they don't hurt
$ echo $str # This works without nullglob or a matching file
[0.0.0.0]
$ shopt -s nullglob
$ echo $str # This fails because of nullglob
$ shopt -u nullglob
$ touch 0
$ echo $str # This fails because of a matching file
0
$ echo "$str" # This just works, no matter whether file(s) match and/or nullglob is set
[0.0.0.0]
So in your script, simply change the last line to:
echo "${str}"
Note that double-quotes are not required in either case $opt in or str=$OPTARG because variables in those specific contexts aren't subject to word splitting or wildcard expansion. But IMO keeping track of which contexts it's safe to leave the double-quotes off is more hassle than it's worth, and you should just double-quote 'em all.
BTW, shellcheck.net is good at spotting common mistakes like this; I recommend feeding your scripts through it, since this is probably not the only place you have this problem.

Assuming that shopt -s nullglob is needed in the bigger script.
You can temporary disable shopt -s nullglob using shopt -u nullglob
shopt -s nullglob
shopt -u nullglob
while getopts ":f:" opt; do
case $opt in
f)
str=$OPTARG
;;
esac
done
echo ${str}
shopt -s nullglob

Related

Associative array not unset when nullglob is set

When I set nullglob in bash:
shopt -s nullglob
and then declare an associative array:
declare -A arr=( [x]=y )
I cannot unset specific keys within the array:
unset arr[x]
echo ${#arr[#]} # still 1
However, unsetting nullglob makes this operation work like I expect:
shopt -u nullglob
unset arr[x]
echo ${#arr[#]} # now it's 0; x has been removed
What's going on here? I don't see how shell globbing could be relevant to the situation. I've tested this on bash 4.4.19 and 5.0.0.
This can be explained by reference the the bash documentation (the man page), paraphrased here:
After word splitting, unless the -f option has been set, Bash scans each word for the characters '*', '?', and '['. If one of these characters appears, then the word is regarded as a pattern, and replaced with an alphabetically sorted list of filenames matching the pattern.
If no matching filenames are found, and the shell option nullglob is disabled, the word is left unchanged. If the nullglob option is set, and no matches are found, the word is removed.
In other words, nullglob affects what will happen to your arr[x] argument. It will either be left alone or removed.
You can see this effect by turning on echo-before-execute flag with set -x:
pax$ declare -A arr=( [x]=y )
pax$ shopt -s nullglob
pax$ set -x
pax$ unset arr[x]
+ unset
Note that this is the "word is removed" case. The "word is left unchanged" case is show thus:
pax$ shopt -u nullglob
+ shopt -u nullglob
pax$ unset arr[x]
+ unset 'arr[x]'
That final echoed command above also provides a clue as to how to delete an entry if you have enable nullglob. Just quote the argument to prevent expansion:
unset 'arr[x]'
This will work regardless of the nullglob setting, because of the section regarding quoting in the documentation:
Enclosing characters in single quotes preserves the literal value of each character within the quotes.

use bash to rename a file with spaces and regex

If I have a file name with spaces and a random set of numbers that looks like this:
file name1234.csv
I want to rename it to this (assuming date is previously specified):
file_name_${date}.csv
I am able to do it like this:
mv 'file name'*'.csv file_name_${date}.csv
However, in a situation that 'file name*.csv' can actually match multiple files, I want to specify that it's 'file name[random numbers].csv'
I've searched around and can't find any relevant answers.
You need what is called a "pathname expansion", to match one or more digits:
+([0-9])
A functional script could be like this one:
date=$(date +'%Y-%m-%d')
shopt -s extglob nullglob
for f in 'file name'+([[:digit:]]).csv; do
file="${f%%[0-9]*}"
echo mv "$f" "${file// /_}_${date}.csv"
done
Warning: all files found will be renamed to just one name, make sure that that is what you want before removing the echo.
To activate the extended version of "Pathname Expansion" we use shopt -s extglob.
To avoid the case where no file is matched, we also need the nullglob set.
We can set the positional arguments to the result of the above expansion.
Then we loop over all files found to change each of their names.
The ${f%%[0-9]*} removes all from the digits to the end.
The ${file// /_} replaces spaces with underscores.
The mv is not actually done with the script presented because of the echo.
If after running a test, you want the change(s) performed, remove the echo.
Use Extended Globs and Parameter Expansion
You can do what you want with Bash extended globs and a few parameter expansions, without resorting to external or non-standard utilities.
date="2016-11-21"
shopt -s extglob
for file in 'file name'+([[:digit:]]).csv; do
newfile="${file%%[0-9]*}"
newfile="${newfile// /_}"
mv "$file" "${newfile}_${date}.csv"
done

Case statement with extglob

With extglob on, I want to match a variable against
*( )x*
(In regex: /^ *x.*/)
This:
main(){
shopt -s extglob
local line=' x bar'
case "$line" in
*( )x*) ;;
*) ;;
esac
}
main "$#"
is giving me a syntax error. Either removing the extglob parentheses or moving shopt -s extglob outside of main, to the outer scope, fixes the problem.
Why? Why does the shopt -s extglob command need to be outside?
bash has to parse the function in order to create it, and since the extended glob syntax you're using would normally be invalid, it can't parse the function unless extglob is on when the function is created.
Net result: extglob has to be on both when the function is declared and when it runs. The shopt -s extglob line in the function takes care of the second requirement, but not the first.
BTW, there are some other places where this can be a problem. For example, if you have a while or for loop, bash needs to parse the entire loop before beginning to run it. So if something in the loop uses extglob, you have to enable it before the beginning of the loop.
You're missing the in keyword.
case "$var" in
*( )x*) echo yes;;
esac

bash loop over file mask

What's the proper way to do a for loop over a file mask?
E.g. if the mask doesn't expand to any file, then the loop should not run; else, it should run over all the files that it expands to.
The problem with naive approach is that if the * doesn't expand to anything, then the whole loop will run once as if the * part is an actual part of a filename (of a file that doesn't actually exist).
One way to do this is:
for f in abc*; do if [[ -f "$f" ]]; then
# Do something with "$f"
fi; done
That will also filter directories out of the list, which might or might not be what you want.
If you want to keep directories, use -e instead of -f.
Another way to do it is to set the shell option nullglob (may not be available on all bash versions). With nullglob set, patterns which don't match any filename will result in nothing instead of being unaltered.
shopt -s nullglob
for f in abc*; do
# do something with $f
done
shopt -u nullglob
(That leaves nullglob set for the duration of the for loop. You can unset it inside the loop instead of waiting for the end, at the smallish cost of executing the shopt -u on every loop.)
Use the nullglob shell option to make a wildcard that doesn't match anything expand into nothing instead of returning the wildcard itself:
shopt -s nullglob
for file in abc*
do
...
done

Wildcard expansion (globbing) in a string composed of quoted and unquoted parts

I have a line in a shell script that looks like this:
java -jar "$dir/"*.jar
, since I just want to execute whatever the jar file happens to be named in that folder. But this is not working as I expected. I get the error message:
Error: Unable to access jarfile [folder-name]/*.jar
It is taking the '*' character literally, instead of doing the replacement that I want. How do I fix this?
EDIT: It is working now. I just had the wrong folder prefix :/ For anybody wondering, this is the correct way to do it.
You just need to set failglob:
shopt -s failglob
to avoid showing literal *.jar when none are matched in a given folder.
PS: This will generate an error when it fails to match any *.jar as:
-bash: no match: *.jar
Explanation and background information
The OP's problem was NOT with globbing per se - for a glob (pattern) to work, the special pattern characters such as * must be unquoted, which works even in strings that are partly single- or double-quoted, as the OP correctly did in his question:
"$dir/"*.jar # OK, because `*` is unquoted
Rather, the problem was bash's - somewhat surprising - default behavior of leaving a pattern unexpanded (leaving it as is), if it happens not to match anything, effectively resulting in a string that does NOT represent any actual filesystem items.
In the case at hand, "$dir" happened to expand to a directory that did not contain *.jar files, and thus the resulting string passed to java ended in literal *.jar ('<value of $dir>/*.jar'), which due to not referring to actual .jar files, resulted in the error quoted in the question.
Shell options govern globbing (more formally called pathname expansion):
set -f (shopt -so noglob) turns off globbing altogether, so that unquoted strings (characters) that would normally cause a string to be treated as a glob are treated as literals.
shopt -s nullglob alters the default behavior to expanding a non-matching glob to an empty string.
shopt -s failglob alters the default behavior to reporting an error and setting the exit code to 1 in the case of a non-matching glob, without even executing the command at hand - see below for pitfalls.
There are other globbing-related options not relevant to this discussion - to see a list of all of them, run { shopt -o; shopt; } | fgrep glob. For a description, search by their names in man bash.
Robust globbing solutions
Note: Setting shell options globally affects the current shell, which is problematic, as third-party code usually makes the - reasonable - assumption that defaults are in effect. Thus, it is good practice to only change shell options temporarily (change, perform action, restore) or to localize changing their effect by using a subshell ((...)).
shopt -s nullglob
Useful for enumerating matches in a loop with for - it ensures that the loop is never entered, if there are no matches:
shopt -s nullglob # expand non-matching globs to empty string
for f in "$dir/"*.jar; do
# If the glob matched nothing, we never get here.
# !! Without `nullglob`, the loop would be entered _once_, with
# !! '<value of $dir>/*.jar'.
done
Problematic when used with arguments to commands that have default behavior in the absence of filename arguments, as it can result in unexpected behavior:
shopt -s nullglob # expand non-matching globs to empty string
wc -c "$dir/"*.jar # !! If no matches, expands to just `wc -c`
If the glob doesn't match anything, just wc -c is executed, which does not fail, and instead starts reading stdin input (when run interactively, this will simply wait for interactive input lines until terminated with Ctrl-D).
shopt -s failglob
Useful for reporting a specific error message, especially when combined with set -e to cause automatic aborting of a script in case a glob matches nothing:
set -e # abort automatically in case of error
shopt -s failglob # report error if a glob matches nothing
java -jar "$dir/"*.jar # script aborts, if this glob doesn't match anything
Problematic when needing to know the specific cause of an error and when combined with the || <command in case of failure> idiom:
shopt -s failglob # report error if a glob matches nothing
# !! DOES NOT WORK AS EXPECTED.
java -jar "$dir/"*.jar || { echo 'No *.jar files found.' >&2; exit 1; }
# !! We ALWAYS get here (but exit code will be 1, if glob didn't match anything).
Since, with failglob on, bash never even executes the command at hand if globbing fails, the || clause is also not executed, and overall execution continues.
While the failed glob will cause the exit code to be set to 1, you won't be able to distinguish between failure due to a non-matching glob vs. failure reported by the command (after successful globbing).
Alternative solution without changing shell options:
With a little more effort, you can do your own checking for non-matching globs:
Ad-hoc:
glob="$dir/*.jar"
[[ -n $(shopt -s nullglob; echo $glob) ]] ||
{ echo 'No *.jar files found.' >&2; exit 1; }
java -jar $glob
$(shopt -s nullglob; echo $glob) sets nullglob and then expands the glob with echo, so that the subshell either returns matching filenames or, if nothing matches, an empty string; that output, thanks to command substitution ($(...)), is passed to -n, which tests whether the string is empty, so that the overall [[ ... ]] conditional's exit code reflects if something matched (exit code 0) or not (exit code 1).
Note that any command inside a command substitution runs in a subshell, which ensures that the effect of shopt -s nullglob only applies to that very subshell and thus doesn't alter global state.
Also note how the entire right-hand side in the variable assignment glob="$dir/*.jar" is double-quoted, to illustrate the point that quoting with respect to globbing matters when a variable is referenced later, not when it is defined. The unquoted references to $glob later ensure that the entire string is interpreted as a glob.
With a small helper function:
# Define simple helper function.
exists() { [[ -e $1 ]]; }
glob="$dir/*.jar"
exists $glob || { echo 'No *.jar files found.' >&2; exit 1; }
java -jar $glob
The helper function takes advantage of shell applying globbing upon invocation of the function, and passing the results of globbing (pathname expansion) as arguments. The function then simply tests whether the 1st resulting argument (if any), refers to an existing item, and sets the exit code accordingly (this will work regardless of whether nullglob happens to be in effect or not).

Resources