527

I've got this script in sh (macOS 10.6) to look through an array of files:

files="*.jpg"
for f in $files
    do
        echo $f | grep -oEi '[0-9]+_([a-z]+)_[0-9a-z]*'
        name=$?
        echo $name
    done

So far $name merely holds 0, 1 or 2, depending on if grep found that the filename matched the matter provided. What I'd like is to capture what's inside the parens ([a-z]+) and store that to a variable.

I'd like to use grep only, if possible. If not, please no Python or Perl, etc. sed or something like it – I would like to attack this from the *nix purist angle.

4
  • 7
    Ah, didn't mean to suggest that. I was just hoping that a solution could be found using a tool I'm specifically trying to learn here. If it's not possible to solve using grep, then sed would be great, if it's possible to solve using sed.
    – Isaac
    Commented Dec 12, 2009 at 1:09
  • 6
    I should have put a :) on that btw ... Commented Dec 12, 2009 at 1:31
  • Psh, my brain is way too fried today haha.
    – Isaac
    Commented Dec 12, 2009 at 1:34
  • 3
    @martinclayton That'd be an interesting argument. I do really think sed, (or ed to be precise) would be older (and therefore purer? maybe?) unix because grep derives it's name from the ed expression g(lobal)/re(gular expression)/p(rint).
    – ffledgling
    Commented Mar 5, 2013 at 15:18

10 Answers 10

693

If you're using Bash, you don't even have to use grep:

files="*.jpg"
regex="[0-9]+_([a-z]+)_[0-9a-z]*" # put the regex in a variable because some patterns won't work if included literally
for f in $files    # unquoted in order to allow the glob to expand
do
    if [[ $f =~ $regex ]]
    then
        name="${BASH_REMATCH[1]}"
        echo "${name}.jpg"    # concatenate strings
        name="${name}.jpg"    # same thing stored in a variable
    else
        echo "$f doesn't match" >&2 # this could get noisy if there are a lot of non-matching files
    fi
done

It's better to put the regex in a variable. Some patterns won't work if included literally.

This uses =~ which is Bash's regex match operator. The results of the match are saved to an array called $BASH_REMATCH. The first capture group is stored in index 1, the second (if any) in index 2, etc. Index zero is the full match.




side note #1 regarding regex anchors:

You should be aware that without anchors, this regex (and the one using grep) will match any of the following examples and more, which may not be what you're looking for:

123_abc_d4e5
xyz123_abc_d4e5
123_abc_d4e5.xyz
xyz123_abc_d4e5.xyz

To eliminate the second and fourth examples, make your regex like this:

^[0-9]+_([a-z]+)_[0-9a-z]*

which says the string must start with one or more digits. The carat represents the beginning of the string. If you add a dollar sign at the end of the regex, like this:

^[0-9]+_([a-z]+)_[0-9a-z]*$

then the third example will also be eliminated since the dot is not among the characters in the regex and the dollar sign represents the end of the string. Note that the fourth example fails this match as well.

side note #2 regarding grep and the \K operator:

If you have GNU grep (around 2.5 or later, I think, when the \K operator was added):

name=$(echo "$f" | grep -Po '(?i)[0-9]+_\K[a-z]+(?=_[0-9a-z]*)').jpg

The \K operator (variable-length look-behind) causes the preceding pattern to match, but doesn't include the match in the result. The fixed-length equivalent is (?<=) - the pattern would be included before the closing parenthesis. You must use \K if quantifiers may match strings of different lengths (e.g. +, *, {2,4}).

The (?=) operator matches fixed or variable-length patterns and is called "look-ahead". It also does not include the matched string in the result.

In order to make the match case-insensitive, the (?i) operator is used. It affects the patterns that follow it so its position is significant.

The regex might need to be adjusted depending on whether there are other characters in the filename. You'll note that in this case, I show an example of concatenating a string at the same time that the substring is captured.

22
  • 80
    In this answer I want to upvote the specific line that says "It's better to put the regex in a variable. Some patterns won't work if included literally."
    – Brandin
    Commented Jan 9, 2014 at 12:41
  • 2
    "It's better to put the regex in a variable. Some patterns won't work if included literally." - Why does it happens? Is there a way fix them? Commented Oct 12, 2014 at 5:47
  • 7
    @FrancescoFrassinelli: An example is a pattern that includes white space. It's awkward to escape and you can't use quotes since that forces it from a regex to an ordinary string. The correct way to do it is to use a variable. Quotes can be used during the assignment making things much simpler. Commented Oct 12, 2014 at 8:03
  • 3
    @Brandon: It does work. What version of Bash are you using? Show me what you're doing that doesn't work and perhaps I can tell you why. Commented Mar 14, 2016 at 20:12
  • 4
    @mdelolmo: My answer includes information about grep. It was also accepted by the OP and upvoted quite a lot. Thanks for the downvote. Commented Oct 26, 2017 at 16:51
194

