Skip to content
VLSI Mentor

Linux & Bash · Part I · Module 16 · Conditionals and Pattern Branches

Combining Conditions, and Negation

Real guards ask more than one question — the file must exist and be readable, the flag must be set or the default must apply, the lock must not be held. Bash gives you three different places to put the word and, and they behave differently enough that one of them should simply never be used.

Intermediate20 min readLinuxPart IBashConditionalsLogicScripting

Part I · Module 16 · Chapter 16.5 · Combining Conditions, and Negation

Chapter 16.4 kept writing things like [ -L "$f" ] && [ ! -e "$f" ] — two separate bracket tests joined on the outside, rather than one test with the operator inside. That was deliberate, and this chapter is why.

1. Three Places to Put an AND

All three of these are valid Bash, and all three ask the same question:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo data > f.txt

$ [ -f f.txt ] && [ -s f.txt ] && echo "a file with content"
a file with content

$ [ -f f.txt -a -s f.txt ] && echo "a file with content"
a file with content

$ [[ -f f.txt && -s f.txt ]] && echo "a file with content"
a file with content

The first joins two complete tests with Chapter 11.3's &&. The second uses -a, an operator inside [ ]. The third uses && inside [[ ]].

They are not equivalent, and the difference is not style.

FormPortableSafe for untrusted valuesReadable
[ A ] && [ B ]yesyesyes
[ A -a B ]deprecatednomoderately
[[ A && B ]]bash/ksh/zshyesyes

2. Why -a and -o Are Broken

Not "old-fashioned". Broken, for values you did not choose yourself.

