recursively list files that contain m of n regex

Viewed 124

I have a directory with quite some files. I have n search patterns and would like to list all files that match m of those.

Example: From the files below, list the ones that contain at least two of str1, str2, str3 and str4.

$ ls -l dir/
total 16
-rw-r--r--. 1 me me 10 Jun 22 14:22 a
-rw-r--r--. 1 me me  5 Jun 22 14:22 b
-rw-r--r--. 1 me me 10 Jun 22 14:22 c
-rw-r--r--. 1 me me  9 Jun 22 14:22 d
-rw-r--r--. 1 me me 10 Jun 22 14:22 e
$ cat dir/a
str1
str2
$ cat dir/b
str2
$ cat dir/c
str2
str3
$ cat dir/d
str
str4
$ cat dir/e
str2
str4

I managed to achieve this with a rather ugly for loop on find results that spawns n grep processes for each file, which obviously is super inefficient and would take ages on directories with a lot of files:

for f in $(find dir/ -type f); do
  c=0
  grep -qs 'str1' $f && let c++
  grep -qs 'str2' $f && let c++
  grep -qs 'str3' $f && let c++
  grep -qs 'str4' $f && let c++
  [[ $c -ge 2 ]] && echo $f
done

I am quite sure I could achieve this in a far better performing way, but I am not sure how to tackle it. From what I understand from the man page (i.e. on -e and -m) this is not possible with grep alone.

What would be the right tool to use? Is this possible with awk?

Bonus: By using find I can define the files to search more precisely (i.e. -prune certain sub directories or only search files with -iname '*.txt'), which I would like to do with other solutions, too.


UPDATE

Some statistics about the performance of different implementations below.


find + awk

(Script from this answer)

real    0m0,006s
user    0m0,002s
sys     0m0,004s

python

(I'm a python noob, please advise if this could be optimized):

import os

patterns = []
patterns = ["str1", "str2", "str3", "str4"]

for root, dirs, files in os.walk("dir"):
    for file in files:
        c = int(0)
        filepath = os.path.join(root, file)
        with open(filepath, 'r') as input:
            for pattern in patterns:
                for line in input:
                    if pattern in line:
                        c += 1
                        break
        if ( c >= 2 ):
            print(filepath)
real    0m0,025s
user    0m0,019s
sys     0m0,006s

c++

(Script from this answer)

real    0m0,002s
user    0m0,001s
sys     0m0,001s
3 Answers
$ cat reg.txt
str1
str2
str3
str4
$ cat prog.awk
# reads regexps from the first input file
# parameterized by `m'
# requires gawk or mawk for `nextfile'
FNR == NR {
  reg[NR] = $0
  next
}
FNR == 1 {
  for (i in reg)
    tst[i]
  cnt = 0
}
{
  for (i in tst) {
    if ($0 ~ reg[i]) {
      if (++cnt == m) {
        print FILENAME
        nextfile
      }
      delete tst[i]
    }
  }
}
$ find dir -type f -exec awk -v m=2 -f prog.awk reg.txt {} +
dir/a
dir/c

Since the programming language doesn't matter as much as the performance, here's a version in C++. I haven't compared it with awk myself though.

#include <cstddef>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

namespace fs = std::filesystem;

int main() {
    const fs::path dir = "dir";
    std::vector<std::string_view> strs{   // or: std::array<std::string_view, 4>
        "str1",
        "str2",
        "str3",
        "str4",
    };

    std::string line;
    int count;     // matches in a file
    size_t strsco; // number of strings to check in strs

    // a lambda to find a match on a line
    auto matcher = [&](const fs::directory_entry& de) {
        for(size_t idx = 0; idx < strsco; ++idx) {
            if(line.find(strs[idx]) != std::string::npos) {
                // a match was found

                if(++count >= 2) {
                    std::cout << de.path() << '\n';
                    // or the below if the quotation marks surrounding the path are
                    // unwanted:
                    // std::cout << de.path().native() << '\n';
                    return false;
                }

                // swap the found string_view with the last in the vector
                // to remove it from future matches in this file.
                --strsco;
                std::swap(strs[idx], strs[strsco]);
            }
        }
        return true;
    };

    // do a "find dir -type f"
    for(const fs::directory_entry& de : fs::recursive_directory_iterator(dir)) {
        if(de.is_regular_file()) { // -type f

            // open the found file
            if(std::ifstream file(de.path()); file) {
                // reset counters
                count = 0;
                strsco = strs.size();
                // read line by line until the file stream is depleated or matcher()
                // returns false
                while(std::getline(file, line) && matcher(de));
            }
        }
    }
}

Save it to prog.cpp and compile like this (if you have g++):

g++ -std=c++17 -O3 -o prog prog.cpp

If you use another compiler, be sure to turn on optimization for speed and that it requires C++17.

Here's an option using awk since you tagged it with that too:

find dir -type f -exec \
awk '/str1|str2|str3|str4/{c++} END{if(c>=2) print FILENAME;}' {} \;

It will however count duplicates, so a file containing

str1
str1

will be listed.

Related