2

How to exclude one or multiple filename suffixes when searching using glob qualifiers?

Works fine, here we match the filename suffix, not exclude them:

print -rC1 **/*.(txt)(.)
print -rC1 **/*.(jpg|png)(.)

And not we are trying to exclude them, doesn't work:

print -rC1 **/*.(^txt)(.)
print -rC1 **/*.(^jpg|^png)(.)

zsh 5.9 (x86_64-apple-darwin23.0)

2
  • Have you enabled the EXTENDED_GLOB shell option? What's the output of setopt?
    – Kusalananda
    Commented May 18 at 9:20
  • @Kusalananda combiningchars interactive login monitor shinstdin zle (per line)
    – jsx97
    Commented May 18 at 9:24

2 Answers 2

4

Using the ^x pattern requires the EXTENDED_GLOB shell option to be set in the zsh shell:

setopt EXTENDED_GLOB

After that, you should be able to look for any regular non-hidden file whose filename suffix is neither .foo nor .bar using the pattern

./**/^*.(foo|bar)(.)

Note that *.(^foo|^bar) is very different from ^*.(foo|bar). The former means "any filename that contains a . that is followed by either something that's not foo or something that's not bar" (essentially all files with at least one dot as nothing can be both foo and bar at the same time), while the latter means "no filename with either .foo or .bar at the end".

If you don't include the ./ prefix, note that it's critical that you don't forget the -- or - option delimited for print as otherwise that makes it an arbitrary command execution vulnerability.

So in your case:

print -rC1 -- **/^*.(jpg|png)(.)
1
3

Glob qualifiers aren't actually the problem here. In fact, they make some of the examples work. There are three separate issues here: how glob qualifiers are parsed, the syntax of glob operators, and options controlling those.

$ zsh -f  # Start with default options
darkstar% ls
foo.bar foo.jpg foo.png foo.txt
darkstar% echo *.(txt)(.)
foo.txt
darkstar% echo *.(jpg|png)(.)
foo.jpg foo.png

Parentheses are always accepted for grouping in globs, although there are several circumstances when they're parsed as something else before they reach the stage where they're part of a glob (arithmetic expressions, glob qualifiers which I'll expand on later, function definitions, etc.). In *.(txt)(.), the last parenthesized chunk (.) is parsed as glob qualifiers, and (txt) is a pattern that matches only the exact text txt.

The pattern *.(jpg|png)(.) uses the glob operator | which means “or”. The part (jpg|png) matches both jpg and png.

darkstar% echo *.(^txt)(.)
zsh: no matches found: *.(^txt)(.)
darkstar% setopt extended_glob
darkstar% echo *.(^txt)(.)
foo.bar foo.jpg foo.png

By default, ^txt matches only itself. The glob operator ^ is only recognized when the option extended_glob is set. This option is always set in completion functions and in zmv, but it's off by default. The only reason it's off by default is for strict backward compatibility (it didn't exist originally, but it's been around for more than 30 years). There are very few practical cases where it makes a difference (mostly if you want to pass a #, ^ or ~ in a command argument without quoting it), and even when it does, a backslash or any other quote fixes that. I recommend to put setopt extended_glob in your .zshrc. With this option turned on, ^txt matches anything except txt.

darkstar% setopt extended_glob
darkstar% echo *.(^jpg|^png)(.)
foo.bar foo.jpg foo.png foo.txt
darkstar% echo *.^(jpg|png)(.)
foo.bar foo.jpg foo.png foo.txt

The pattern (^jpg|^png) matches anything that is not jpg or that is not png. Any string is not-jpg or not-png, so (^jpg|^png) matches everything. To match anything that is neither jpg nor png, use ^(jpg|png). If there's more after the part that you want to negate, you'll need two laters of parentheses, e.g. *.(^(jpg|png)).gz to match foo.bar.gz and foo.txt.gz but not foo.jpg.gz or foo.png.gz.

Now let's come back to how glob qualifiers are parsed. By default, a parenthesized group at the end is parsed as glob qualifiers, except when it contains | or nested parentheses or ~ (when extended_glob is enabled).

darkstar% echo *.txt
foo.txt
darkstar% echo *.(txt)
zsh: no matches found: *.(txt)
darkstar% echo *.((txt))
foo.txt
darkstar% echo *.(txt)(.)
foo.txt
darkstar% echo *.(png|jpg)
foo.jpg foo.png

In *.(txt), (txt) is parsed as glob qualifiers (so *.(txt) matches files whose name ends with a dot and that are executable and have the sticky bit set). Using double parentheses avoids the glob qualifier parsing. *.(png|jpg) is not interpreted as using glob qualifiers because of the |. *.(txt)(.) already has (.) interpreted as glob qualifiers so the (txt) part can't be glob qualifiers.

You can disable glob qualifier parsing by unsetting bare_glob_qual. I don't recommend it, because ambiguous patterns are rare and it requires extra typing when you do want glob qualifiers. Note that

darkstar% setopt no_bare_glob_qual
darkstar% setopt no_extended_glob
darkstar% echo *.(txt)
foo.txt
darkstar% echo *.(txt)(.)
zsh: no matches found: *.(txt)(.)
darkstar% echo *.txt(#q.)
zsh: no matches found: *.txt(#q.)
darkstar% setopt extended_glob
darkstar% echo *.txt(#q.)
foo.txt
3
  • Same comment as on @Kusalananda's initial answer, *.(^(jpg|png)).gz matches on foo.bar.jpg.gz because bar.jpg is neither jpg nor png. You'd need (^*.(jpg|png)).gz. IIRC there are a few Q&As that cover that common gotcha (or maybe for ksh's !(...) equivalent). Commented May 18 at 9:56
  • @StéphaneChazelas Yes, the trickiness of negation in regexps is a separate matter that I didn't want to get into. This answer is already long as it is. Commented May 18 at 10:02
  • Still showing that *.(^(jpg|png)).gz is misleading as it would suggest it's the pattern for compressed files other than .jpg or .png ones which it's not. Commented May 18 at 10:22

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .