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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c2c091bc89ae552d6393fd2314bc30fa6f858b426d7690145aa54d6f2c9ef7f
4
- data.tar.gz: 5920dfde71947661dc0ec33318dcb6679f9b899c03bd42d6b61654503435dfbf
3
+ metadata.gz: 505fa3f2e5b4b5ffc41a795f0305e6a37f913b7fe66036018d4f4438dc6c9b40
4
+ data.tar.gz: 3f89449fd5ab9d2851aadac1ec432606aeaae73b4673bd0d4e62cd96b4bdc83f
5
5
  SHA512:
6
- metadata.gz: 1dd7f640198928a13d1e118771a6c47986c753a5e997b8fc53fce5ac2b9b8135ce30986eb9a6d5141600f7985a461c59c24b41c611c8a55f2d7050bb6d0e2d4f
7
- data.tar.gz: ae7d0e5a09554f03accf9f144a0e9448044728e53a22228ac9dd8cf343a75814cc831ef28a58b75e64af35d3aa50e801945e440d9c607fdfd82e11d889e66996
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: Enhanced File Pattern Search Tool
1
+ # Grepfruit: Text Search Tool
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/grepfruit.svg)](http://badge.fury.io/rb/grepfruit)
4
- [![Github Actions badge](https://github.com/brownboxdev/grepfruit/actions/workflows/ci.yml/badge.svg)](https://github.com/brownboxdev/grepfruit/actions/workflows/ci.yml)
4
+ [![Downloads](https://img.shields.io/gem/dt/grepfruit.svg)](https://rubygems.org/gems/grepfruit)
5
+ [![Github Actions badge](https://github.com/enjaku4/grepfruit/actions/workflows/ci.yml/badge.svg)](https://github.com/enjaku4/grepfruit/actions/workflows/ci.yml)
6
+ [![License](https://img.shields.io/github/license/enjaku4/grepfruit.svg)](LICENSE)
5
7
 
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.
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
- **Key Features:**
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
- ```bash
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/brownboxdev/grepfruit/discussions) for:
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/brownboxdev/grepfruit/issues) with:
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/brownboxdev/grepfruit/discussions))
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/brownboxdev/grepfruit/blob/master/CONTRIBUTING.md)
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/brownboxdev/grepfruit/blob/master/LICENSE.txt).
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/brownboxdev/grepfruit/blob/master/CODE_OF_CONDUCT.md).
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.homepage = "https://github.com/brownboxdev/grepfruit"
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 = "A Ruby gem for searching text patterns in files with colorized output"
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", "< 3.5"
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
- validate_options!(options)
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::Search.new(
24
- dir: path,
25
- regex: create_regex(options[: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
- json_output: options[:json]
32
- ).run
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,9 @@
1
+ module Grepfruit
2
+ class ProgrammaticSearch < Search
3
+ def execute
4
+ raise ArgumentError, "directory '#{path}' does not exist." unless File.exist?(path)
5
+
6
+ build_result_hash(execute_search)
7
+ end
8
+ end
9
+ 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
@@ -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
- include Decorator
9
+ attr_reader :path, :regex, :exclusions, :inclusions, :excluded_paths, :excluded_lines, :included_paths, :truncate, :search_hidden, :jobs, :json, :count
9
10
 
10
- attr_reader :dir, :regex, :excluded_paths, :excluded_lines, :included_paths, :truncate, :search_hidden, :jobs, :json_output
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
- @json_output = json_output
21
+ @json = json
22
+ @count = count
21
23
  end
22
24
 
23
- def run
24
- puts "Searching for #{regex.inspect} in #{dir.inspect}...\n\n" unless json_output
25
+ private
25
26
 
26
- display_final_results(execute_search)
27
- end
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
- private
52
+ result_hash
53
+ end
30
54
 
31
55
  def execute_search
32
56
  results = SearchResults.new
33
- workers = Array.new(jobs) { create_persistent_worker }
57
+ workers_and_ports = Array.new(jobs) { create_persistent_worker }
34
58
  file_enumerator = create_file_enumerator
35
59
  active_workers = {}
36
60
 
37
- workers.each do |worker|
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 = Ractor.select(*active_workers.keys)
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(workers)
73
+ shutdown_workers(workers_and_ports.map(&:first))
74
+
50
75
  results
51
76
  end
52
77
 
53
- def display_final_results(results)
54
- if json_output
55
- display_json_results(results.raw_matches, results.total_files, results.total_files_with_matches)
56
- else
57
- display_results(results.all_lines, results.total_files, results.total_files_with_matches)
58
- end
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
- Ractor.new do
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, base_dir = work
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
- Ractor.yield([file_results, has_matches])
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
- worker.send([file_path, regex, excluded_lines, dir])
90
- active_workers[worker] = file_path
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(dir) do |path|
108
- Find.prune if excluded_path?(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
- def process_worker_result(worker_result, results)
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
- unless json_output
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?(path)
141
- rel_path = path.delete_prefix("#{dir}/")
145
+ def excluded_path?(file_path)
146
+ rel_path = file_path.delete_prefix("#{path}/")
142
147
 
143
- (File.file?(path) && included_paths.any? && !matches_pattern?(included_paths, rel_path)) ||
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(path).start_with?("."))
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 :all_lines, :raw_matches, :total_files_with_matches
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 add_lines(lines)
18
- @all_lines.concat(lines)
17
+ def add_match_count(count)
18
+ @match_count += count
19
19
  end
20
20
 
21
21
  def add_raw_matches(matches)
@@ -1,3 +1,3 @@
1
1
  module Grepfruit
2
- VERSION = "3.1.2".freeze
2
+ VERSION = "3.2.0".freeze
3
3
  end
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
- class Error < StandardError; end
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.1.2
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/decorator.rb
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/brownboxdev/grepfruit
49
+ homepage: https://github.com/enjaku4/grepfruit
43
50
  licenses:
44
51
  - MIT
45
52
  metadata:
46
- homepage_uri: https://github.com/brownboxdev/grepfruit
47
- source_code_uri: https://github.com/brownboxdev/grepfruit
48
- changelog_uri: https://github.com/brownboxdev/grepfruit/blob/master/CHANGELOG.md
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: '3.5'
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: 3.6.7
77
+ rubygems_version: 4.0.0
68
78
  specification_version: 4
69
- summary: A Ruby gem for searching text patterns in files with colorized output
79
+ summary: Text search tool
70
80
  test_files: []
@@ -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