grepfruit 3.1.2 → 3.2.0
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 +67 -17
- data/grepfruit.gemspec +8 -3
- data/lib/grepfruit/cli.rb +15 -17
- data/lib/grepfruit/cli_decorator.rb +42 -0
- data/lib/grepfruit/cli_search.rb +23 -0
- data/lib/grepfruit/programmatic_search.rb +9 -0
- data/lib/grepfruit/ractor_compat.rb +43 -0
- data/lib/grepfruit/search.rb +75 -65
- data/lib/grepfruit/search_results.rb +4 -4
- data/lib/grepfruit/version.rb +1 -1
- data/lib/grepfruit.rb +24 -2
- metadata +19 -9
- data/lib/grepfruit/decorator.rb +0 -73
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 505fa3f2e5b4b5ffc41a795f0305e6a37f913b7fe66036018d4f4438dc6c9b40
|
|
4
|
+
data.tar.gz: 3f89449fd5ab9d2851aadac1ec432606aeaae73b4673bd0d4e62cd96b4bdc83f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e2ff1bdec53492186f6ff37670e82b62ee72ff84435c68167bd7ef7ae460ee23221dbc1d0682205207acc579660675ea4504b6722498e51520465fb9ac79cbe
|
|
7
|
+
data.tar.gz: 4dac4064e07aafd907523d6223dfa789eae14428a5e92b4edcb82ef206391e179e469f76db56d972bbc01815be0c58f3c574353832eb45bfbb942d89b21a052e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## v3.2.0
|
|
2
|
+
|
|
3
|
+
- Added programmatic API for use in Ruby applications
|
|
4
|
+
- Added --count option to display only the count of matches
|
|
5
|
+
- Added support for Ruby 4
|
|
6
|
+
|
|
7
|
+
## v3.1.3
|
|
8
|
+
|
|
9
|
+
- Fixed race condition in worker shutdown
|
|
10
|
+
|
|
1
11
|
## v3.1.2
|
|
2
12
|
|
|
3
13
|
- Corrected usage of `dry-cli` gem for flag handling
|
data/README.md
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
# Grepfruit:
|
|
1
|
+
# Grepfruit: Text Search Tool
|
|
2
2
|
|
|
3
3
|
[](http://badge.fury.io/rb/grepfruit)
|
|
4
|
-
[](https://rubygems.org/gems/grepfruit)
|
|
5
|
+
[](https://github.com/enjaku4/grepfruit/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
5
7
|
|
|
6
|
-
Grepfruit is a
|
|
8
|
+
Grepfruit is a tool for searching regex patterns in files, with a CLI designed for CI/CD pipelines and a programmatic API for Ruby applications.
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- Parallel search using Ractors
|
|
11
|
-
- JSON output format for programmatic integration
|
|
12
|
-
- Colorized output for improved readability
|
|
13
|
-
- CI/CD pipeline friendly exit codes
|
|
10
|
+
<img width="933" height="221" alt="Screenshot 2025-10-15 at 13 28 06" src="https://github.com/user-attachments/assets/cad09b17-9b79-4377-8cf9-365108a96a5a" />
|
|
11
|
+
<img width="709" height="586" alt="Screenshot 2025-10-15 at 13 29 01" src="https://github.com/user-attachments/assets/36664522-1eee-40bf-8f58-a2503668e1a4" />
|
|
14
12
|
|
|
15
13
|
## Table of Contents
|
|
16
14
|
|
|
@@ -20,6 +18,7 @@ Grepfruit is a Ruby gem for searching files within a directory for specified reg
|
|
|
20
18
|
- [Command Line Options](#command-line-options)
|
|
21
19
|
- [Usage Examples](#usage-examples)
|
|
22
20
|
- [Exit Status](#exit-status)
|
|
21
|
+
- [Programmatic API](#programmatic-api)
|
|
23
22
|
|
|
24
23
|
**Community Resources:**
|
|
25
24
|
- [Getting Help and Contributing](#getting-help-and-contributing)
|
|
@@ -30,7 +29,7 @@ Grepfruit is a Ruby gem for searching files within a directory for specified reg
|
|
|
30
29
|
|
|
31
30
|
Install the gem:
|
|
32
31
|
|
|
33
|
-
```
|
|
32
|
+
```shell
|
|
34
33
|
gem install grepfruit
|
|
35
34
|
```
|
|
36
35
|
|
|
@@ -59,6 +58,7 @@ If no PATH is specified, Grepfruit searches the current directory.
|
|
|
59
58
|
| `-i, --include x,y,z` | Comma-separated list of file patterns to include (only these files will be searched) |
|
|
60
59
|
| `-t, --truncate N` | Truncate search result output to N characters |
|
|
61
60
|
| `-j, --jobs N` | Number of parallel workers (default: number of CPU cores) |
|
|
61
|
+
| `-c, --count` | Show only match counts, not match details |
|
|
62
62
|
| `--search-hidden` | Include hidden files and directories in search |
|
|
63
63
|
| `--json` | Output results in JSON format |
|
|
64
64
|
|
|
@@ -156,9 +156,18 @@ This outputs a JSON response containing search metadata, summary statistics, and
|
|
|
156
156
|
}
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
+
### Count Only Mode
|
|
160
|
+
|
|
161
|
+
Show only match counts without displaying the actual matches:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
grepfruit search -r 'TODO' --count
|
|
165
|
+
grepfruit search -r 'FIXME|TODO' -c -e 'vendor,node_modules'
|
|
166
|
+
```
|
|
167
|
+
|
|
159
168
|
### Parallel Processing
|
|
160
169
|
|
|
161
|
-
Control the number of parallel workers:
|
|
170
|
+
Grepfruit uses ractors for true parallel processing across CPU cores. Control the number of parallel workers:
|
|
162
171
|
|
|
163
172
|
```bash
|
|
164
173
|
grepfruit search -r 'TODO' -j 8 # Use 8 parallel workers
|
|
@@ -172,16 +181,57 @@ Grepfruit returns meaningful exit codes for CI/CD integration:
|
|
|
172
181
|
- **Exit code 0**: No matches found (ideal for quality gates - code is clean)
|
|
173
182
|
- **Exit code 1**: Pattern matches were found (CI should fail - issues detected)
|
|
174
183
|
|
|
184
|
+
## Programmatic API
|
|
185
|
+
|
|
186
|
+
Use Grepfruit directly in Ruby applications:
|
|
187
|
+
|
|
188
|
+
```rb
|
|
189
|
+
result = Grepfruit.search(
|
|
190
|
+
regex: /TODO|FIXME/,
|
|
191
|
+
path: "app",
|
|
192
|
+
exclude: ["tmp", "vendor"],
|
|
193
|
+
include: ["*.rb"],
|
|
194
|
+
truncate: 80,
|
|
195
|
+
search_hidden: false,
|
|
196
|
+
jobs: 4,
|
|
197
|
+
count: false
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Returns a hash (note: when `count: true`, the `matches` key is omitted):
|
|
202
|
+
|
|
203
|
+
```rb
|
|
204
|
+
{
|
|
205
|
+
search: {
|
|
206
|
+
pattern: /TODO|FIXME/,
|
|
207
|
+
directory: "/path/to/app",
|
|
208
|
+
exclusions: ["tmp", "vendor"],
|
|
209
|
+
inclusions: ["*.rb"]
|
|
210
|
+
},
|
|
211
|
+
summary: {
|
|
212
|
+
files_checked: 42,
|
|
213
|
+
files_with_matches: 8,
|
|
214
|
+
total_matches: 23
|
|
215
|
+
},
|
|
216
|
+
matches: [
|
|
217
|
+
{ file: "models/user.rb", line: 15, content: "# TODO: add validation" },
|
|
218
|
+
# ...
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
All keyword arguments correspond to their CLI flag counterparts.
|
|
224
|
+
|
|
175
225
|
## Getting Help and Contributing
|
|
176
226
|
|
|
177
227
|
### Getting Help
|
|
178
|
-
Have a question or need assistance? Open a discussion in our [discussions section](https://github.com/
|
|
228
|
+
Have a question or need assistance? Open a discussion in our [discussions section](https://github.com/enjaku4/grepfruit/discussions) for:
|
|
179
229
|
- Usage questions
|
|
180
230
|
- Implementation guidance
|
|
181
231
|
- Feature suggestions
|
|
182
232
|
|
|
183
233
|
### Reporting Issues
|
|
184
|
-
Found a bug? Please [create an issue](https://github.com/
|
|
234
|
+
Found a bug? Please [create an issue](https://github.com/enjaku4/grepfruit/issues) with:
|
|
185
235
|
- A clear description of the problem
|
|
186
236
|
- Steps to reproduce the issue
|
|
187
237
|
- Your environment details (Ruby version, OS, etc.)
|
|
@@ -190,14 +240,14 @@ Found a bug? Please [create an issue](https://github.com/brownboxdev/grepfruit/i
|
|
|
190
240
|
Ready to contribute? You can:
|
|
191
241
|
- Fix bugs by submitting pull requests
|
|
192
242
|
- Improve documentation
|
|
193
|
-
- Add new features (please discuss first in our [discussions section](https://github.com/
|
|
243
|
+
- Add new features (please discuss first in our [discussions section](https://github.com/enjaku4/grepfruit/discussions))
|
|
194
244
|
|
|
195
|
-
Before contributing, please read the [contributing guidelines](https://github.com/
|
|
245
|
+
Before contributing, please read the [contributing guidelines](https://github.com/enjaku4/grepfruit/blob/master/CONTRIBUTING.md)
|
|
196
246
|
|
|
197
247
|
## License
|
|
198
248
|
|
|
199
|
-
The gem is available as open source under the terms of the [MIT License](https://github.com/
|
|
249
|
+
The gem is available as open source under the terms of the [MIT License](https://github.com/enjaku4/grepfruit/blob/master/LICENSE.txt).
|
|
200
250
|
|
|
201
251
|
## Code of Conduct
|
|
202
252
|
|
|
203
|
-
Everyone interacting in the Grepfruit project is expected to follow the [code of conduct](https://github.com/
|
|
253
|
+
Everyone interacting in the Grepfruit project is expected to follow the [code of conduct](https://github.com/enjaku4/grepfruit/blob/master/CODE_OF_CONDUCT.md).
|
data/grepfruit.gemspec
CHANGED
|
@@ -4,14 +4,19 @@ Gem::Specification.new do |spec|
|
|
|
4
4
|
spec.name = "grepfruit"
|
|
5
5
|
spec.version = Grepfruit::VERSION
|
|
6
6
|
spec.authors = ["enjaku4"]
|
|
7
|
-
spec.
|
|
7
|
+
spec.email = ["contact@brownbox.dev"]
|
|
8
|
+
spec.homepage = "https://github.com/enjaku4/grepfruit"
|
|
8
9
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
9
10
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
10
11
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
|
12
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
13
|
+
spec.metadata["documentation_uri"] = "#{spec.homepage}/blob/master/README.md"
|
|
14
|
+
spec.metadata["mailing_list_uri"] = "#{spec.homepage}/discussions"
|
|
11
15
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
12
|
-
spec.summary = "
|
|
16
|
+
spec.summary = "Text search tool"
|
|
17
|
+
spec.description = "A tool for searching regex patterns in files with a CI/CD-friendly CLI and a programmatic Ruby API"
|
|
13
18
|
spec.license = "MIT"
|
|
14
|
-
spec.required_ruby_version = ">= 3.2", "<
|
|
19
|
+
spec.required_ruby_version = ">= 3.2", "< 4.1"
|
|
15
20
|
|
|
16
21
|
spec.files = [
|
|
17
22
|
"grepfruit.gemspec", "README.md", "CHANGELOG.md", "LICENSE.txt"
|
data/lib/grepfruit/cli.rb
CHANGED
|
@@ -16,35 +16,33 @@ module Grepfruit
|
|
|
16
16
|
option :search_hidden, type: :flag, default: false, desc: "Search hidden files and directories"
|
|
17
17
|
option :jobs, aliases: ["-j"], type: :integer, desc: "Number of parallel workers (default: all CPU cores, use 1 for sequential)"
|
|
18
18
|
option :json, type: :flag, default: false, desc: "Output results in JSON format"
|
|
19
|
+
option :count, aliases: ["-c"], type: :flag, default: false, desc: "Show only counts, not match details"
|
|
19
20
|
|
|
20
21
|
def call(path: ".", **options)
|
|
21
|
-
|
|
22
|
+
error_exit("You must specify a regex pattern using the -r or --regex option.") unless options[:regex]
|
|
23
|
+
error_exit("Jobs must be at least 1") if options[:jobs] && options[:jobs].to_i < 1
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
regex_pattern = Regexp.new(options[:regex])
|
|
27
|
+
rescue RegexpError => e
|
|
28
|
+
error_exit("Invalid regex pattern - #{e.message}")
|
|
29
|
+
end
|
|
22
30
|
|
|
23
|
-
Grepfruit::
|
|
24
|
-
|
|
25
|
-
regex:
|
|
31
|
+
Grepfruit::CliSearch.new(
|
|
32
|
+
path: path,
|
|
33
|
+
regex: regex_pattern,
|
|
26
34
|
exclude: options[:exclude] || [],
|
|
27
35
|
include: options[:include] || [],
|
|
28
36
|
truncate: options[:truncate]&.to_i,
|
|
29
37
|
search_hidden: options[:search_hidden],
|
|
30
38
|
jobs: options[:jobs]&.to_i,
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
json: options[:json],
|
|
40
|
+
count: options[:count]
|
|
41
|
+
).execute
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
private
|
|
36
45
|
|
|
37
|
-
def validate_options!(options)
|
|
38
|
-
error_exit("You must specify a regex pattern using the -r or --regex option.") unless options[:regex]
|
|
39
|
-
error_exit("Number of jobs must be at least 1") if (jobs = options[:jobs]&.to_i) && jobs < 1
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def create_regex(pattern)
|
|
43
|
-
Regexp.new(pattern)
|
|
44
|
-
rescue RegexpError => e
|
|
45
|
-
error_exit("Invalid regex pattern - #{e.message}")
|
|
46
|
-
end
|
|
47
|
-
|
|
48
46
|
def error_exit(message)
|
|
49
47
|
puts "Error: #{message}"
|
|
50
48
|
exit 1
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Grepfruit
|
|
2
|
+
module CliDecorator
|
|
3
|
+
COLORS = { cyan: "\e[36m", red: "\e[31m", green: "\e[32m", reset: "\e[0m" }.freeze
|
|
4
|
+
|
|
5
|
+
private
|
|
6
|
+
|
|
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)
|
|
11
|
+
|
|
12
|
+
def number_of_files(num) = "#{num} file#{'s' if num != 1}"
|
|
13
|
+
def number_of_matches(num) = "#{num} match#{'es' if num != 1}"
|
|
14
|
+
|
|
15
|
+
def display_results(results)
|
|
16
|
+
puts "" if results.total_files.positive?
|
|
17
|
+
|
|
18
|
+
if results.match_count.zero?
|
|
19
|
+
puts "#{number_of_files(results.total_files)} checked, #{green('no matches found')}"
|
|
20
|
+
else
|
|
21
|
+
unless count
|
|
22
|
+
puts "Matches:\n\n"
|
|
23
|
+
results.raw_matches.each do |line_info|
|
|
24
|
+
puts "#{cyan("#{line_info[0]}:#{line_info[1]}")}: #{processed_line(line_info[2])}"
|
|
25
|
+
end
|
|
26
|
+
puts ""
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
puts "#{number_of_files(results.total_files)} checked, #{red("#{number_of_matches(results.match_count)} found in #{number_of_files(results.total_files_with_matches)}")}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def display_json_results(result_hash)
|
|
34
|
+
require "json"
|
|
35
|
+
|
|
36
|
+
result_hash[:search][:pattern] = result_hash[:search][:pattern].inspect
|
|
37
|
+
result_hash[:search][:timestamp] = Time.now.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
38
|
+
|
|
39
|
+
puts JSON.pretty_generate(result_hash)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative "cli_decorator"
|
|
2
|
+
|
|
3
|
+
module Grepfruit
|
|
4
|
+
class CliSearch < Search
|
|
5
|
+
include Grepfruit::CliDecorator
|
|
6
|
+
|
|
7
|
+
def execute
|
|
8
|
+
puts "Error: Directory '#{path}' does not exist." and exit 1 unless File.exist?(path)
|
|
9
|
+
|
|
10
|
+
puts "Searching for #{regex.inspect} in #{path.inspect}..." unless json
|
|
11
|
+
|
|
12
|
+
results = execute_search
|
|
13
|
+
|
|
14
|
+
if json
|
|
15
|
+
display_json_results(build_result_hash(results))
|
|
16
|
+
else
|
|
17
|
+
display_results(results)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
exit(results.match_count.positive? ? 1 : 0)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Grepfruit
|
|
2
|
+
module RactorCompat
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def send_work(worker, data)
|
|
6
|
+
worker.send(data)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
if defined?(::Ractor::Port)
|
|
10
|
+
def create_worker(&)
|
|
11
|
+
port = Ractor::Port.new
|
|
12
|
+
worker = Ractor.new(port, &)
|
|
13
|
+
[worker, port]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def yield_result(port, data)
|
|
17
|
+
port << data
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def select_ready(workers_and_ports)
|
|
21
|
+
ports = workers_and_ports.values
|
|
22
|
+
ready_port, result = Ractor.select(*ports)
|
|
23
|
+
worker = workers_and_ports.key(ready_port)
|
|
24
|
+
[worker, result]
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
def create_worker(&)
|
|
28
|
+
worker = Ractor.new(&)
|
|
29
|
+
[worker, nil]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def yield_result(_port, data)
|
|
33
|
+
Ractor.yield(data)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def select_ready(workers_and_ports)
|
|
37
|
+
workers = workers_and_ports.keys
|
|
38
|
+
ready_worker, result = Ractor.select(*workers)
|
|
39
|
+
[ready_worker, result]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/grepfruit/search.rb
CHANGED
|
@@ -1,93 +1,122 @@
|
|
|
1
1
|
require "find"
|
|
2
2
|
require "etc"
|
|
3
|
+
require_relative "ractor_compat"
|
|
3
4
|
|
|
4
5
|
Warning[:experimental] = false
|
|
5
6
|
|
|
6
7
|
module Grepfruit
|
|
7
8
|
class Search
|
|
8
|
-
|
|
9
|
+
attr_reader :path, :regex, :exclusions, :inclusions, :excluded_paths, :excluded_lines, :included_paths, :truncate, :search_hidden, :jobs, :json, :count
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def initialize(dir:, regex:, exclude:, include:, truncate:, search_hidden:, jobs:, json_output: false)
|
|
13
|
-
@dir = File.expand_path(dir)
|
|
11
|
+
def initialize(path:, regex:, exclude:, include:, truncate:, search_hidden:, jobs:, json: false, count: false)
|
|
12
|
+
@path = File.expand_path(path)
|
|
14
13
|
@regex = regex
|
|
14
|
+
@exclusions = exclude
|
|
15
|
+
@inclusions = include
|
|
15
16
|
@excluded_lines, @excluded_paths = exclude.map { _1.split("/") }.partition { _1.last.include?(":") }
|
|
16
17
|
@included_paths = include.map { _1.split("/") }
|
|
17
18
|
@truncate = truncate
|
|
18
19
|
@search_hidden = search_hidden
|
|
19
20
|
@jobs = jobs || Etc.nprocessors
|
|
20
|
-
@
|
|
21
|
+
@json = json
|
|
22
|
+
@count = count
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
puts "Searching for #{regex.inspect} in #{dir.inspect}...\n\n" unless json_output
|
|
25
|
+
private
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
def build_result_hash(results)
|
|
28
|
+
result_hash = {
|
|
29
|
+
search: {
|
|
30
|
+
pattern: regex,
|
|
31
|
+
directory: path,
|
|
32
|
+
exclusions: exclusions,
|
|
33
|
+
inclusions: inclusions
|
|
34
|
+
},
|
|
35
|
+
summary: {
|
|
36
|
+
files_checked: results.total_files,
|
|
37
|
+
files_with_matches: results.total_files_with_matches,
|
|
38
|
+
total_matches: results.match_count
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
unless count
|
|
43
|
+
result_hash[:matches] = results.raw_matches.map do |relative_path, line_num, line_content|
|
|
44
|
+
{
|
|
45
|
+
file: relative_path,
|
|
46
|
+
line: line_num,
|
|
47
|
+
content: processed_line(line_content)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
28
51
|
|
|
29
|
-
|
|
52
|
+
result_hash
|
|
53
|
+
end
|
|
30
54
|
|
|
31
55
|
def execute_search
|
|
32
56
|
results = SearchResults.new
|
|
33
|
-
|
|
57
|
+
workers_and_ports = Array.new(jobs) { create_persistent_worker }
|
|
34
58
|
file_enumerator = create_file_enumerator
|
|
35
59
|
active_workers = {}
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
assign_file_to_worker(worker, file_enumerator, active_workers, results)
|
|
61
|
+
workers_and_ports.each do |worker, port|
|
|
62
|
+
assign_file_to_worker(worker, port, file_enumerator, active_workers, results)
|
|
39
63
|
end
|
|
40
64
|
|
|
41
65
|
while active_workers.any?
|
|
42
|
-
ready_worker, worker_result =
|
|
43
|
-
active_workers.delete(ready_worker)
|
|
66
|
+
ready_worker, worker_result = RactorCompat.select_ready(active_workers)
|
|
67
|
+
port = active_workers.delete(ready_worker)
|
|
44
68
|
|
|
45
69
|
results.increment_files_with_matches if process_worker_result(worker_result, results)
|
|
46
|
-
assign_file_to_worker(ready_worker, file_enumerator, active_workers, results)
|
|
70
|
+
assign_file_to_worker(ready_worker, port, file_enumerator, active_workers, results)
|
|
47
71
|
end
|
|
48
72
|
|
|
49
|
-
shutdown_workers(
|
|
73
|
+
shutdown_workers(workers_and_ports.map(&:first))
|
|
74
|
+
|
|
50
75
|
results
|
|
51
76
|
end
|
|
52
77
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
def process_worker_result(worker_result, results)
|
|
79
|
+
file_results, has_matches, match_count = worker_result
|
|
80
|
+
|
|
81
|
+
return false unless has_matches
|
|
82
|
+
|
|
83
|
+
results.add_match_count(match_count)
|
|
84
|
+
results.add_raw_matches(file_results)
|
|
85
|
+
|
|
86
|
+
true
|
|
59
87
|
end
|
|
60
88
|
|
|
61
89
|
def create_persistent_worker
|
|
62
|
-
|
|
90
|
+
RactorCompat.create_worker do |port|
|
|
63
91
|
loop do
|
|
64
92
|
work = Ractor.receive
|
|
65
93
|
break if work == :quit
|
|
66
94
|
|
|
67
|
-
file_path, pattern, exc_lines,
|
|
68
|
-
file_results, has_matches = [], false
|
|
95
|
+
file_path, pattern, exc_lines, base_path, count = work
|
|
96
|
+
file_results, has_matches, match_count = [], false, 0
|
|
97
|
+
relative_path = file_path.delete_prefix("#{base_path}/")
|
|
69
98
|
|
|
70
99
|
File.foreach(file_path).with_index do |line, line_num|
|
|
71
100
|
next unless line.valid_encoding? && line.match?(pattern)
|
|
72
101
|
|
|
73
|
-
relative_path = file_path.delete_prefix("#{base_dir}/")
|
|
74
102
|
next if exc_lines.any? { "#{relative_path}:#{line_num + 1}".end_with?(_1.join("/")) }
|
|
75
103
|
|
|
76
|
-
file_results << [relative_path, line_num + 1, line]
|
|
104
|
+
file_results << [relative_path, line_num + 1, line] unless count
|
|
77
105
|
has_matches = true
|
|
106
|
+
match_count += 1
|
|
78
107
|
end
|
|
79
108
|
|
|
80
|
-
|
|
109
|
+
RactorCompat.yield_result(port, [file_results, has_matches, match_count])
|
|
81
110
|
end
|
|
82
111
|
end
|
|
83
112
|
end
|
|
84
113
|
|
|
85
|
-
def assign_file_to_worker(worker, file_enumerator, active_workers, results)
|
|
114
|
+
def assign_file_to_worker(worker, port, file_enumerator, active_workers, results)
|
|
86
115
|
file_path = get_next_file(file_enumerator)
|
|
87
116
|
return unless file_path
|
|
88
117
|
|
|
89
|
-
|
|
90
|
-
active_workers[worker] =
|
|
118
|
+
RactorCompat.send_work(worker, [file_path, regex, excluded_lines, path, count])
|
|
119
|
+
active_workers[worker] = port
|
|
91
120
|
results.total_files += 1
|
|
92
121
|
end
|
|
93
122
|
|
|
@@ -99,50 +128,26 @@ module Grepfruit
|
|
|
99
128
|
|
|
100
129
|
def shutdown_workers(workers)
|
|
101
130
|
workers.each { |worker| worker.send(:quit) }
|
|
102
|
-
workers.each(&:close_outgoing)
|
|
103
131
|
end
|
|
104
132
|
|
|
105
133
|
def create_file_enumerator
|
|
106
134
|
Enumerator.new do |yielder|
|
|
107
|
-
Find.find(
|
|
108
|
-
Find.prune if excluded_path?(
|
|
109
|
-
|
|
110
|
-
next unless File.file?(path)
|
|
111
|
-
|
|
112
|
-
yielder << path
|
|
113
|
-
end
|
|
114
|
-
rescue Errno::ENOENT
|
|
115
|
-
puts "Error: Directory '#{dir}' does not exist."
|
|
116
|
-
exit 1
|
|
117
|
-
end
|
|
118
|
-
end
|
|
135
|
+
Find.find(path) do |file_path|
|
|
136
|
+
Find.prune if excluded_path?(file_path)
|
|
119
137
|
|
|
120
|
-
|
|
121
|
-
file_results, has_matches = worker_result
|
|
122
|
-
|
|
123
|
-
if has_matches
|
|
124
|
-
results.add_raw_matches(file_results) if json_output
|
|
138
|
+
next unless File.file?(file_path)
|
|
125
139
|
|
|
126
|
-
|
|
127
|
-
colored_lines = file_results.map do |relative_path, line_num, line_content|
|
|
128
|
-
"#{cyan("#{relative_path}:#{line_num}")}: #{processed_line(line_content)}"
|
|
129
|
-
end
|
|
130
|
-
results.add_lines(colored_lines)
|
|
131
|
-
print red("M")
|
|
140
|
+
yielder << file_path
|
|
132
141
|
end
|
|
133
|
-
else
|
|
134
|
-
print green(".") unless json_output
|
|
135
142
|
end
|
|
136
|
-
|
|
137
|
-
has_matches
|
|
138
143
|
end
|
|
139
144
|
|
|
140
|
-
def excluded_path?(
|
|
141
|
-
rel_path =
|
|
145
|
+
def excluded_path?(file_path)
|
|
146
|
+
rel_path = file_path.delete_prefix("#{path}/")
|
|
142
147
|
|
|
143
|
-
(File.file?(
|
|
148
|
+
(File.file?(file_path) && included_paths.any? && !matches_pattern?(included_paths, rel_path)) ||
|
|
144
149
|
matches_pattern?(excluded_paths, rel_path) ||
|
|
145
|
-
(!search_hidden && File.basename(
|
|
150
|
+
(!search_hidden && File.basename(file_path).start_with?("."))
|
|
146
151
|
end
|
|
147
152
|
|
|
148
153
|
def matches_pattern?(pattern_list, path)
|
|
@@ -151,5 +156,10 @@ module Grepfruit
|
|
|
151
156
|
File.fnmatch?(pattern, path, File::FNM_PATHNAME) || File.fnmatch?(pattern, File.basename(path))
|
|
152
157
|
end
|
|
153
158
|
end
|
|
159
|
+
|
|
160
|
+
def processed_line(line)
|
|
161
|
+
stripped = line.strip
|
|
162
|
+
truncate && stripped.length > truncate ? "#{stripped[0...truncate]}..." : stripped
|
|
163
|
+
end
|
|
154
164
|
end
|
|
155
165
|
end
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
module Grepfruit
|
|
2
2
|
class SearchResults
|
|
3
|
-
attr_reader :
|
|
3
|
+
attr_reader :raw_matches, :total_files_with_matches, :match_count
|
|
4
4
|
attr_accessor :total_files
|
|
5
5
|
|
|
6
6
|
def initialize
|
|
7
|
-
@all_lines = []
|
|
8
7
|
@raw_matches = []
|
|
9
8
|
@total_files = 0
|
|
10
9
|
@total_files_with_matches = 0
|
|
10
|
+
@match_count = 0
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def increment_files_with_matches
|
|
14
14
|
@total_files_with_matches += 1
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def
|
|
18
|
-
@
|
|
17
|
+
def add_match_count(count)
|
|
18
|
+
@match_count += count
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def add_raw_matches(matches)
|
data/lib/grepfruit/version.rb
CHANGED
data/lib/grepfruit.rb
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
require_relative "grepfruit/version"
|
|
2
|
-
require_relative "grepfruit/decorator"
|
|
3
2
|
require_relative "grepfruit/search_results"
|
|
4
3
|
require_relative "grepfruit/search"
|
|
4
|
+
require_relative "grepfruit/programmatic_search"
|
|
5
5
|
require_relative "grepfruit/cli"
|
|
6
|
+
require_relative "grepfruit/cli_search"
|
|
6
7
|
|
|
7
8
|
module Grepfruit
|
|
8
|
-
|
|
9
|
+
def self.search(regex:, path: ".", exclude: [], include: [], truncate: nil, search_hidden: false, jobs: nil, count: false)
|
|
10
|
+
raise ArgumentError, "regex is required" unless regex.is_a?(Regexp)
|
|
11
|
+
raise ArgumentError, "path must be a string" unless path.is_a?(String)
|
|
12
|
+
raise ArgumentError, "exclude must be an array" unless exclude.is_a?(Array)
|
|
13
|
+
raise ArgumentError, "include must be an array" unless include.is_a?(Array)
|
|
14
|
+
raise ArgumentError, "truncate must be a positive integer" if truncate && (!truncate.is_a?(Integer) || truncate <= 0)
|
|
15
|
+
raise ArgumentError, "search_hidden must be a boolean" unless [true, false].include?(search_hidden)
|
|
16
|
+
raise ArgumentError, "count must be a boolean" unless [true, false].include?(count)
|
|
17
|
+
raise ArgumentError, "jobs must be at least 1" if jobs && (!jobs.is_a?(Integer) || jobs < 1)
|
|
18
|
+
|
|
19
|
+
ProgrammaticSearch.new(
|
|
20
|
+
path: path,
|
|
21
|
+
regex: regex,
|
|
22
|
+
exclude: exclude,
|
|
23
|
+
include: include,
|
|
24
|
+
truncate: truncate,
|
|
25
|
+
search_hidden: search_hidden,
|
|
26
|
+
jobs: jobs,
|
|
27
|
+
json: false,
|
|
28
|
+
count: count
|
|
29
|
+
).execute
|
|
30
|
+
end
|
|
9
31
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: grepfruit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- enjaku4
|
|
@@ -23,6 +23,10 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '1.1'
|
|
26
|
+
description: A tool for searching regex patterns in files with a CI/CD-friendly CLI
|
|
27
|
+
and a programmatic Ruby API
|
|
28
|
+
email:
|
|
29
|
+
- contact@brownbox.dev
|
|
26
30
|
executables:
|
|
27
31
|
- grepfruit
|
|
28
32
|
extensions: []
|
|
@@ -35,17 +39,23 @@ files:
|
|
|
35
39
|
- grepfruit.gemspec
|
|
36
40
|
- lib/grepfruit.rb
|
|
37
41
|
- lib/grepfruit/cli.rb
|
|
38
|
-
- lib/grepfruit/
|
|
42
|
+
- lib/grepfruit/cli_decorator.rb
|
|
43
|
+
- lib/grepfruit/cli_search.rb
|
|
44
|
+
- lib/grepfruit/programmatic_search.rb
|
|
45
|
+
- lib/grepfruit/ractor_compat.rb
|
|
39
46
|
- lib/grepfruit/search.rb
|
|
40
47
|
- lib/grepfruit/search_results.rb
|
|
41
48
|
- lib/grepfruit/version.rb
|
|
42
|
-
homepage: https://github.com/
|
|
49
|
+
homepage: https://github.com/enjaku4/grepfruit
|
|
43
50
|
licenses:
|
|
44
51
|
- MIT
|
|
45
52
|
metadata:
|
|
46
|
-
homepage_uri: https://github.com/
|
|
47
|
-
source_code_uri: https://github.com/
|
|
48
|
-
changelog_uri: https://github.com/
|
|
53
|
+
homepage_uri: https://github.com/enjaku4/grepfruit
|
|
54
|
+
source_code_uri: https://github.com/enjaku4/grepfruit
|
|
55
|
+
changelog_uri: https://github.com/enjaku4/grepfruit/blob/master/CHANGELOG.md
|
|
56
|
+
bug_tracker_uri: https://github.com/enjaku4/grepfruit/issues
|
|
57
|
+
documentation_uri: https://github.com/enjaku4/grepfruit/blob/master/README.md
|
|
58
|
+
mailing_list_uri: https://github.com/enjaku4/grepfruit/discussions
|
|
49
59
|
rubygems_mfa_required: 'true'
|
|
50
60
|
rdoc_options: []
|
|
51
61
|
require_paths:
|
|
@@ -57,14 +67,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
57
67
|
version: '3.2'
|
|
58
68
|
- - "<"
|
|
59
69
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '
|
|
70
|
+
version: '4.1'
|
|
61
71
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
62
72
|
requirements:
|
|
63
73
|
- - ">="
|
|
64
74
|
- !ruby/object:Gem::Version
|
|
65
75
|
version: '0'
|
|
66
76
|
requirements: []
|
|
67
|
-
rubygems_version:
|
|
77
|
+
rubygems_version: 4.0.0
|
|
68
78
|
specification_version: 4
|
|
69
|
-
summary:
|
|
79
|
+
summary: Text search tool
|
|
70
80
|
test_files: []
|
data/lib/grepfruit/decorator.rb
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
module Grepfruit
|
|
2
|
-
module Decorator
|
|
3
|
-
COLORS = { cyan: "\e[36m", red: "\e[31m", green: "\e[32m", reset: "\e[0m" }.freeze
|
|
4
|
-
|
|
5
|
-
private
|
|
6
|
-
|
|
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)
|
|
11
|
-
|
|
12
|
-
def number_of_files(num) = "#{num} file#{'s' if num != 1}"
|
|
13
|
-
def number_of_matches(num) = "#{num} match#{'es' if num != 1}"
|
|
14
|
-
|
|
15
|
-
def relative_path(path)
|
|
16
|
-
path.delete_prefix("#{dir}/")
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def processed_line(line)
|
|
20
|
-
stripped = line.strip
|
|
21
|
-
truncate && stripped.length > truncate ? "#{stripped[0...truncate]}..." : stripped
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def display_results(lines, files, files_with_matches)
|
|
25
|
-
puts "\n\n" if files.positive?
|
|
26
|
-
|
|
27
|
-
if lines.empty?
|
|
28
|
-
puts "#{number_of_files(files)} checked, #{green('no matches found')}"
|
|
29
|
-
exit(0)
|
|
30
|
-
else
|
|
31
|
-
puts "Matches:\n\n#{lines.join("\n")}\n\n"
|
|
32
|
-
puts "#{number_of_files(files)} checked, #{red("#{number_of_matches(lines.size)} found in #{number_of_files(files_with_matches)}")}"
|
|
33
|
-
exit(1)
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def display_json_results(raw_matches, total_files, files_with_matches)
|
|
38
|
-
require "json"
|
|
39
|
-
|
|
40
|
-
search_info = {
|
|
41
|
-
pattern: regex.inspect,
|
|
42
|
-
directory: dir,
|
|
43
|
-
exclusions: (excluded_paths + excluded_lines).map { |path_parts| path_parts.join("/") },
|
|
44
|
-
inclusions: included_paths.map { |path_parts| path_parts.join("/") },
|
|
45
|
-
timestamp: Time.now.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
summary = {
|
|
49
|
-
files_checked: total_files,
|
|
50
|
-
files_with_matches: files_with_matches,
|
|
51
|
-
total_matches: raw_matches.size
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
matches = raw_matches.map do |relative_path, line_num, line_content|
|
|
55
|
-
{
|
|
56
|
-
file: relative_path,
|
|
57
|
-
line: line_num,
|
|
58
|
-
content: line_content.strip
|
|
59
|
-
}
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
result = {
|
|
63
|
-
search: search_info,
|
|
64
|
-
summary: summary,
|
|
65
|
-
matches: matches
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
puts JSON.pretty_generate(result)
|
|
69
|
-
|
|
70
|
-
exit(raw_matches.empty? ? 0 : 1)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|