9

I want to recursively look for every *.pdf file in a directory ~/foo whose base name matches the name of the file's parent directory.

For instance, suppose that the directory structure ~/foo looks like this

foo
├── dir1
│   ├── dir1.pdf
│   └── dir1.txt
├── dir2
│   ├── dir2.tex
│   └── spam
│       └── spam.pdf
└── dir3
    ├── dir3.pdf
    └── eggs
        └── eggs.pdf

Running my desired command would return

~/foo/dir1/dir1.pdf
~/foo/dir2/spam/spam.pdf
~/foo/dir3/dir3.pdf
~/foo/dir3/eggs/eggs.pdf

Is this possible using find or some other core utility? I assume this is doable using the -regex option to find but I'm not sure how to write the correct pattern.

16

With GNU find:

find . -regextype egrep -regex '.*/([^/]+)/\1\.pdf'
  • -regextype egrep use egrep style regex.
  • .*/ match grand parent directires.
  • ([^/]+)/ match parent dir in a group.
  • \1\.pdf use backreference to match file name as parent dir.

update

One (myself for one) might think that .* is greedy enough, it's unnecessary to exclude / from parent matching:

find . -regextype egrep -regex '.*/(.+)/\1\.pdf'

Above command won't work well, because it mathches ./a/b/a/b.pdf:

  • .*/ matches ./
  • (.+)/ matches a/b/
  • \1.pdf matches a/b.pdf
  • Very cool. Wish I could regex this well. – Brian Fitzpatrick May 21 at 5:19
  • Or find . -regex '.*/\([^/]*\)/\1\.pdf' and then it would even work with BSD find. – Stéphane Chazelas May 21 at 8:59
7

The traditional loop variant of the find .. -exec sh -c '' to use the shell constructs to match the basename and the immediate path above would be to do below.

find foo/ -name '*.pdf' -exec sh -c '
    for file; do 
        base="${file##*/}"
        path="${file%/*}"
        if [ "${path##*/}" =  "${base%.*}" ]; then
            printf "%s\n" "$file" 
        fi
    done' sh {} +

To breakdown the individual parameter expansions

  • file contains the full path of the .pdf file returned from the find command
  • "${file##*/}" contains only the part after the last / i.e. only the basename of the file
  • "${file%/*}" contains the path up to the final / i.e. except the basename portion of the result
  • "${path##*/}" contains the part after the last / from the path variable, i.e. the immediate folder path above the basename of the file
  • "${base%.*}" contains the part of the basename with the .pdf extension removed

So if the basename without extension matches with the name of the immediate folder above, we print the path.

7

The reverse of Inian's answer, i.e. look for directories, and then see whether they hold a file with a particular name.

The following prints the pathnames of the found files relative to the directory foo:

find foo -type d -exec sh -c '
    for dirpath do
        pathname="$dirpath/${dirpath##*/}.pdf"
        if [ -f "$pathname" ]; then
            printf "%s\n" "$pathname"
        fi
    done' sh {} +

${dirpath##*/} will be replaced by the filename portion of the directory path, and could be replaced by $(basename "$dirpath").

For people who like the short-circuit syntax:

find foo -type d -exec sh -c '
    for dirpath do
        pathname="$dirpath/${dirpath##*/}.pdf"
        [ -f "$pathname" ] && printf "%s\n" "$pathname"
    done' sh {} +

The benefit of doing it this way is that you may have more PDF files than directories. The number of tests involved are reduced if one restrict the query by the smaller number (the number of directories).

For example, if a single directory contains 100 PDF files, this would only try to detect one of them rather than testing the names of all 100 files against that of the directory.

3

with zsh:

printf '%s\n' **/*/*.pdf(e@'[[ $REPLY:t = $REPLY:h:t.pdf ]]'@)

Beware that while **/ won't follow symlinks, */ will.

2

It was not specified, but here is a solution without regular expressions if anybody is interested.

We can use find . -type f to just get files, then utilize dirname and basename to write the conditional. The utilities have the following behavior:

$ find . -type f
./dir2/spam/spam.pdf
./dir2/dir2.tex
./dir3/dir3.pdf
./dir3/eggs/eggs.pdf
./dir1/dir1.pdf
./dir1/dir1.txt

basename returns just the filename after the last /:

$ for file in $(find . -type f); do basename $file; done
spam.pdf
dir2.tex
dir3.pdf
eggs.pdf
dir1.pdf
dir1.txt

dirname gives the entire path up to the final /:

$ for file in $(find . -type f); do dirname $file; done
./dir2/spam
./dir2
./dir3
./dir3/eggs
./dir1
./dir1

Therefore, basename $(dirname $file) gives the parent directory of the file.

$ for file in $(find . -type f); do basename $(dirname $file) ; done
spam
dir2
dir3
eggs
dir1
dir1

Solution

Combine the above to form the conditional "$(basename $file)" = "$(basename $(dirname $file))".pdf, then only print each result from find if that conditional returns true.

$ while read file; do if [ "$(basename "$file")" = "$(basename "$(dirname "$file")")".pdf ]; then echo $file; fi done < <(find . -type f)
./dir2/spam/spam.pdf
./dir3/dir3.pdf
./dir3/eggs/eggs.pdf
./dir1/dir1.pdf
./Final Thesis/grits/grits.pdf
./Final Thesis/Final Thesis.pdf

In the above example, we've added a directory/file with spaces in the name to treat that case (thanks to @Kusalananda in the comments)

  • This will unfortunately break on filenames like Final Thesis.pdf (with a space). – Kusalananda May 21 at 16:17
  • @Kusalananda Fixed. – user1717828 May 21 at 16:38
0

I take bash globbing, simple loop over string tests any day over the Find program. Call me irrational, and while it may well be suboptimal such simple code does the trick for me: readable and reusable, satisfying even!. Allow me therefore to suggest a combination of:

• bash globstar : for f in ** ; do ... ** loops over every files in the current directory and all subfolders.. to check globstar status in your current session: shopt -p globstar. To activate globstar: shopt -s globstar.

• "file" utlity : if [[ $(file "$f") =~ pdf ]]; then ... to check actual file format for pdf - more robust than testing only for the file's extension

• basename, dirname : to compare the file name to the name of the directory immediately above it. basename returns the filename - dirname returns entire directory path - combine the two functions to only return the one directory containing the matching file. I put each one in a variable (_mydir and _myf) to then do a simple test using =~ for string matching.

One subtility: remove any "dot" in the filename to avoid matching filename to current directory whose shortcut is also "." - I used direct string substitution on the variable _myf : ${_myf//./} - not very elegant but it works. Positive matches will return each file's path - together with the full path of the current folder by preceding the output with : $(pwd)/.

Code

for f in ** ; do
  if [[ $(file "$f") =~ PDF ]]; then
    _mydir="$(basename $(dirname $f))" ; 
    _myf="$(basename $f)" ; 
    [[ "${_myf//./}" =~ "$_mydir" ]] && echo -e "$(pwd)/$f" ; 
  fi ; 
done

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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