grepfruit 3.1.3 → 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 +6 -0
- data/README.md +58 -11
- data/grepfruit.gemspec +5 -4
- 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 -64
- data/lib/grepfruit/search_results.rb +4 -4
- data/lib/grepfruit/version.rb +1 -1
- data/lib/grepfruit.rb +24 -2
- metadata +12 -8
- 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
data/README.md
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
# Grepfruit:
|
|
1
|
+
# Grepfruit: Text Search Tool
|
|
2
2
|
|
|
3
3
|
[](http://badge.fury.io/rb/grepfruit)
|
|
4
4
|
[](https://rubygems.org/gems/grepfruit)
|
|
5
5
|
[](https://github.com/enjaku4/grepfruit/actions/workflows/ci.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
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.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
**Key Features:**
|
|
13
|
-
|
|
14
|
-
- Colorized output for improved readability
|
|
15
|
-
- JSON output format for programmatic integration
|
|
16
|
-
- 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" />
|
|
17
12
|
|
|
18
13
|
## Table of Contents
|
|
19
14
|
|
|
@@ -23,6 +18,7 @@ Originally designed for CI/CD pipelines to search for `TODO` comments in Ruby ap
|
|
|
23
18
|
- [Command Line Options](#command-line-options)
|
|
24
19
|
- [Usage Examples](#usage-examples)
|
|
25
20
|
- [Exit Status](#exit-status)
|
|
21
|
+
- [Programmatic API](#programmatic-api)
|
|
26
22
|
|
|
27
23
|
**Community Resources:**
|
|
28
24
|
- [Getting Help and Contributing](#getting-help-and-contributing)
|
|
@@ -33,7 +29,7 @@ Originally designed for CI/CD pipelines to search for `TODO` comments in Ruby ap
|
|
|
33
29
|
|
|
34
30
|
Install the gem:
|
|
35
31
|
|
|
36
|
-
```
|
|
32
|
+
```shell
|
|
37
33
|
gem install grepfruit
|
|
38
34
|
```
|
|
39
35
|
|
|
@@ -62,6 +58,7 @@ If no PATH is specified, Grepfruit searches the current directory.
|
|
|
62
58
|
| `-i, --include x,y,z` | Comma-separated list of file patterns to include (only these files will be searched) |
|
|
63
59
|
| `-t, --truncate N` | Truncate search result output to N characters |
|
|
64
60
|
| `-j, --jobs N` | Number of parallel workers (default: number of CPU cores) |
|
|
61
|
+
| `-c, --count` | Show only match counts, not match details |
|
|
65
62
|
| `--search-hidden` | Include hidden files and directories in search |
|
|
66
63
|
| `--json` | Output results in JSON format |
|
|
67
64
|
|
|
@@ -159,9 +156,18 @@ This outputs a JSON response containing search metadata, summary statistics, and
|
|
|
159
156
|
}
|
|
160
157
|
```
|
|
161
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
|
+
|
|
162
168
|
### Parallel Processing
|
|
163
169
|
|
|
164
|
-
Control the number of parallel workers:
|
|
170
|
+
Grepfruit uses ractors for true parallel processing across CPU cores. Control the number of parallel workers:
|
|
165
171
|
|
|
166
172
|
```bash
|
|
167
173
|
grepfruit search -r 'TODO' -j 8 # Use 8 parallel workers
|
|
@@ -175,6 +181,47 @@ Grepfruit returns meaningful exit codes for CI/CD integration:
|
|
|
175
181
|
- **Exit code 0**: No matches found (ideal for quality gates - code is clean)
|
|
176
182
|
- **Exit code 1**: Pattern matches were found (CI should fail - issues detected)
|
|
177
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
|
+
|
|
178
225
|
## Getting Help and Contributing
|
|
179
226
|
|
|
180
227
|
### Getting Help
|
data/grepfruit.gemspec
CHANGED
|
@@ -4,18 +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.email = ["
|
|
7
|
+
spec.email = ["contact@brownbox.dev"]
|
|
8
8
|
spec.homepage = "https://github.com/enjaku4/grepfruit"
|
|
9
9
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
10
10
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
11
11
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
|
|
12
12
|
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
13
13
|
spec.metadata["documentation_uri"] = "#{spec.homepage}/blob/master/README.md"
|
|
14
|
+
spec.metadata["mailing_list_uri"] = "#{spec.homepage}/discussions"
|
|
14
15
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
15
|
-
spec.summary = "
|
|
16
|
-
spec.description = "
|
|
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"
|
|
17
18
|
spec.license = "MIT"
|
|
18
|
-
spec.required_ruby_version = ">= 3.2", "<
|
|
19
|
+
spec.required_ruby_version = ">= 3.2", "< 4.1"
|
|
19
20
|
|
|
20
21
|
spec.files = [
|
|
21
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
|
|
|
@@ -103,45 +132,22 @@ module Grepfruit
|
|
|
103
132
|
|
|
104
133
|
def create_file_enumerator
|
|
105
134
|
Enumerator.new do |yielder|
|
|
106
|
-
Find.find(
|
|
107
|
-
Find.prune if excluded_path?(
|
|
108
|
-
|
|
109
|
-
next unless File.file?(path)
|
|
110
|
-
|
|
111
|
-
yielder << path
|
|
112
|
-
end
|
|
113
|
-
rescue Errno::ENOENT
|
|
114
|
-
puts "Error: Directory '#{dir}' does not exist."
|
|
115
|
-
exit 1
|
|
116
|
-
end
|
|
117
|
-
end
|
|
135
|
+
Find.find(path) do |file_path|
|
|
136
|
+
Find.prune if excluded_path?(file_path)
|
|
118
137
|
|
|
119
|
-
|
|
120
|
-
file_results, has_matches = worker_result
|
|
121
|
-
|
|
122
|
-
if has_matches
|
|
123
|
-
results.add_raw_matches(file_results) if json_output
|
|
138
|
+
next unless File.file?(file_path)
|
|
124
139
|
|
|
125
|
-
|
|
126
|
-
colored_lines = file_results.map do |relative_path, line_num, line_content|
|
|
127
|
-
"#{cyan("#{relative_path}:#{line_num}")}: #{processed_line(line_content)}"
|
|
128
|
-
end
|
|
129
|
-
results.add_lines(colored_lines)
|
|
130
|
-
print red("M")
|
|
140
|
+
yielder << file_path
|
|
131
141
|
end
|
|
132
|
-
else
|
|
133
|
-
print green(".") unless json_output
|
|
134
142
|
end
|
|
135
|
-
|
|
136
|
-
has_matches
|
|
137
143
|
end
|
|
138
144
|
|
|
139
|
-
def excluded_path?(
|
|
140
|
-
rel_path =
|
|
145
|
+
def excluded_path?(file_path)
|
|
146
|
+
rel_path = file_path.delete_prefix("#{path}/")
|
|
141
147
|
|
|
142
|
-
(File.file?(
|
|
148
|
+
(File.file?(file_path) && included_paths.any? && !matches_pattern?(included_paths, rel_path)) ||
|
|
143
149
|
matches_pattern?(excluded_paths, rel_path) ||
|
|
144
|
-
(!search_hidden && File.basename(
|
|
150
|
+
(!search_hidden && File.basename(file_path).start_with?("."))
|
|
145
151
|
end
|
|
146
152
|
|
|
147
153
|
def matches_pattern?(pattern_list, path)
|
|
@@ -150,5 +156,10 @@ module Grepfruit
|
|
|
150
156
|
File.fnmatch?(pattern, path, File::FNM_PATHNAME) || File.fnmatch?(pattern, File.basename(path))
|
|
151
157
|
end
|
|
152
158
|
end
|
|
159
|
+
|
|
160
|
+
def processed_line(line)
|
|
161
|
+
stripped = line.strip
|
|
162
|
+
truncate && stripped.length > truncate ? "#{stripped[0...truncate]}..." : stripped
|
|
163
|
+
end
|
|
153
164
|
end
|
|
154
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,10 +23,10 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '1.1'
|
|
26
|
-
description:
|
|
27
|
-
|
|
26
|
+
description: A tool for searching regex patterns in files with a CI/CD-friendly CLI
|
|
27
|
+
and a programmatic Ruby API
|
|
28
28
|
email:
|
|
29
|
-
-
|
|
29
|
+
- contact@brownbox.dev
|
|
30
30
|
executables:
|
|
31
31
|
- grepfruit
|
|
32
32
|
extensions: []
|
|
@@ -39,7 +39,10 @@ files:
|
|
|
39
39
|
- grepfruit.gemspec
|
|
40
40
|
- lib/grepfruit.rb
|
|
41
41
|
- lib/grepfruit/cli.rb
|
|
42
|
-
- 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
|
|
43
46
|
- lib/grepfruit/search.rb
|
|
44
47
|
- lib/grepfruit/search_results.rb
|
|
45
48
|
- lib/grepfruit/version.rb
|
|
@@ -52,6 +55,7 @@ metadata:
|
|
|
52
55
|
changelog_uri: https://github.com/enjaku4/grepfruit/blob/master/CHANGELOG.md
|
|
53
56
|
bug_tracker_uri: https://github.com/enjaku4/grepfruit/issues
|
|
54
57
|
documentation_uri: https://github.com/enjaku4/grepfruit/blob/master/README.md
|
|
58
|
+
mailing_list_uri: https://github.com/enjaku4/grepfruit/discussions
|
|
55
59
|
rubygems_mfa_required: 'true'
|
|
56
60
|
rdoc_options: []
|
|
57
61
|
require_paths:
|
|
@@ -63,14 +67,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
63
67
|
version: '3.2'
|
|
64
68
|
- - "<"
|
|
65
69
|
- !ruby/object:Gem::Version
|
|
66
|
-
version: '
|
|
70
|
+
version: '4.1'
|
|
67
71
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
72
|
requirements:
|
|
69
73
|
- - ">="
|
|
70
74
|
- !ruby/object:Gem::Version
|
|
71
75
|
version: '0'
|
|
72
76
|
requirements: []
|
|
73
|
-
rubygems_version:
|
|
77
|
+
rubygems_version: 4.0.0
|
|
74
78
|
specification_version: 4
|
|
75
|
-
summary:
|
|
79
|
+
summary: Text search tool
|
|
76
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
|