12

I am writing a Bash script and need to check to see if a file exists that looks like *.$1.*.ext I can do this really easily with POSIX test as [ -f *.$1.*.ext ] returns true, but using the double bracket [[ -f *.$1.*.ext ]] fails.

This is just to satisfy curiosity as I can't believe the extended testing just can't pick out whether the file exists. I know that I can use [[ `ls *.$1.*.ext` ]] but that will match if there's more than one match. I could probably pipe it to wc or something but that seems clunky.

Is there a simple way to use double brackets to check for the existence of a file using wildcards?

EDIT: I see that [[ -f `ls -U *.$1.*.ext` ]] works, but I'd still prefer to not have to call ls.

William Everett
  • 751
  • 1
  • 8
  • 18

2 Answers2

16

Neither [ -f ... ] nor [[ -f ... ]] (nor other file-test operators) are designed to work with patterns (a.k.a. globs, wildcard expressions) - they always interpret their operand as a literal filename.[1]

A simple trick to test if a pattern (glob) matches exactly one file is to use a helper function:

existsExactlyOne() { [[ $# -eq 1 && -f $1 ]]; }

if existsExactlyOne *."$1".*.ext; then # ....

If you're just interested in whether there are any matches - i.e., one or more - the function is even simpler:

exists() { [[ -f $1 ]]; }

If you want to avoid a function, it gets trickier:

Caveat: This solution does not distinguish between regular files directories, for instance (though that could be fixed.)

if [[ $(shopt -s nullglob; set -- *."$1".*.ext; echo $#) -eq 1 ]]; then # ...
  • The code inside the command substitution ($(...)) does the following:
    • shopt -s nullglob instructs bash to expand the pattern to an empty string, if there are no matches
    • set -- ... assigns the results of the pattern expansion to the positional parameters ($1, $2, ...) of the subshell in which the command substitution runs.
    • echo $# simply echoes the count of positional parameters, which then corresponds to the count of matching files;
  • That echoed number (the command substitution's stdout output) becomes the left-hand side to the -eq operator, which (numerically) compares it to 1.

Again, if you're just interested in whether there are any matches - i.e., one or more - simply replace -eq with -ge.


[1]
As @Etan Reisinger points out in a comment, in the case of the [ ... ] (single-bracket syntax), the shell expands the pattern before the -f operator even sees it (normal command-line parsing rules apply).

By contrast, different rules apply to bash's [[ ... ]], which is parsed differently, and in this case simply treats the pattern as a literal (i.e., doesn't expand it).

Either way, it won't work (robustly and predictably) with patterns:

  • With [[ ... ]] it never works: the pattern is always seen as a literal by the file-test operator.
  • With [ ... ] it only works properly if there happens to be exactly ONE match.
    • If there's NO match:
      • The file-test operator sees the pattern as a literal, if nullglob is OFF (the default), or, if nullglob is ON, the conditional always returns true, because it is reduced to -f, which, due to the missing operand, is no longer interpreted as a file test, but as a nonempty string (and a nonempty string evaluates to true)).
    • If there are MULTIPLE matches: the [ ... ] command breaks as a whole, because the pattern then expands to multiple words, whereas file-test operators only take one argument.
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • This fails the "will match if there's more than one match" part of the question but the statement about `[ -f` and `[[ -f` is a good one (if somewhat hand-wave-y in that neither of them actually sees a glob when testing). – Etan Reisner Jul 07 '14 at 16:48
  • @EtanReisner: Good point re multiple matches - didn't read carefully; will edit. – mklement0 Jul 07 '14 at 16:49
  • Are you putting quotes around the $1 in case of spaces? – William Everett Jul 07 '14 at 16:56
  • Looking at that first sentence again it isn't quite correct. Only `[[` sees a literal glob. `[` sees an expanded list of filenames. (Compare the output from `set -x` on both of them.) – Etan Reisner Jul 07 '14 at 16:59
  • @WilliamEverett: Not needed with `[[ ... ]]` syntax (try it). It's one of the perks of using `[[ ... ]]`, but there's certainly no harm in double-quoting. – mklement0 Jul 07 '14 at 16:59
  • I would expect that since you're passing $1 as a parameter to existsOne, $# wouldn't equal 1 if you didn't use quotes and there were spaces. – William Everett Jul 07 '14 at 17:02
  • 2
    @WilliamEverett: `*."$1".*.ext` is a _glob_ that potentially expands to _multiple_ arguments - properly partitioned into individual filenames, even with embedded whitespace (thanks to double-quoting the `"$1"` part). So, if there's only 1 match, `$#` will be 1 inside the function - whether that 1 match has embedded whitespace or not. – mklement0 Jul 07 '14 at 17:07
  • Thanks for this idea. I like the idea of using the helper function and see that you can check for negatives with `if !(existsOne *."$1".*.ext)` and stick multiple conditionals together. – William Everett Jul 07 '14 at 17:15
  • @EtanReisner: Thanks for the follow-up. I've incorporated your findings into an update. – mklement0 Jul 07 '14 at 17:22
  • 1
    @WilliamEverett: You're welcome - good point about negation; no need for parentheses in your simple example, though: `if ! existsOne *."$1".*.ext` works fine - no need to create a _subshell_ with parentheses unless needed for the logic. – mklement0 Jul 07 '14 at 17:56
4

as your question is bash tagged, you can take advantage of bash specific facilities, such as an array:

file=(*.ext)
[[ -f "$file" ]] && echo "yes, ${#file[@]} matching files"

this first populates an array with one item for each matching file name, then tests the first item only: Referring to the array by name without specifying an index addresses its first element. As this represents only one single file, -f behaves nicely.

An added bonus is that the number of populated array items corresponds with the number of matching files, should you need the file count, and can thereby be determined easily, as shown in the echoed output above. You may find it an advantage that no extra function needs to be defined.

Deleted User
  • 2,551
  • 1
  • 11
  • 18
  • 1
    +1; this is handy if you want to _collect_ the actual matches. If all you care about is whether there _is_ (at least one) match, you can stuff it all into a single conditional: `[[ -n $(shopt -s nullglob; echo *.ext) ]] && echo "matches"`. I'm still not entirely clear on whether the OP wants to determine whether there is _exactly one_ match, but just in case: `[[ ${#file[@]} -eq 1 && -f "$file" ]]` – mklement0 Jul 08 '14 at 00:27
  • 1
    If you have multiple matches, but the first one is a broken symlink, this will claim that no matches exist. – Charles Duffy Jul 16 '18 at 20:11