Bash lints beyond ShellCheck
ShellCheck is great, but it can’t catch everything. This is a collection of lints which I believe ShellCheck doesn’t currently detect.
SC+1 Indent continuation lines past the starting line.
Update: Filed issue.
Problematic code:
foo
a bunch of arguments broken into \
multiple \
lines
bar
Correct code:
foo
a bunch of arguments broken into \
multiple \
lines
bar
Rationale:
This clearly differentiates continuation lines from neighboring commands.
SC+2 Use modern test syntax.
Update: Filed issue.
Problematic code:
[ -n "$foo" ]
test -n "$foo"
Correct code:
[[ -n "$foo" ]]
Rationale:
[[
is generally safer and saner than [
/test
. See for example [
Is a
Builtin, But [[
Is Part of the
Language and What is the
difference between test
, [
and
[[
? for more.
SC+3 Remove unreferenced variable.
Update: This already exists as SC2034. I don’t know why I didn’t find it. In the best case I didn’t test with minimal code, and there was some other reason this error message didn’t show up. In the worst case I somehow managed to ignore the error message. I’m sorry either way.
Problematic code:
foo=bar
# No direct or indirect reference to `foo` in the rest of the file
Correct code:
Should not exist.
Rationale:
Unreferenced variable declarations are even worse in shell scripts than in most other languages:
- Shell scripts can be sourced, so it’s not enough to inspect a script to know whether any of its variables are actually used.
- Indirect variable references (
${!variable_name}
) do happen. If they are used anywhere after an assignment you have to do extra checks to make sure they are truly unreferenced. And if the indirect references somehow use user input to determine the variable name you’re out of luck.
set -o nounset
and a thorough set of tests can help a lot to check whether
removing a variable declaration breaks anything.
SC+4 Use arrays to construct commands with arguments.
Update: Filed issue.
Problematic code:
command='foo --bar'
command="${command} ${path}"
$command
Correct code:
command=(foo --bar)
command+=("$path")
"${command[@]}"
Rationale:
See I’m trying to put a command in a variable, but the complex cases always fail!.
Exceptions:
Passing arguments to SSH is even more complex:
ssh host "${remote_command[*]@Q}"
SC+5 Don’t put multiple commands on one line
Update: Filed issue.
Problematic code:
foo; bar
Correct code:
foo
bar
Rationale:
Consistently lining commands up vertically makes it easier to understand the context of each command. If two commands belong “strongly” together, why not put them in a function to make that grouping explicit?
SC+6 Use here-string rather than echo
+ a pipeline.
Update: Filed issue.
Problematic code:
echo value | cmd
Correct code:
cmd <<< value
Rationale:
This is analogous to the
useless cat
lint.
Exceptions:
If you’re a strong proponent of left-to-right flow of information you can also
use <<< value cmd
, but in my opinion that’s not worth the trade-off.
SC+7 Use the least powerful form of quotes and here docs applicable to the contents.
Update: Filed issue.
Problematic code:
cat << EOF
Some static text
EOF
echo $'More static text'
Correct code:
cat << 'EOF'
Some static text
EOF
echo 'More static text'
Rationale:
This is an example of the rule of least power. Using a less powerful language construct constrains the complexity of the contents of a string, lowering cognitive load.
This is analogous to Pylint’s f-string without interpolation warning.
SC+8 Use getopt
to parse options.
Update: Filed issue.
Problematic code:
Basically any kind of manual option parsing.
Correct code:
arguments="$(getopt --options='' \
--longoptions=configuration:,help,include:,verbose --name=foo -- "$@")"
eval set -- "$arguments"
unset arguments
while true
do
case "$1" in
--configuration)
configuration="$2"
shift 2
;;
--help)
usage
exit
;;
--include)
includes+=("$2")
shift 2
;;
--verbose)
verbose=1
shift
;;
--)
shift
break
;;
*)
printf 'Not implemented: %q\n' "$1" >&2
exit 1
;;
esac
done
getopt
features in the example:
- Supports both
--configuration=foo
and--configuration foo
styles. - Supports optional
--
option/argument separator. If this separator is not part of the original command it’s added after all recognised flags, so there’s no ambiguity about when to break off the loop. - Supports arbitrary option order.
- Supports arbitrary values. Newlines, spaces, backslashes, you name it. If you escape/quote them properly, they’ll be part of the value, with no extra hacks.
- Supports long options with minimal hacks (
--options=''
is unfortunately still necessary to specify that I definitely don’t want to support any short option names). - Prints a useful error message including the program name if parsing fails, which is useful when you’re deep in a call stack.
Other features:
- If
--configuration
is specified more than once, the last value is used. This is a common solution to allow a default set of options (for example in a configuration file) which can then be overridden by command-line options. It would be easy to change the example toexit
instead, if that’s what you want. - Accumulates
--include
values in an array, making them safe to reuse (see SC+4). - If we ever hit the default case then there’s a flag in the
getopt
call which is not yet handled by thecase
, which would be a programmer error.
Rationale:
It’s tempting to implement basic option handling yourself when there’s a single
option. But there are plenty of pitfalls, and I’d recommend using getopt
from
the start.
SC+9 Put continuation indicators at the start of the line
Update: Filed issue.
Problematic code:
grep … | \
sed … | \
cut … || \
echo …
Correct code:
grep … \
| sed … \
| cut … \
|| echo …
Rationale:
It’s easier to tell apart the various command separators if they are lined up
and separate from other distracting syntax like \
.