Remember Chapter 16.2: [ receives a flat argument list and has to work out the structure from the words alone. It has no parser and no lookahead worth speaking of. And among its own operators are (, ) and ! — used for grouping and negation inside the brackets. So when one of your values happens to be one of those characters, [ cannot tell your data from its own syntax:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v="("
$ [ "$v" = "(" -o "$v" = ")" ] && echo "it is a parenthesis"
bash: [: `)' expected, found (

A test that any reviewer would pass, written correctly, with every variable quoted — and it is a hard error. [ saw the argument ( in a position where it expects a grouping parenthesis, decided a group had been opened, and went looking for the matching close.

Quoting cannot fix this. The quotes already did their job perfectly: [ received exactly the one-character argument (. The failure is downstream, in ['s own interpretation of an argument list that is genuinely ambiguous.

Here is the same failure on a different character, to show it is not a quirk of parentheses:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v="!"
$ [ "$v" = "!" ] && echo "bang"
bang

$ [ "$v" = "!" -o "$v" = "x" ] && echo "bang"
bash: [: too many arguments

The single test is fine. Add -o and a second clause, and the ! is suddenly read as negation, the argument count no longer matches any pattern [ knows, and it gives up with a different error again.

Sweep every awkward character through that test and exactly two of them break it — ( and !. A value of ) happens to survive this shape, because it lands in a position where [ can still resolve it. That is not reassurance; it is the worst possible property for a bug to have. Whether your data breaks the test depends on which clause the value lands in and how many clauses there are:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v="("
$ [ "$v" = "a" -o "$v" = "b" -o "$v" = "c" ]
bash: [: `)' expected, found a

A third clause, a different error message, the same root cause. You cannot test your way to confidence here, because the set of values that breaks it changes with the shape of the condition.

The separate-test form has neither problem:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v="("
$ [ "$v" = "(" ] || [ "$v" = ")" ] && echo "it is a parenthesis"
it is a parenthesis

Each [ now receives exactly three arguments and a closing bracket. There is nothing to misparse, because there is no compound structure for it to get wrong. The || is handled by the shell, outside the brackets, where it cannot be confused with data.

3. The Forms to Actually Use

Under a bash shebang, use [[ ]] with && and || inside it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ [[ -f f.txt && -r f.txt && -s f.txt ]] && echo "usable"
usable

One construct, one pair of brackets, and Bash is parsing — so (, ) and ! in a value are just characters:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v="("
$ [[ $v == "(" || $v == ")" ]] && echo "it is a parenthesis"
it is a parenthesis

[[ ]] also gives you real grouping with parentheses, which [ ] can only approximate:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ mkdir d
$ [[ ( -f f.txt || -d d ) && ! -f nope ]] && echo "matched"
matched

Read as: (a regular file or a directory) and not a missing thing. The parentheses here are grouping operators Bash recognises, because [[ put it into a context where that is what parentheses mean.

Under #!/bin/sh, join separate tests:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if [ -f "$log" ] && [ -r "$log" ] && [ -s "$log" ]; then

Slightly more punctuation, completely safe, runs anywhere. And when a compound condition gets long, this form is the one that stays readable — because you can break it across lines:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if [ -f "$log" ] &&
   [ -r "$log" ] &&
   [ -s "$log" ]
then
  summarise "$log"
fi

A line ending in && or || continues automatically — Chapter 11.3's rule, and the reason you almost never need a backslash to continue a condition.

4. Negation

! negates, and it has three valid positions:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ! false && echo "a failing command, negated"
a failing command, negated

$ [ ! -f nope ] && echo "inside the brackets"
inside the brackets

$ ! [ -f nope ] && echo "outside the brackets"
outside the brackets

The first is the shell's !, which inverts any command's status — worth remembering, because it works on things that are not tests at all:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ if ! grep -q "ERROR" /etc/hosts; then echo "clean"; fi
clean

That reads as a sentence, and Chapter 16.7 is built on it.

Inside the brackets, ! is ['s own negation operator, and [ ! -f x ] is the form you will see most often. Outside, ! [ -f x ] is the shell's. They give the same answer here — but not always, and the difference is the one you would expect given everything in Chapter 16.3:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ [ ! abc -eq 1 ]
bash: [: abc: integer expression expected
$ echo $?
2
$ ! [ abc -eq 1 ]
bash: [: abc: integer expression expected
$ echo $?
0

The shell's ! inverted the error status 2 into 0, reporting success for a test that did not run. ['s own ! left the error as an error. When a test can fail rather than merely answer no, [ ! ... ] is the honest form.

Prefer negation on the smallest piece. This is readable:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if [ ! -s "$log" ]; then
  echo "empty log" >&2
fi

This is not:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if ! [ -f "$log" ] || ! [ -r "$log" ] || ! [ -s "$log" ]; then
  echo "unusable log" >&2
fi

Three negations and a chain of ORs is De Morgan's law written out by hand. The positive form plus one outer negation says the same thing and can be read aloud:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if ! [[ -f "$log" && -r "$log" && -s "$log" ]]; then
  echo "unusable log" >&2
fi

5. Precedence, Measured

The rule people assume — that && binds tighter than ||, as in C — is not the rule in Bash.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ true || echo A && echo B
B

$ false || echo A && echo B
A
B

If && bound tighter, the first line would group as true || (echo A && echo B) and print nothing at all, because true would short-circuit the whole right side. It printed B.

&& and || have equal precedence and associate left to right. So the first line is (true || echo A) && echo B: true succeeds, || echo A is skipped, the group's status is 0, and && echo B runs.

The practical rule follows immediately: when you mix && and ||, group explicitly. Either with braces, or by using if:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
true || { echo A && echo B; }      # now it means what it looks like

6. The Ternary Trap

The most-used compound idiom in shell is also the most misunderstood:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ [ -f f.txt ] && echo "yes" || echo "no"
yes
$ [ -f nope ] && echo "yes" || echo "no"
no

It looks like a ternary. It is not, and here is the failure:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { echo "body ran"; return 1; }
$ true && f || echo "else ran"
body ran
else ran

Both branches ran. || does not test the condition — it tests whatever ran immediately before it, which was f. f printed its message and returned 1, so || fired.

The A && B || C idiom is only safe when B cannot fail. echo effectively cannot, which is why the idiom is fine for the one-line pass/fail reporting used throughout this module. Anything heavier than an echo in the B slot — a cp, a function, a pipeline, a command that might hit a full disk — and you need the real thing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if true; then
  f
else
  echo "else ran"
fi

Which runs f once and prints nothing else, because if remembers which condition it tested.

7. A Complete Script

A lock-and-run wrapper, where every guard needs more than one question answered:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# run-regress.sh — run a regression, refusing to start if anything is off
# usage: run-regress.sh <testlist> <outdir>
#
# exit: 0 ran · 2 bad arguments · 3 already running · 4 environment not ready

set -o nounset

testlist=${1:-}
outdir=${2:-}

# both arguments, or neither is usable
if [[ -z $testlist || -z $outdir ]]; then
  echo "usage: ${0##*/} <testlist> <outdir>" >&2
  exit 2
fi

# the testlist must be a readable, non-empty regular file
if ! [[ -f $testlist && -r $testlist && -s $testlist ]]; then
  echo "${0##*/}: $testlist is not a readable, non-empty file" >&2
  exit 2
fi

# the outdir must either be a writable directory, or creatable
if [[ -e $outdir ]] && ! [[ -d $outdir && -w $outdir ]]; then
  echo "${0##*/}: $outdir exists but is not a writable directory" >&2
  exit 2
fi
mkdir -p "$outdir" || exit 2

# a stale lock from a crashed run is not the same as a live one
lock="$outdir/.running"
if [[ -f $lock ]]; then
  owner=$(cat "$lock")
  if kill -0 "$owner" 2>/dev/null; then
    echo "${0##*/}: already running as pid $owner" >&2
    exit 3
  fi
  echo "${0##*/}: clearing stale lock from pid $owner" >&2
  rm -f "$lock"
fi

# the simulator must be on PATH and actually executable
if ! command -v vcs > /dev/null 2>&1; then
  echo "${0##*/}: vcs is not on PATH" >&2
  exit 4
fi

echo $$ > "$lock"
echo "running $(wc -l < "$testlist" | tr -d ' ') tests into $outdir"
# ... the real work ...
rm -f "$lock"

Five guards, and each one is compound because the single-question version would be wrong:

  • -z A || -z B — either argument missing is the same failure, so one message covers both.
  • ! [[ -f && -r && -s ]] — the positive condition is stated plainly and negated once, rather than three separate negations joined by ORs.
  • [[ -e ]] && ! [[ -d && -w ]] — this is the interesting one. It complains only when $outdir exists and is not a usable directory. A non-existent $outdir falls through to mkdir -p, which is the desired behaviour, and a single [[ ! -d $outdir ]] would have rejected it.
  • [[ -f $lock ]] then kill -0 — Chapter 7.6's signal zero, which tests whether a process exists without signalling it. A lock file whose owner is gone is a crash artefact, not a running job, and the two deserve different outcomes.
  • ! command -v — Chapter 10.2's lookup, negated. Chapter 16.7 covers this form properly.

Note that the [[ ]] tests drop the quotes on $testlist and $outdir, which Chapter 16.2 licensed. The cat "$lock", wc -l < "$testlist" and mkdir -p "$outdir" outside the brackets keep them, because those are ordinary commands and the exemption does not reach them.

Practice

  1. Set v="(" and run [ "$v" = "(" -o "$v" = ")" ]. Then rewrite it three ways — two separate tests, one [[ ]], and a case if you have read ahead — and confirm all three work.
  2. Sweep it. Loop a list of awkward single-character values — (, ), !, ], =, -o, -n, a backslash, an empty string — through [ "$v" = "(" -o "$v" = ")" ] and print only the ones that produce output on stderr. Exactly two break. Then move the variable into the second clause and sweep again, and explain why the set of breakers changed.
  3. Predict then run all four: true || echo A && echo B, false || echo A && echo B, true && echo A || echo B, false && echo A || echo B. Explain the first using left-to-right equal precedence.
  4. Write f() { echo body; return 1; } and run true && f || echo else. Explain why both lines print, then fix it with if.
  5. Use the shout function from Section 6 to prove that [ -n "" ] && shout never calls it. Why does that make [ -n "$f" ] && [ -f "$f" ] safe?
  6. Compare [ ! abc -eq 1 ]; echo $? with ! [ abc -eq 1 ]; echo $?. Which hides a real error, and which would you want in a script?
  7. Take the three-negation example in Section 4 and convert it to one negated positive condition by hand. Verify they agree on a missing file, an empty file and a good file.
  8. In run-regress.sh, replace the [[ -e $outdir ]] && ! [[ -d $outdir && -w $outdir ]] guard with [[ ! -d $outdir ]]. Find the input that now behaves wrongly.

Check Your Understanding

  1. Why does [ "$v" = "(" -o "$v" = ")" ] fail when v is a parenthesis, given that the variable is quoted?
  2. Can quoting fix that? Why not?
  3. What is the portable way to write a compound condition?
  4. In Bash, does && bind tighter than ||?
  5. true && f || echo else prints both when f fails. Why?
  6. When is A && B || C safe to use as a ternary?
  7. What is the difference between [ ! -f x ] and ! [ -f x ]?

8. Remember

  • There are three places to put a logical operator, and only two are safe. Between tests, or inside [[ ]].
  • -a and -o are broken, not merely dated. A value of ( or ! makes [ misparse its own argument list, and quoting cannot help. POSIX deprecates them.
  • [ A ] && [ B ] is the portable form, and it is safe because each [ gets an unambiguous argument list.
  • [[ A && B ]] is the Bash form, with real parenthesised grouping and no quoting requirement.
  • A line ending in && or || continues, so a long condition can be broken across lines without backslashes.
  • && and || have equal precedence, left to right. Group explicitly whenever you mix them.
  • A && B || C is not a ternary. || tests B's status, so a failing B runs C as well. Safe only when B cannot fail.
  • Negate the smallest thing, or negate one positive condition — never a chain of individually-negated clauses.
  • [ ! x ] preserves an error; ! [ x ] inverts it into success.
  • Short-circuiting is specified, not an optimisation. [ -n "$f" ] && [ -f "$f" ] genuinely never runs the second test on an empty name.

Chapter 16.6 takes the case this chapter handles worst. When one value is being compared against six possibilities, no amount of || makes it readable — and Bash has a construct built for exactly that, reusing the glob patterns you already know from Chapter 3.5.

Where this fits

Part of the Linux & Bash curriculum.