rbgrep 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 13fc98cb1e9f142256df88ad75264dfba4f3133cd86782f69562e9e4f3b73f9d
4
+ data.tar.gz: 95f53537ba8c4fb6f885be383e1b78782309c751cd3313e24ab612dbb9f6e7ba
5
+ SHA512:
6
+ metadata.gz: 7085f872add33bf241d0e043f95b70806ef9a09085929831b416fbab78f2deda61b19bcfe4d2721c3687a76b379b4ecc5226058caf69480e15d2dae4fa2158b2
7
+ data.tar.gz: 58f543d9979226be7e4c134047b0aafc11739df7c1ad7a03a8e78103667a2b4714a2ab8c9817b7d11631f9364b65e33ca2367746caaab37293d4ddb6338692ee
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yusuke Endoh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # rbgrep
2
+
3
+ A mostly grep-compatible search tool for Ruby codebases.
4
+
5
+ By default, `rbgrep` takes the same flags as `grep` and prints the same
6
+ kind of output: a filename heading, then `NN:` hit lines. Add
7
+ **`--context`** and each hit in a `.rb` file is shown together with the
8
+ method definition and class that enclose it, located with the
9
+ [Prism](https://github.com/ruby/prism) parser.
10
+
11
+ ## Installation
12
+
13
+ ```
14
+ gem install rbgrep
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ rbgrep [-rn -w -i -E -F -A/-B/-C --include=GLOB] PATTERN [PATH...]
21
+ ```
22
+
23
+ Plain search works like grep (including `\|` alternation in basic
24
+ regexps, or `-E` for extended ones):
25
+
26
+ ```
27
+ $ rbgrep -n 'def visible?' app/models/news.rb
28
+ app/models/news.rb
29
+ 55: def visible?(user=User.current)
30
+ ```
31
+
32
+ With `--context`, the hit arrives with the code that gives it meaning —
33
+ the enclosing method body and the class frame, with `--` marking elided
34
+ stretches:
35
+
36
+ ```
37
+ $ rbgrep -n --context 'def visible?' app/models/news.rb
38
+ app/models/news.rb
39
+ 20- class News < ApplicationRecord
40
+ --
41
+ 55: def visible?(user=User.current)
42
+ 56- !user.nil? && user.allowed_to?(:view_news, project)
43
+ 57- end
44
+ --
45
+ 105- end
46
+ ```
47
+
48
+ A hit on a method definition shows the body (capped); a hit that merely
49
+ references something shows a few surrounding lines. Files under `test/`
50
+ and `spec/` directories, and non-Ruby files, print plain hits.
51
+
52
+ `--head N` caps output by hits, returns each hit whole, and reports how
53
+ many were cut — unlike `| head`, you always know whether there was more:
54
+
55
+ ```
56
+ $ rbgrep -rn --head 3 'def initialize' lib
57
+ ...
58
+ ... 48 more hits (raise --head to see them)
59
+ ```
60
+
61
+ When reading from a pipe, `rbgrep` behaves exactly like `grep` — no
62
+ headings, no context — so `... | rbgrep -v foo` works as expected.
63
+
64
+ ## Use with coding agents
65
+
66
+ `rbgrep` is designed to be handed to an AI coding agent as its search
67
+ tool. [instruction.md](instruction.md) is a ready-made instruction sheet:
68
+ tell your agent to read and follow it.
69
+
70
+ ## License
71
+
72
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/rbgrep ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/rbgrep"
4
+
5
+ exit Rbgrep::CLI.run(ARGV)
data/instruction.md ADDED
@@ -0,0 +1,44 @@
1
+ # rbgrep
2
+
3
+ **Use `rbgrep` to search the repository you are working in — Ruby sources and templates alike.**
4
+ If `rbgrep` is not installed, run `gem install rbgrep` first.
5
+
6
+ The arguments are the same as grep's, and by default so is the output:
7
+ a filename heading, then `NN:` hit lines. **Using `grep` / `rg` to search
8
+ files in the repository is prohibited.**
9
+ The following remain free:
10
+
11
+ - narrowing **downstream of a pipe** (`... | grep -v foo`)
12
+ - **reading files** (`cat` / `sed -n` / Read / `head` / `tail`)
13
+
14
+ ```
15
+ rbgrep [-rn -w -i -E -F -A/-B/-C --include=GLOB] PATTERN [PATH...]
16
+ ```
17
+
18
+ Search. Multi-term search is written the same way as in grep:
19
+
20
+ ```
21
+ rbgrep -rn 'Payment\|payment_method' app lib
22
+ rbgrep -rnE 'Payment|PaymentMethod' app lib
23
+ ```
24
+
25
+ Add **`--context`** to see where hits sit: each hit in a `.rb` file is then
26
+ shown together with its **enclosing method definition and class** as `NN-`
27
+ context lines (`--` marks elided stretches). **Reach for it whenever a hit
28
+ is in a file you haven't read yet** — it usually saves a follow-up read.
29
+ Plain output is for counting and locating.
30
+
31
+ ```
32
+ rbgrep --head N PATTERN [PATH...]
33
+ ```
34
+
35
+ **Piping `rbgrep` output into `| head` is prohibited. Use `--head N` instead.**
36
+ `--head` caps by hits, returns each hit whole, and when results are cut it
37
+ says so, so you know whether to raise N.
38
+
39
+ **Warm-up (once, before you start)**: to get used to the `--context` output,
40
+ pick one small Ruby file (any file under `lib/` will do) and search it:
41
+
42
+ ```
43
+ rbgrep -n --context 'def ' path/to/that/file.rb
44
+ ```
data/lib/rbgrep/cli.rb ADDED
@@ -0,0 +1,235 @@
1
+ module Rbgrep
2
+ # The command line: parse grep-shaped arguments, search, print.
3
+ class CLI
4
+ def self.run(argv, stdout: $stdout, stdin: $stdin)
5
+ new(stdout: stdout, stdin: stdin).run(argv)
6
+ end
7
+
8
+ def initialize(stdout: $stdout, stdin: $stdin)
9
+ @out = stdout
10
+ @in = stdin
11
+ end
12
+
13
+ def run(argv)
14
+ o = parse(argv)
15
+ if o[:patterns].empty?
16
+ warn "usage: rbgrep [options] PATTERN [FILE...]"
17
+ return 1
18
+ end
19
+ regexp = Pattern.build(o[:patterns], fixed: o[:fixed], extended: o[:ere],
20
+ word: o[:word], ignore_case: o[:ignore_case])
21
+ return filter_stdin(regexp, o) if o[:paths].empty? && !@in.tty?
22
+
23
+ search_files(regexp, o)
24
+ end
25
+
26
+ private
27
+
28
+ def parse(argv)
29
+ o = {
30
+ patterns: [], paths: [], recursive: false, line_numbers: false,
31
+ ignore_case: false, word: false, invert: false, fixed: false,
32
+ list: false, count: false, quiet: false, no_filename: nil,
33
+ before: 0, after: 0, include: [], exclude: [], exclude_dir: [],
34
+ max: nil, ere: false, context: false, head: nil,
35
+ }
36
+ argv = argv.dup
37
+ until argv.empty?
38
+ a = argv.shift
39
+ case a
40
+ when "--" then o[:paths].concat(argv); break
41
+ when /\A--include=(.+)/ then o[:include] << $1
42
+ when "--include" then o[:include] << argv.shift
43
+ when /\A--exclude=(.+)/ then o[:exclude] << $1
44
+ when "--exclude" then o[:exclude] << argv.shift
45
+ when /\A--exclude-dir=(.+)/ then o[:exclude_dir] << $1
46
+ when "--exclude-dir" then o[:exclude_dir] << argv.shift
47
+ when "--color", "--colour", /\A--colou?r=./ then nil # output is never colored
48
+ when "--no-filename" then o[:no_filename] = true
49
+ when "--with-filename" then o[:no_filename] = false
50
+ when "--line-number" then o[:line_numbers] = true
51
+ when "--recursive" then o[:recursive] = true
52
+ when "--invert-match" then o[:invert] = true
53
+ when "--files-with-matches" then o[:list] = true
54
+ when "--count" then o[:count] = true
55
+ when "--word-regexp" then o[:word] = true
56
+ when "--ignore-case" then o[:ignore_case] = true
57
+ when "--fixed-strings" then o[:fixed] = true
58
+ when "--extended-regexp" then o[:ere] = true
59
+ when "--context" then o[:context] = true
60
+ when /\A--head=(\d+)\z/ then o[:head] = $1.to_i
61
+ when "--head" then o[:head] = argv.shift.to_i
62
+ when /\A-([A-Za-z]+)(\d*)\z/
63
+ letters = $1
64
+ trailing = $2
65
+ until letters.empty?
66
+ c = letters.slice!(0)
67
+ case c
68
+ when "r", "R" then o[:recursive] = true
69
+ when "n" then o[:line_numbers] = true
70
+ when "i" then o[:ignore_case] = true
71
+ when "w" then o[:word] = true
72
+ when "v" then o[:invert] = true
73
+ when "l" then o[:list] = true
74
+ when "c" then o[:count] = true
75
+ when "q" then o[:quiet] = true
76
+ when "h" then o[:no_filename] = true
77
+ when "H" then o[:no_filename] = false
78
+ when "F" then o[:fixed] = true
79
+ when "E", "P" then o[:ere] = true
80
+ when "s", "a", "o" then nil # -o is approximated as a normal match
81
+ when "e" then o[:patterns] << (letters.empty? ? argv.shift : letters.slice!(0..-1))
82
+ when "A", "B", "C", "m"
83
+ n = letters.empty? ? (trailing.empty? ? argv.shift : trailing) : letters.slice!(0..-1)
84
+ trailing = ""
85
+ n = n.to_i
86
+ case c
87
+ when "A" then o[:after] = n
88
+ when "B" then o[:before] = n
89
+ when "C" then o[:after] = o[:before] = n
90
+ when "m" then o[:max] = n
91
+ end
92
+ end
93
+ end
94
+ else
95
+ o[:patterns].empty? ? o[:patterns] << a : o[:paths] << a
96
+ end
97
+ end
98
+ o
99
+ end
100
+
101
+ # piped input: behave exactly like grep — no headings, no context
102
+ def filter_stdin(regexp, o)
103
+ hits = 0
104
+ @in.each_line do |line|
105
+ next unless regexp.match?(line) ^ o[:invert]
106
+
107
+ hits += 1
108
+ break if o[:max] && hits > o[:max]
109
+
110
+ @out.print line unless o[:count] || o[:quiet]
111
+ end
112
+ @out.puts hits if o[:count]
113
+ hits > 0 ? 0 : 1
114
+ end
115
+
116
+ def collect_files(paths, o)
117
+ out = []
118
+ paths = ["."] if paths.empty?
119
+ paths.each do |p|
120
+ if File.directory?(p)
121
+ Dir.glob(File.join(p, "**", "*")).each do |f|
122
+ next unless File.file?(f)
123
+ next if o[:exclude_dir].any? { |g| f.split("/").any? { |seg| File.fnmatch?(g, seg) } }
124
+
125
+ out << f
126
+ end
127
+ elsif File.file?(p)
128
+ out << p
129
+ end
130
+ end
131
+ out = out.select { |f| o[:include].any? { |g| File.fnmatch?(g, File.basename(f)) } } if o[:include].any?
132
+ out = out.reject { |f| o[:exclude].any? { |g| File.fnmatch?(g, File.basename(f)) } } if o[:exclude].any?
133
+ out.reject { |f| f.include?("/.git/") }
134
+ end
135
+
136
+ def search_files(regexp, o)
137
+ files = collect_files(o[:paths], o)
138
+
139
+ results = []
140
+ total = 0
141
+ found = false
142
+ files.each do |path|
143
+ body = begin
144
+ File.read(path, encoding: "UTF-8")
145
+ rescue StandardError
146
+ next
147
+ end
148
+ next unless body.valid_encoding?
149
+
150
+ lines = body.lines
151
+ hits = lines.each_index.select { |i| regexp.match?(lines[i]) ^ o[:invert] }.map { |i| i + 1 }
152
+ next if hits.empty?
153
+
154
+ found = true
155
+ total += hits.size
156
+ results << [path, body, lines, hits]
157
+ end
158
+
159
+ truncated = nil
160
+ if o[:head]
161
+ left = o[:head]
162
+ capped = []
163
+ results.each do |path, body, lines, hits|
164
+ break if left <= 0
165
+
166
+ take = hits.first(left)
167
+ left -= take.size
168
+ capped << [path, body, lines, take]
169
+ end
170
+ truncated = total - o[:head]
171
+ truncated = nil unless truncated > 0
172
+ results = capped
173
+ end
174
+
175
+ return found ? 0 : 1 if o[:quiet]
176
+ return list_output(results) || (found ? 0 : 1) if o[:list]
177
+ return count_output(results, files, o) || (found ? 0 : 1) if o[:count]
178
+
179
+ text_output(results, o, truncated)
180
+ found ? 0 : 1
181
+ end
182
+
183
+ def list_output(results)
184
+ results.each { |path, _, _, _| @out.puts path }
185
+ nil
186
+ end
187
+
188
+ def count_output(results, files, o)
189
+ show_name = o[:no_filename].nil? ? (files.size > 1 || o[:recursive]) : !o[:no_filename]
190
+ results.each do |path, _, _, hits|
191
+ @out.puts show_name ? "#{path}:#{hits.size}" : hits.size.to_s
192
+ end
193
+ nil
194
+ end
195
+
196
+ # a filename heading, then "NN:" hits and "NN-" context, "--" for elisions
197
+ def text_output(results, o, truncated)
198
+ first = true
199
+ results.each do |path, body, lines, hits|
200
+ keep = select_lines(path, body, lines, hits, o)
201
+ shown = keep.keys.select { |l| l >= 1 && l <= lines.size }.sort
202
+ bare = shown.all? { |l| keep[l] == :hit }
203
+ @out.puts "" unless first
204
+ first = false
205
+ @out.puts path
206
+ prev = 0
207
+ shown.each do |ln|
208
+ @out.puts "--" if !bare && prev > 0 && ln > prev + 1
209
+ @out.puts "#{ln}#{keep[ln] == :hit ? ':' : '-'} #{lines[ln - 1].to_s.chomp}"
210
+ prev = ln
211
+ end
212
+ end
213
+ @out.puts "... #{truncated} more hits (raise --head to see them)" if truncated
214
+ end
215
+
216
+ def select_lines(path, body, lines, hits, o)
217
+ if o[:context] && windowable?(path, o)
218
+ parsed = Prism.parse(body)
219
+ return Window.select(parsed.value, hits, o[:before], o[:after]) if parsed.success?
220
+ end
221
+ ctx = [o[:before], o[:after]].max
222
+ keep = {}
223
+ hits.each do |ln|
224
+ keep[ln] = :hit
225
+ ((ln - ctx)..(ln + ctx)).each { |i| keep[i] ||= :ctx if i >= 1 && i <= lines.size }
226
+ end
227
+ keep
228
+ end
229
+
230
+ # windows are for Ruby source; tests and non-.rb files print plain hits
231
+ def windowable?(path, o)
232
+ path.end_with?(".rb") && !o[:invert] && path !~ %r{(\A|/)(test|spec)/}
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,30 @@
1
+ module Rbgrep
2
+ # Builds the Regexp for a search the way grep would read it.
3
+ module Pattern
4
+ module_function
5
+
6
+ # grep's basic regexps spell alternation "\|" and grouping "\(...\)";
7
+ # translate to Ruby: escaped metacharacters active, bare ones literal.
8
+ def from_bre(pattern)
9
+ out = +""
10
+ i = 0
11
+ while i < pattern.length
12
+ if pattern[i] == "\\" && i + 1 < pattern.length && "|(){}+?".include?(pattern[i + 1])
13
+ out << pattern[i + 1]
14
+ i += 2
15
+ else
16
+ out << "\\" if "|(){}+?".include?(pattern[i])
17
+ out << pattern[i]
18
+ i += 1
19
+ end
20
+ end
21
+ out
22
+ end
23
+
24
+ def build(patterns, fixed: false, extended: false, word: false, ignore_case: false)
25
+ src = patterns.map { |p| fixed ? Regexp.escape(p) : (extended ? p : from_bre(p)) }.join("|")
26
+ src = "\\b(?:#{src})\\b" if word
27
+ Regexp.new(src, ignore_case ? Regexp::IGNORECASE : nil)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ module Rbgrep
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,57 @@
1
+ module Rbgrep
2
+ # Line selection for --context: show each hit together with the enclosing
3
+ # method definition and class/module frame.
4
+ module Window
5
+ BODY_CAP = 10 # body lines shown when the hit is the `def` line itself
6
+ REF_MARGIN = 3 # lines around a hit inside a body (a mere reference)
7
+ EDGE_SLACK = 4 # show this many lines rather than eliding them at a def edge
8
+ FRAME_NODES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
9
+
10
+ module_function
11
+
12
+ # Nodes enclosing the line, outermost first.
13
+ def ancestors(node, lineno, acc = [])
14
+ return acc unless node.is_a?(Prism::Node)
15
+
16
+ loc = node.location
17
+ return acc unless loc.start_line <= lineno && lineno <= loc.end_line
18
+
19
+ acc << node
20
+ node.compact_child_nodes.each { |c| ancestors(c, lineno, acc) }
21
+ acc
22
+ end
23
+
24
+ # => { lineno => :hit | :body | :frame | :ctx }
25
+ def select(ast, hits, before, after)
26
+ cap_b = [BODY_CAP, before].max
27
+ cap_a = [BODY_CAP, after].max
28
+ ctx_b = [REF_MARGIN, before].max
29
+ ctx_a = [REF_MARGIN, after].max
30
+ keep = {}
31
+ hits.each do |ln|
32
+ keep[ln] = :hit
33
+ ((ln - before)..(ln + after)).each { |i| keep[i] ||= :ctx if i >= 1 }
34
+ stack = ancestors(ast, ln)
35
+ if (defn = stack.find { |n| n.is_a?(Prism::DefNode) })
36
+ keep[defn.location.start_line] ||= :frame
37
+ keep[defn.location.end_line] ||= :frame
38
+ b, a = defn.location.start_line == ln ? [cap_b, cap_a] : [ctx_b, ctx_a]
39
+ lo = [defn.location.start_line, ln - b].max
40
+ hi = [defn.location.end_line, ln + a].min
41
+ lo = defn.location.start_line if lo - defn.location.start_line <= EDGE_SLACK
42
+ hi = defn.location.end_line if defn.location.end_line - hi <= EDGE_SLACK
43
+ (lo..hi).each { |i| keep[i] ||= :body }
44
+ else
45
+ ((ln - ctx_b)..(ln + ctx_a)).each { |i| keep[i] ||= :body if i >= 1 }
46
+ end
47
+ stack.each do |n|
48
+ next unless FRAME_NODES.any? { |k| n.is_a?(k) }
49
+
50
+ keep[n.location.start_line] ||= :frame
51
+ keep[n.location.end_line] ||= :frame
52
+ end
53
+ end
54
+ keep
55
+ end
56
+ end
57
+ end
data/lib/rbgrep.rb ADDED
@@ -0,0 +1,9 @@
1
+ require "prism"
2
+
3
+ require_relative "rbgrep/version"
4
+ require_relative "rbgrep/pattern"
5
+ require_relative "rbgrep/window"
6
+ require_relative "rbgrep/cli"
7
+
8
+ module Rbgrep
9
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rbgrep
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yusuke Endoh
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ -
17
+ - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: "0.24"
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ -
25
+ - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: "0.24"
28
+ description: |
29
+ rbgrep is a mostly grep-compatible search tool for Ruby codebases.
30
+ By default it behaves like grep. With --context, each hit in a Ruby
31
+ file is shown together with the method definition and class that
32
+ enclose it, located with the Prism parser.
33
+ email:
34
+ - y.endoh@gmail.com
35
+ executables:
36
+ - rbgrep
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - LICENSE.txt
41
+ - README.md
42
+ - exe/rbgrep
43
+ - instruction.md
44
+ - lib/rbgrep.rb
45
+ - lib/rbgrep/cli.rb
46
+ - lib/rbgrep/pattern.rb
47
+ - lib/rbgrep/version.rb
48
+ - lib/rbgrep/window.rb
49
+ homepage: "https://github.com/mame/rbgrep"
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: "https://github.com/mame/rbgrep"
54
+ source_code_uri: "https://github.com/mame/rbgrep"
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ -
61
+ - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: "3.2"
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ -
67
+ - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: "0"
70
+ requirements: []
71
+ rubygems_version: 4.1.0.dev
72
+ specification_version: 4
73
+ summary: "grep for Ruby codebases: grep-compatible search, syntax-aware context"
74
+ test_files: []