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.

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

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.

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.

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

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.

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.

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.

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 to exit 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 the case, 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

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 \.