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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3eed6cbb094ea1031d938d7459ccf8004f3fb557e0569c3afb919aeccfabbd2
4
- data.tar.gz: 2df5426280091c652d372ce119e506efdf6eb0cec7dca0a311965345962c3239
3
+ metadata.gz: 505fa3f2e5b4b5ffc41a795f0305e6a37f913b7fe66036018d4f4438dc6c9b40
4
+ data.tar.gz: 3f89449fd5ab9d2851aadac1ec432606aeaae73b4673bd0d4e62cd96b4bdc83f
5
5
  SHA512:
6
- metadata.gz: 03a5b5748fbc9c5f2e67f9c25c167387c4fb8fb7ebb4590d8b5e4b98ebee93a914960a5cf3c90da5d516af79c35a394ac6e96b2bcd992243b4aa17a7b96c3525
7
- data.tar.gz: 12e02c6c9d7c3a86ea982d0ed6aa45044711d3c4b1d8f37eade228d213cfdc24ecc617a5f8d0b89fad889cea5ad538615d1fa1af74c2cebf9690b58e7e1b128d
6
+ metadata.gz: 2e2ff1bdec53492186f6ff37670e82b62ee72ff84435c68167bd7ef7ae460ee23221dbc1d0682205207acc579660675ea4504b6722498e51520465fb9ac79cbe
7
+ data.tar.gz: 4dac4064e07aafd907523d6223dfa789eae14428a5e92b4edcb82ef206391e179e469f76db56d972bbc01815be0c58f3c574353832eb45bfbb942d89b21a052e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
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
+
1
7
  ## v3.1.3
2
8
 
3
9
  - Fixed race condition in worker shutdown
data/README.md CHANGED
@@ -1,19 +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
4
  [![Downloads](https://img.shields.io/gem/dt/grepfruit.svg)](https://rubygems.org/gems/grepfruit)
5
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
6
  [![License](https://img.shields.io/github/license/enjaku4/grepfruit.svg)](LICENSE)
7
7
 
8
- Grepfruit is a Ruby gem for searching files within a directory for specified regular expression patterns. It provides exclusion and inclusion options with JSON-formatted or colorized output for enhanced readability.
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
- Originally designed for CI/CD pipelines to search for `TODO` comments in Ruby applications, Grepfruit offers more user-friendly output than the standard `grep` command while maintaining flexibility for diverse search scenarios.
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
- ```bash
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 = ["enjaku4@icloud.com"]
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 = "A Ruby gem for searching text patterns in files"
16
- spec.description = "Grepfruit provides enhanced file pattern search with colorized results, JSON output, and CI/CD integration"
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", "< 3.5"
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
- 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
 
@@ -103,45 +132,22 @@ module Grepfruit
103
132
 
104
133
  def create_file_enumerator
105
134
  Enumerator.new do |yielder|
106
- Find.find(dir) do |path|
107
- Find.prune if excluded_path?(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
- def process_worker_result(worker_result, results)
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
- unless json_output
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?(path)
140
- rel_path = path.delete_prefix("#{dir}/")
145
+ def excluded_path?(file_path)
146
+ rel_path = file_path.delete_prefix("#{path}/")
141
147
 
142
- (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)) ||
143
149
  matches_pattern?(excluded_paths, rel_path) ||
144
- (!search_hidden && File.basename(path).start_with?("."))
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 :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.3".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.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: Grepfruit provides enhanced file pattern search with colorized results,
27
- JSON output, and CI/CD integration
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
- - enjaku4@icloud.com
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/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
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: '3.5'
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: 3.7.2
77
+ rubygems_version: 4.0.0
74
78
  specification_version: 4
75
- summary: A Ruby gem for searching text patterns in files
79
+ summary: Text search tool
76
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