Skip to content

An unquoted [:lower:] is a glob

Level: 101 · you copied tr [:lower:] [:upper:] from a book, and it worked

One line: Without quotes, bash and zsh read [:lower:] as a filename pattern that matches any one-character name among :, l, o, w, e and r. So tr [:lower:] [:upper:] works in a directory with no such file. After touch e it does nothing, because the shell runs tr e e. In zsh it does not run at all when nothing matches. fish does not treat square brackets as a wildcard, so there it always works. Quote the class.

Measured

In bash, first in a fresh empty directory and then in the same directory with one file named e:

Verified output of tr_unquoted_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ ls

$ echo [:lower:] [:upper:]
[:lower:] [:upper:]

$ echo hello | tr [:lower:] [:upper:]
HELLO

$ shopt -s failglob; echo hello | tr [:lower:] [:upper:]; echo "status $?"; shopt -u failglob
tr_unquoted_sh.sh: line 7: no match: [:lower:]
status 1

$ touch e

$ echo [:lower:] [:upper:]
e e

$ echo hello | tr [:lower:] [:upper:]; echo "status $?"
hello
status 0

$ echo hello | tr '[:lower:]' '[:upper:]'
HELLO
  • With nothing to match, bash leaves the word alone. echo [:lower:] [:upper:] prints both words as typed, and tr gets the classes it expected. That is the book's situation: a home directory with no one-letter file names in it.
  • [...] is a bracket expression, and this one is a set of characters. [:lower:] is :, l, o, w, e, r, and [:upper:] is :, u, p, e, r. A file named e matches both, and echo shows each word replaced by e.
  • So tr ran as tr e e, which translates e into e. hello came out still lowercase, with no message and status 0. Nothing points at quoting; it looks as if tr ignored you.
  • shopt -s failglob makes a pattern with no match an error, so in the empty directory the command fails with a message and does not just happen to work. The line 7 is the eval inside the script's say helper. failglob cannot catch the touch e case, because there the pattern did match.
  • The fix is quotes. '[:lower:]' is not a pattern in any directory, in any of the three shells.

On a Mac

Nothing changes: the key above is shared. /bin/bash 3.2 matches the pattern, passes an unmatched word through and supports failglob just as bash 5.2 does. This chapter's Mac difference is inside tr itself, on the previous page. A Mac user is probably not typing into bash anyway, which is the next section.

In zsh and fish

zsh is the Mac's default shell, and it treats a pattern with no match as an error:

Verified output of tr_unquoted_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ echo hello | tr [:lower:] [:upper:]; echo "status $?"
(eval):1: no matches found: [:lower:]
status 1

$ setopt no_nomatch; echo hello | tr [:lower:] [:upper:]; unsetopt no_nomatch
HELLO

$ touch e

$ echo [:lower:] [:upper:]
e e

$ echo hello | tr [:lower:] [:upper:]; echo "status $?"
hello
status 0

$ echo hello | tr '[:lower:]' '[:upper:]'
HELLO
  • no matches found, status 1, and tr never started. zsh's NOMATCH option is on by default. So the book's command, typed into a new Terminal window in a directory with no matching file, fails. bash behaves this way only with failglob.
  • setopt no_nomatch gives back bash's pass-through. Use quotes instead, because the option changes every pattern in the shell.
  • After touch e, zsh does exactly what bash does: it runs tr e e, the output stays lowercase, and the status is 0.

fish has no bracket wildcard. Its wildcards are * and **, and [ is an ordinary character:

Verified output of tr_unquoted_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ echo hello | tr [:lower:] [:upper:]
HELLO

$ touch e

$ echo [:lower:] [:upper:]
[:lower:] [:upper:]

$ echo hello | tr [:lower:] [:upper:]
HELLO

$ echo hello | tr '[:lower:]' '[:upper:]'
HELLO

The unquoted command works in both directories, and echo shows the words arriving as typed. Quoting still costs nothing in fish, and the quoted line works in the other two shells as well.

bash zsh fish
no file matches tr gets [:lower:] and works error; tr does not run tr gets [:lower:] and works
a file e exists runs tr e e: no change, status 0 runs tr e e: no change, status 0 tr gets [:lower:] and works
make no match an error shopt -s failglob the default (NOMATCH) not a pattern
always right tr '[:lower:]' '[:upper:]' the same the same

If you are coming from another library

  • Encodings. The shell has no string type ↗: a shell word is only bytes, and the shell rewrites it before any program sees it. Pattern expansion, as on this page, is one of those rewrites.
  • Perl. The one-liners in -n and -p are a loop ↗ single-quote the program for the same reason: $, * and [ belong to the shell before they reach perl.

See also