This isn't really possible with pure grep, at least not generally.

But if your pattern is suitable, you may be able to use grep multiple times within a pipeline to first reduce your line to a known format, and then to extract just the bit you want. (Although tools like cut and sed are far better at this).

Suppose for the sake of argument that your pattern was a bit simpler: [0-9]+_([a-z]+)_ You could extract this like so:

echo $name | grep -Ei '[0-9]+_[a-z]+_' | grep -oEi '[a-z]+'

The first grep would remove any lines that didn't match your overall patern, the second grep (which has --only-matching specified) would display the alpha portion of the name. This only works because the pattern is suitable: "alpha portion" is specific enough to pull out what you want.

(Aside: Personally I'd use grep + cut to achieve what you are after: echo $name | grep {pattern} | cut -d _ -f 2. This gets cut to parse the line into fields by splitting on the delimiter _, and returns just field 2 (field numbers start at 1)).

Unix philosophy is to have tools which do one thing, and do it well, and combine them to achieve non-trivial tasks, so I'd argue that grep + sed etc is a more Unixy way of doing things :-)

5
  • 4
    for f in $files; do name=echo $f | grep -oEi '[0-9]+_([a-z]+)_[0-9a-z]*'| cut -d _ -f 2; Aha!
    – Isaac
    Commented Dec 12, 2009 at 1:43
  • 4
    i disagree with that "philosophy". if you can use the shell's in built capabilities without calling external commands, then your script will be a lot faster in performance. there are some tools that overlap in function. eg grep and sed and awk. all of them does string manipulations, but awk stands out above them all because it can do a lot more. Practically, all those chaining of commands, like the above double greps or grep+sed can be shortened by doing them with one awk process.
    – ghostdog74
    Commented Dec 12, 2009 at 4:43
  • 10
    @ghostdog74: No argument here that chaining lots of tiny operations together is generally less efficient than doing it all in one place, but I stand by my assertion that the Unix philosophy is lots of tools working together. For instance, tar just archives files, it doesn't compress them, and because it outputs to STDOUT by default you can pipe it across the network with netcat, or compress it with bzip2, etc. Which to my mind reinforces the convention and general ethos that Unix tools should be able to work together in pipes.
    – RobM
    Commented Dec 13, 2009 at 14:26
  • cut is awesome -- thanks for the tip! As for the tools vs efficiency argument, I like the simplicity of chaining tools.
    – ether_joe
    Commented Oct 28, 2014 at 23:00
  • props for grep's o option, that is very helpful
    – chiliNUT
    Commented Jan 22, 2017 at 5:02
127

I realize that an answer was already accepted for this, but from a "strictly *nix purist angle" it seems like the right tool for the job is pcregrep, which doesn't seem to have been mentioned yet. Try changing the lines:

    echo $f | grep -oEi '[0-9]+_([a-z]+)_[0-9a-z]*'
    name=$?

to the following:

    name=$(echo $f | pcregrep -o1 -Ei '[0-9]+_([a-z]+)_[0-9a-z]*')

to get only the contents of the capturing group 1.

The pcregrep tool utilizes all of the same syntax you've already used with grep, but implements the functionality that you need.

The parameter -o works just like the grep version if it is bare, but it also accepts a numeric parameter in pcregrep, which indicates which capturing group you want to show.

With this solution there is a bare minimum of change required in the script. You simply replace one modular utility with another and tweak the parameters.

Interesting Note: You can use multiple -o arguments to return multiple capture groups in the order in which they appear on the line.

9
  • 6
    pcregrep is not available by default in Mac OS X which is what the OP uses
    – grebneke
    Commented Jan 1, 2014 at 2:06
  • 5
    My pcregrep doesn't seem to understand the digit after the -o: "Unknown option letter '1' in "-o1". Also no mention of that functionaliy when looking at pcregrep --help Commented Mar 25, 2015 at 9:10
  • 2
    @WAF sorry, guess I should have included that info in my comment. I'm on Centos 6.5 and the pcregrep version is apparently very old: 7.8 2008-09-05. Commented Jul 31, 2015 at 8:14
  • 3
    yeah, very help, e.g. echo 'r123456 foo 2016-03-17' | pcregrep -o1 'r([0-9]+)' 123456
    – zhuguowei
    Commented Mar 17, 2016 at 13:18
  • 7
    pcregrep 8.41 (installed with apt-get install pcregrep on Ubuntu 16.03) doesn't recognize the -Ei switch. It works perfectly without it, though. On macOS, with pcregrep installed via homebrew (also 8.41) as @anishpatel mentions above, at least on High Sierra the -E switch is also not recognized.
    – Ville
    Commented Feb 11, 2018 at 22:56
44

Not possible in just grep I believe

for sed:

name=`echo $f | sed -E 's/([0-9]+_([a-z]+)_[0-9a-z]*)|.*/\2/'`

I'll take a stab at the bonus though:

echo "$name.jpg"
6
  • 5
    Unfortunately, that sed solution doesn't work. It simply prints out everything in my directory.
    – Isaac
    Commented Dec 12, 2009 at 1:14
  • updated, will output a blank line if there isn't a match, so be sure to check for that
    – cobbal
    Commented Dec 12, 2009 at 1:19
  • It now outputs only blank lines!
    – Isaac
    Commented Dec 12, 2009 at 1:24
  • this sed has a problem. The first group of capturing parenthesis encompass everything. Of course \2 will have nothing.
    – ghostdog74
    Commented Dec 12, 2009 at 4:36
  • it worked for some simple test cases... \2 gets the inner group
    – cobbal
    Commented Dec 12, 2009 at 6:01
26
str="1w 2d 1h"
regex="([0-9])w ([0-9])d ([0-9])h"
if [[ $str =~ $regex ]]
then
    week="${BASH_REMATCH[1]}"
    day="${BASH_REMATCH[2]}"
    hour="${BASH_REMATCH[3]}"
    echo $week --- $day ---- $hour
fi

output: 1 --- 2 ---- 1

21

This is a solution that uses gawk. It's something I find I need to use often so I created a function for it

function regex1 { gawk 'match($0,/'$1'/, ary) {print ary['${2:-'1'}']}'; }

to use just do

$ echo 'hello world' | regex1 'hello\s(.*)'
world
2
  • Great idea, but does not seem to work with spaces in the regexp - they need to be replaced with \s. Do you know how to fix it? Commented Feb 16, 2019 at 9:10
  • @opsb What is the purpose of - ary['${2:-'1'}']}'? Couldn't we have done the same thing with - function regex1 { gawk 'match($0,/'$1'/, ary) {print ary[1]}'; } to match the 1st occurrence? Edit: Never mind! got the intention.
    – Anis
    Commented Jan 4, 2023 at 11:33
