grepfruit 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +20 -9
- data/lib/grepfruit/cli.rb +5 -5
- data/lib/grepfruit/decorator.rb +8 -6
- data/lib/grepfruit/search.rb +72 -59
- data/lib/grepfruit/search_results.rb +25 -0
- data/lib/grepfruit/version.rb +1 -1
- data/lib/grepfruit.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 51c0381a8449c749e2017cf4feac9cf35fb2c58e38f19a6a00fe994997d38b17
|
4
|
+
data.tar.gz: 61bca9861ce7d63331285d7b089e8521eac58f19465c293a50b07c5e4eb3cf01
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e16bd1bc1c78d56b9253e2be32788f7dc5a03584de099e6ff6e7b78f581ea7ba9af018dc6c7b17dda06a7f55ab61488181237320b16fc3cd5768422a16d0cf8f
|
7
|
+
data.tar.gz: 948e8fadc208c058cfa943eeabb79a8b17d499fb78277e757b0655357dd028d8f312d008c52098c14bdcc2d1e94279fcefc1a821467e54020a26b209a72000d1
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,13 @@
|
|
1
|
+
## v3.1.1
|
2
|
+
|
3
|
+
- Fixed JSON timestamp to reflect result generation time
|
4
|
+
- Minor optimization
|
5
|
+
|
6
|
+
## v3.1.0
|
7
|
+
|
8
|
+
- Added --include option to specify files to include in the search
|
9
|
+
- Both --exclude and --include options can now accept wildcard patterns
|
10
|
+
|
1
11
|
## v3.0.0
|
2
12
|
|
3
13
|
- Dropped support for Ruby 3.1
|
data/README.md
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
[](http://badge.fury.io/rb/grepfruit)
|
4
4
|
[](https://github.com/brownboxdev/grepfruit/actions/workflows/ci.yml)
|
5
5
|
|
6
|
-
Grepfruit is a Ruby gem for searching files within a directory for specified regular expression patterns, with exclusion options and JSON-formatted or colorized output for enhanced readability. Originally designed for CI/CD pipelines to search for `TODO` comments in Ruby on Rails applications, Grepfruit provides more user-friendly output than the standard `grep` command while maintaining the flexibility for diverse search scenarios.
|
6
|
+
Grepfruit is a Ruby gem for searching files within a directory for specified regular expression patterns, with exclusion and inclusion options and JSON-formatted or colorized output for enhanced readability. Originally designed for CI/CD pipelines to search for `TODO` comments in Ruby on Rails applications, Grepfruit provides more user-friendly output than the standard `grep` command while maintaining the flexibility for diverse search scenarios.
|
7
7
|
|
8
8
|
**Key Features:**
|
9
9
|
|
@@ -22,7 +22,7 @@ Grepfruit is a Ruby gem for searching files within a directory for specified reg
|
|
22
22
|
- [Exit Status](#exit-status)
|
23
23
|
|
24
24
|
**Community Resources:**
|
25
|
-
- [Contributing](#contributing)
|
25
|
+
- [Getting Help and Contributing](#getting-help-and-contributing)
|
26
26
|
- [License](#license)
|
27
27
|
- [Code of Conduct](#code-of-conduct)
|
28
28
|
|
@@ -56,6 +56,7 @@ If no PATH is specified, Grepfruit searches the current directory.
|
|
56
56
|
|--------|-------------|
|
57
57
|
| `-r, --regex REGEX` | Regex pattern to search for (required) |
|
58
58
|
| `-e, --exclude x,y,z` | Comma-separated list of files, directories, or lines to exclude |
|
59
|
+
| `-i, --include x,y,z` | Comma-separated list of file patterns to include (only these files will be searched) |
|
59
60
|
| `-t, --truncate N` | Truncate search result output to N characters |
|
60
61
|
| `-j, --jobs N` | Number of parallel workers (default: number of CPU cores) |
|
61
62
|
| `--search-hidden` | Include hidden files and directories in search |
|
@@ -84,7 +85,7 @@ grepfruit search -r 'TODO' -e 'log,tmp,vendor,node_modules,assets'
|
|
84
85
|
Search for both `FIXME` and `TODO` comments in a specific directory:
|
85
86
|
|
86
87
|
```bash
|
87
|
-
grepfruit search -r 'FIXME|TODO' -e 'bin,tmp/log,Gemfile.lock' dev/my_app
|
88
|
+
grepfruit search -r 'FIXME|TODO' -e 'bin,*.md,tmp/log,Gemfile.lock' dev/my_app
|
88
89
|
```
|
89
90
|
|
90
91
|
### Line-Specific Exclusion
|
@@ -95,6 +96,15 @@ Exclude specific lines from search results:
|
|
95
96
|
grepfruit search -r 'FIXME|TODO' -e 'README.md:18'
|
96
97
|
```
|
97
98
|
|
99
|
+
### Including Specific File Types
|
100
|
+
|
101
|
+
Search only in specific file types using patterns:
|
102
|
+
|
103
|
+
```bash
|
104
|
+
grepfruit search -r 'TODO' -i '*.rb,*.js'
|
105
|
+
grepfruit search -r 'FIXME' -i '*.py'
|
106
|
+
```
|
107
|
+
|
98
108
|
### Output Truncation
|
99
109
|
|
100
110
|
Limit output length for cleaner results:
|
@@ -111,22 +121,23 @@ Search hidden files and directories:
|
|
111
121
|
grepfruit search -r 'FIXME|TODO' --search-hidden
|
112
122
|
```
|
113
123
|
|
114
|
-
### JSON Output
|
124
|
+
### JSON Output
|
115
125
|
|
116
|
-
Get structured JSON output
|
126
|
+
Get structured JSON output:
|
117
127
|
|
118
128
|
```bash
|
119
|
-
grepfruit search -r 'TODO' --json
|
129
|
+
grepfruit search -r 'TODO' -e 'node_modules' -i '*.rb,*.js' --json /path/to/search
|
120
130
|
```
|
121
131
|
|
122
|
-
This outputs a
|
132
|
+
This outputs a JSON response containing search metadata, summary statistics, and detailed match information:
|
123
133
|
|
124
|
-
```
|
134
|
+
```jsonc
|
125
135
|
{
|
126
136
|
"search": {
|
127
137
|
"pattern": "/TODO/",
|
128
138
|
"directory": "/path/to/search",
|
129
139
|
"exclusions": ["node_modules"],
|
140
|
+
"inclusions": ["*.rb", "*.js"],
|
130
141
|
"timestamp": "2025-01-16T10:30:00+00:00"
|
131
142
|
},
|
132
143
|
"summary": {
|
@@ -161,7 +172,7 @@ Grepfruit returns meaningful exit codes for CI/CD integration:
|
|
161
172
|
- **Exit code 0**: No matches found (ideal for quality gates - code is clean)
|
162
173
|
- **Exit code 1**: Pattern matches were found (CI should fail - issues detected)
|
163
174
|
|
164
|
-
## Contributing
|
175
|
+
## Getting Help and Contributing
|
165
176
|
|
166
177
|
### Getting Help
|
167
178
|
Have a question or need assistance? Open a discussion in our [discussions section](https://github.com/brownboxdev/grepfruit/discussions) for:
|
data/lib/grepfruit/cli.rb
CHANGED
@@ -11,6 +11,7 @@ module Grepfruit
|
|
11
11
|
|
12
12
|
option :regex, aliases: ["-r"], required: true, desc: "Regex pattern to search for"
|
13
13
|
option :exclude, aliases: ["-e"], type: :array, default: [], desc: "Comma-separated list of files and directories to exclude"
|
14
|
+
option :include, aliases: ["-i"], type: :array, default: [], desc: "Comma-separated list of file patterns to include (only these files will be searched)"
|
14
15
|
option :truncate, aliases: ["-t"], type: :integer, desc: "Truncate output to N characters"
|
15
16
|
option :search_hidden, type: :boolean, default: false, desc: "Search hidden files and directories"
|
16
17
|
option :jobs, aliases: ["-j"], type: :integer, desc: "Number of parallel workers (default: all CPU cores, use 1 for sequential)"
|
@@ -23,10 +24,11 @@ module Grepfruit
|
|
23
24
|
dir: path,
|
24
25
|
regex: create_regex(options[:regex]),
|
25
26
|
exclude: options[:exclude] || [],
|
27
|
+
include: options[:include] || [],
|
26
28
|
truncate: options[:truncate]&.to_i,
|
27
|
-
search_hidden:
|
29
|
+
search_hidden: options[:search_hidden],
|
28
30
|
jobs: options[:jobs]&.to_i,
|
29
|
-
json_output:
|
31
|
+
json_output: options[:json]
|
30
32
|
).run
|
31
33
|
end
|
32
34
|
|
@@ -34,9 +36,7 @@ module Grepfruit
|
|
34
36
|
|
35
37
|
def validate_options!(options)
|
36
38
|
error_exit("You must specify a regex pattern using the -r or --regex option.") unless options[:regex]
|
37
|
-
|
38
|
-
jobs = options[:jobs]&.to_i
|
39
|
-
error_exit("Number of jobs must be at least 1") if jobs && jobs < 1
|
39
|
+
error_exit("Number of jobs must be at least 1") if (jobs = options[:jobs]&.to_i) && jobs < 1
|
40
40
|
end
|
41
41
|
|
42
42
|
def create_regex(pattern)
|
data/lib/grepfruit/decorator.rb
CHANGED
@@ -4,9 +4,10 @@ module Grepfruit
|
|
4
4
|
|
5
5
|
private
|
6
6
|
|
7
|
-
def
|
8
|
-
def
|
9
|
-
def
|
7
|
+
def colorize(text, color) = "#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
8
|
+
def green(text) = colorize(text, :green)
|
9
|
+
def red(text) = colorize(text, :red)
|
10
|
+
def cyan(text) = colorize(text, :cyan)
|
10
11
|
|
11
12
|
def number_of_files(num) = "#{num} file#{'s' if num != 1}"
|
12
13
|
def number_of_matches(num) = "#{num} match#{'es' if num != 1}"
|
@@ -16,8 +17,8 @@ module Grepfruit
|
|
16
17
|
end
|
17
18
|
|
18
19
|
def processed_line(line)
|
19
|
-
|
20
|
-
truncate &&
|
20
|
+
stripped = line.strip
|
21
|
+
truncate && stripped.length > truncate ? "#{stripped[0...truncate]}..." : stripped
|
21
22
|
end
|
22
23
|
|
23
24
|
def display_results(lines, files, files_with_matches)
|
@@ -40,7 +41,8 @@ module Grepfruit
|
|
40
41
|
pattern: regex.inspect,
|
41
42
|
directory: dir,
|
42
43
|
exclusions: (excluded_paths + excluded_lines).map { |path_parts| path_parts.join("/") },
|
43
|
-
|
44
|
+
inclusions: included_paths.map { |path_parts| path_parts.join("/") },
|
45
|
+
timestamp: Time.now.strftime("%Y-%m-%dT%H:%M:%S%z")
|
44
46
|
}
|
45
47
|
|
46
48
|
summary = {
|
data/lib/grepfruit/search.rb
CHANGED
@@ -1,99 +1,107 @@
|
|
1
1
|
require "find"
|
2
2
|
require "etc"
|
3
3
|
|
4
|
-
require_relative "decorator"
|
5
|
-
|
6
4
|
Warning[:experimental] = false
|
7
5
|
|
8
6
|
module Grepfruit
|
9
7
|
class Search
|
10
8
|
include Decorator
|
11
9
|
|
12
|
-
attr_reader :dir, :regex, :excluded_paths, :excluded_lines, :truncate, :search_hidden, :jobs, :json_output
|
10
|
+
attr_reader :dir, :regex, :excluded_paths, :excluded_lines, :included_paths, :truncate, :search_hidden, :jobs, :json_output
|
13
11
|
|
14
|
-
def initialize(dir:, regex:, exclude:, truncate:, search_hidden:, jobs:, json_output: false)
|
12
|
+
def initialize(dir:, regex:, exclude:, include:, truncate:, search_hidden:, jobs:, json_output: false)
|
15
13
|
@dir = File.expand_path(dir)
|
16
14
|
@regex = regex
|
17
15
|
@excluded_lines, @excluded_paths = exclude.map { _1.split("/") }.partition { _1.last.include?(":") }
|
16
|
+
@included_paths = include.map { _1.split("/") }
|
18
17
|
@truncate = truncate
|
19
18
|
@search_hidden = search_hidden
|
20
19
|
@jobs = jobs || Etc.nprocessors
|
21
20
|
@json_output = json_output
|
22
|
-
@start_time = Time.now
|
23
21
|
end
|
24
22
|
|
25
23
|
def run
|
26
24
|
puts "Searching for #{regex.inspect} in #{dir.inspect}...\n\n" unless json_output
|
27
25
|
|
28
|
-
|
29
|
-
|
30
|
-
|
26
|
+
display_final_results(execute_search)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def execute_search
|
32
|
+
results = SearchResults.new
|
33
|
+
workers = Array.new(jobs) { create_persistent_worker }
|
31
34
|
file_enumerator = create_file_enumerator
|
32
35
|
active_workers = {}
|
33
36
|
|
34
37
|
workers.each do |worker|
|
35
|
-
|
36
|
-
next unless file_path
|
37
|
-
|
38
|
-
worker.send([file_path, regex, excluded_lines, dir])
|
39
|
-
active_workers[worker] = file_path
|
40
|
-
total_files += 1
|
38
|
+
assign_file_to_worker(worker, file_enumerator, active_workers, results)
|
41
39
|
end
|
42
40
|
|
43
41
|
while active_workers.any?
|
44
|
-
ready_worker,
|
42
|
+
ready_worker, worker_result = Ractor.select(*active_workers.keys)
|
45
43
|
active_workers.delete(ready_worker)
|
46
44
|
|
47
|
-
|
48
|
-
|
49
|
-
next_file = get_next_file(file_enumerator)
|
50
|
-
next unless next_file
|
51
|
-
|
52
|
-
ready_worker.send([next_file, regex, excluded_lines, dir])
|
53
|
-
active_workers[ready_worker] = next_file
|
54
|
-
total_files += 1
|
45
|
+
results.increment_files_with_matches if process_worker_result(worker_result, results)
|
46
|
+
assign_file_to_worker(ready_worker, file_enumerator, active_workers, results)
|
55
47
|
end
|
56
48
|
|
57
|
-
workers
|
49
|
+
shutdown_workers(workers)
|
50
|
+
results
|
51
|
+
end
|
58
52
|
|
53
|
+
def display_final_results(results)
|
59
54
|
if json_output
|
60
|
-
display_json_results(raw_matches, total_files, total_files_with_matches)
|
55
|
+
display_json_results(results.raw_matches, results.total_files, results.total_files_with_matches)
|
61
56
|
else
|
62
|
-
display_results(all_lines, total_files, total_files_with_matches)
|
57
|
+
display_results(results.all_lines, results.total_files, results.total_files_with_matches)
|
63
58
|
end
|
64
59
|
end
|
65
60
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
nil
|
72
|
-
end
|
73
|
-
|
74
|
-
def create_workers
|
75
|
-
Array.new(jobs) do
|
76
|
-
Ractor.new do
|
77
|
-
loop do
|
78
|
-
file_path, pattern, exc_lines, base_dir = Ractor.receive
|
79
|
-
results, has_matches = [], false
|
61
|
+
def create_persistent_worker
|
62
|
+
Ractor.new do
|
63
|
+
loop do
|
64
|
+
work = Ractor.receive
|
65
|
+
break if work == :quit
|
80
66
|
|
81
|
-
|
82
|
-
|
67
|
+
file_path, pattern, exc_lines, base_dir = work
|
68
|
+
file_results, has_matches = [], false
|
83
69
|
|
84
|
-
|
85
|
-
|
70
|
+
File.foreach(file_path).with_index do |line, line_num|
|
71
|
+
next unless line.valid_encoding? && line.match?(pattern)
|
86
72
|
|
87
|
-
|
88
|
-
|
89
|
-
end
|
73
|
+
relative_path = file_path.delete_prefix("#{base_dir}/")
|
74
|
+
next if exc_lines.any? { "#{relative_path}:#{line_num + 1}".end_with?(_1.join("/")) }
|
90
75
|
|
91
|
-
|
76
|
+
file_results << [relative_path, line_num + 1, line]
|
77
|
+
has_matches = true
|
92
78
|
end
|
79
|
+
|
80
|
+
Ractor.yield([file_results, has_matches])
|
93
81
|
end
|
94
82
|
end
|
95
83
|
end
|
96
84
|
|
85
|
+
def assign_file_to_worker(worker, file_enumerator, active_workers, results)
|
86
|
+
file_path = get_next_file(file_enumerator)
|
87
|
+
return unless file_path
|
88
|
+
|
89
|
+
worker.send([file_path, regex, excluded_lines, dir])
|
90
|
+
active_workers[worker] = file_path
|
91
|
+
results.total_files += 1
|
92
|
+
end
|
93
|
+
|
94
|
+
def get_next_file(enumerator)
|
95
|
+
enumerator.next
|
96
|
+
rescue StopIteration
|
97
|
+
nil
|
98
|
+
end
|
99
|
+
|
100
|
+
def shutdown_workers(workers)
|
101
|
+
workers.each { |worker| worker.send(:quit) }
|
102
|
+
workers.each(&:close_outgoing)
|
103
|
+
end
|
104
|
+
|
97
105
|
def create_file_enumerator
|
98
106
|
Enumerator.new do |yielder|
|
99
107
|
Find.find(dir) do |path|
|
@@ -109,34 +117,39 @@ module Grepfruit
|
|
109
117
|
end
|
110
118
|
end
|
111
119
|
|
112
|
-
def process_worker_result(
|
120
|
+
def process_worker_result(worker_result, results)
|
121
|
+
file_results, has_matches = worker_result
|
122
|
+
|
113
123
|
if has_matches
|
114
|
-
|
124
|
+
results.add_raw_matches(file_results) if json_output
|
115
125
|
|
116
126
|
unless json_output
|
117
127
|
colored_lines = file_results.map do |relative_path, line_num, line_content|
|
118
128
|
"#{cyan("#{relative_path}:#{line_num}")}: #{processed_line(line_content)}"
|
119
129
|
end
|
120
|
-
|
130
|
+
results.add_lines(colored_lines)
|
121
131
|
print red("M")
|
122
132
|
end
|
123
|
-
true
|
124
133
|
else
|
125
134
|
print green(".") unless json_output
|
126
|
-
false
|
127
135
|
end
|
136
|
+
|
137
|
+
has_matches
|
128
138
|
end
|
129
139
|
|
130
140
|
def excluded_path?(path)
|
131
|
-
|
132
|
-
end
|
141
|
+
rel_path = path.delete_prefix("#{dir}/")
|
133
142
|
|
134
|
-
|
135
|
-
|
143
|
+
(File.file?(path) && included_paths.any? && !matches_pattern?(included_paths, rel_path)) ||
|
144
|
+
matches_pattern?(excluded_paths, rel_path) ||
|
145
|
+
(!search_hidden && File.basename(path).start_with?("."))
|
136
146
|
end
|
137
147
|
|
138
|
-
def
|
139
|
-
|
148
|
+
def matches_pattern?(pattern_list, path)
|
149
|
+
pattern_list.any? do |pattern_parts|
|
150
|
+
pattern = pattern_parts.join("/")
|
151
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME) || File.fnmatch?(pattern, File.basename(path))
|
152
|
+
end
|
140
153
|
end
|
141
154
|
end
|
142
155
|
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Grepfruit
|
2
|
+
class SearchResults
|
3
|
+
attr_reader :all_lines, :raw_matches, :total_files_with_matches
|
4
|
+
attr_accessor :total_files
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@all_lines = []
|
8
|
+
@raw_matches = []
|
9
|
+
@total_files = 0
|
10
|
+
@total_files_with_matches = 0
|
11
|
+
end
|
12
|
+
|
13
|
+
def increment_files_with_matches
|
14
|
+
@total_files_with_matches += 1
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_lines(lines)
|
18
|
+
@all_lines.concat(lines)
|
19
|
+
end
|
20
|
+
|
21
|
+
def add_raw_matches(matches)
|
22
|
+
@raw_matches.concat(matches)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/grepfruit/version.rb
CHANGED
data/lib/grepfruit.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: grepfruit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- enjaku4
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-07-16 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: dry-cli
|
@@ -37,6 +37,7 @@ files:
|
|
37
37
|
- lib/grepfruit/cli.rb
|
38
38
|
- lib/grepfruit/decorator.rb
|
39
39
|
- lib/grepfruit/search.rb
|
40
|
+
- lib/grepfruit/search_results.rb
|
40
41
|
- lib/grepfruit/version.rb
|
41
42
|
homepage: https://github.com/brownboxdev/grepfruit
|
42
43
|
licenses:
|