grepfruit 3.0.0 → 3.1.1

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