6

A suggestion for you - you can use parameter expansion to remove the part of the name from the last underscore onwards, and similarly at the start:

f=001_abc_0za.jpg
work=${f%_*}
name=${work#*_}

Then name will have the value abc.

See Apple developer docs, search forward for 'Parameter Expansion'.

2
  • this will not check for ([a-z]+).
    – ghostdog74
    Commented Dec 12, 2009 at 4:09
  • @levislevis - that's true, but, as commented by the OP, it does do what was needed. Commented Dec 12, 2009 at 5:18
3

if you have bash, you can use extended globbing

shopt -s extglob
shopt -s nullglob
shopt -s nocaseglob
for file in +([0-9])_+([a-z])_+([a-z0-9]).jpg
do
   IFS="_"
   set -- $file
   echo "This is your captured output : $2"
done

or

ls +([0-9])_+([a-z])_+([a-z0-9]).jpg | while read file
do
   IFS="_"
   set -- $file
   echo "This is your captured output : $2"
done
1
  • 1
    That looks intriguing. Could you perhaps append a little explanation to it? Or, if you're so inclined, link to a particularly insightful resource that explains it? Thanks!
    – Isaac
    Commented Dec 12, 2009 at 4:14
3

I prefer the one line python or perl command, both often included in major linux disdribution

echo $'
<a href="http://stackoverflow.com">
</a>
<a href="http://google.com">
</a>
' |  python -c $'
import re
import sys
for i in sys.stdin:
  g=re.match(r\'.*href="(.*)"\',i);
  if g is not None:
    print g.group(1)
'

and to handle files:

ls *.txt | python -c $'
import sys
import re
for i in sys.stdin:
  i=i.strip()
  f=open(i,"r")
  for j in f:
    g=re.match(r\'.*href="(.*)"\',j);
    if g is not None:
      print g.group(1)
  f.close()
'
2
  • 2
    +1 for the multiline python program, I feel like this is a fairly standard way of doing this on a lot of systems, that's also inline yet much more flexible than standard bash tools. Commented Dec 13, 2020 at 1:32
  • Ever try doing this in Vim or Neovim? A well-used list comprehension makes it possible to populate your script with batched sh command lines. Commented Sep 5, 2022 at 2:53
3

The follow example shows how to extract the 3 character sequence from a filename using a regex capture group:

for f in 123_abc_123.jpg 123_xyz_432.jpg
do
    echo "f:    " $f
    name=$( perl -ne 'if (/[0-9]+_([a-z]+)_[0-9a-z]*/) { print $1 . "\n" }' <<< $f )
    echo "name: " $name
done

Outputs:

f:     123_abc_123.jpg
name:  abc
f:     123_xyz_432.jpg
name:  xyz

So the if-regex conditional in perl will filter out all non-matching lines at the same time, for those lines that do match, it will apply the capture group(s) which you can access with $1, $2, ... respectively,

1
  • 1
    I wish I would have found this a week ago. Works great, thanks!!!
    – rtremaine
    Commented Jun 16, 2021 at 18:16

Not the answer you're looking for? Browse other questions tagged or ask your own question.