pink_spoon 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: ce69a26c0202add732139120d580e082afe4e783e33053e29652f0ab113778bd
4
+ data.tar.gz: 468b2feec436762cb468751b58cda34264088ece69e246a0422e067cad213b2f
5
+ SHA512:
6
+ metadata.gz: 2b716b7f37adb643ccaaca86c4058f32bc3903d7097f9a82ca3cdfddd2c397b1628792fb40141dc111d92d97f764c7a383018c2b0c24b37c8af1e06791203b49
7
+ data.tar.gz: '078a9df02c0ed4160180b8a9a9efd7ad4ca0161af3723edffd443a019f410cdb55c10b86a0320e19a32e001f5c355c1cac17cd43eaf8e4a9cd362c772f495267'
data/bin/install-addon ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: ruby bin/install-addon /path/to/target/project
5
+ #
6
+ # Drops a ruby_lsp/pink_spoon/addon.rb stub into the target project that
7
+ # loads pink-spoon from this gem's source tree.
8
+ # ruby_lsp/pink_spoon/ is covered by ~/.gitignore_global so the stub
9
+ # never shows up in git status.
10
+
11
+ require "fileutils"
12
+
13
+ target = ARGV[0]&.then { File.expand_path(_1) }
14
+
15
+ if target.nil? || !File.directory?(target)
16
+ abort "Usage: #{$0} /path/to/project"
17
+ end
18
+
19
+ gem_root = File.expand_path("..", __dir__)
20
+ stub_dir = File.join(target, "ruby_lsp", "pink_spoon")
21
+ stub_file = File.join(stub_dir, "addon.rb")
22
+
23
+ FileUtils.mkdir_p(stub_dir)
24
+ File.write(stub_file, <<~RUBY)
25
+ # frozen_string_literal: true
26
+ # Auto-generated by pink-spoon/bin/install-addon — not committed.
27
+ # Re-run install-addon to update after changes to pink-spoon.
28
+ $LOAD_PATH.unshift "#{gem_root}/lib" unless $LOAD_PATH.include?("#{gem_root}/lib")
29
+ require "ruby_lsp/pink_spoon/addon"
30
+ RUBY
31
+
32
+ puts "wrote #{stub_file}"
33
+ puts "covered by ~/.gitignore_global — won't appear in git status"
34
+ puts "\nDone. Restart ruby-lsp in Zed (cmd+shift+p → Restart Language Server)."
data/bin/pink-spoon ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "pink_spoon/server"
7
+
8
+ PinkSpoon::Server.new.run
@@ -0,0 +1,469 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module PinkSpoon
6
+ # Given a file + cursor position, figures out:
7
+ # 1. What constant the receiver resolves to (e.g. EnabledFeatureGauge → Prometheus::Client::Gauge)
8
+ # 2. What method name is under the cursor
9
+ #
10
+ # Returns { type: "Prometheus::Client::Gauge", method: "init_label_set" } or nil.
11
+ #
12
+ # Resolution strategy (in order):
13
+ # a. Walk up the AST to find the call node under the cursor.
14
+ # b. If the receiver is a constant, look it up as an assignment in the same file.
15
+ # c. If the receiver is a local variable, resolve its type from assignments or block params.
16
+ # d. Follow the RHS chain through the RBI index (register_gauge → Gauge, .freeze → passthrough).
17
+ # e. For Foo.new(...), the type is Foo directly (Sorbet rarely types .new explicitly).
18
+ class ConstantResolver
19
+ PASSTHROUGH_METHODS = %w[freeze dup clone tap then yield_self].freeze
20
+
21
+ RSPEC_EXAMPLE_GROUP_METHODS = %w[
22
+ it example specify
23
+ context describe
24
+ let let!
25
+ subject subject!
26
+ before after around
27
+ pending skip
28
+ include_context include_examples include_shared_examples_for
29
+ shared_examples shared_context shared_examples_for
30
+ aggregate_failures
31
+ ].freeze
32
+
33
+ RSPEC_MODULE_METHODS = %w[describe shared_examples shared_context shared_examples_for].freeze
34
+
35
+ # Methods that yield each element of their receiver collection.
36
+ ENUMERATION_METHODS = %w[
37
+ each map select reject flat_map filter_map find detect
38
+ each_with_index each_with_object collect sum min_by max_by
39
+ sort_by group_by
40
+ ].freeze
41
+
42
+ def initialize(root_path, rbi_index)
43
+ @root_path = root_path
44
+ @rbi_index = rbi_index
45
+ end
46
+
47
+ # Entry point for the ruby-lsp addon.
48
+ # Returns { type:, method: } or nil.
49
+ def resolve_from_call(call_node, program_node, nesting = nil)
50
+ return nil unless call_node
51
+
52
+ method_name = call_node.name.to_s
53
+ receiver = call_node.receiver
54
+
55
+ unless receiver
56
+ type = infer_rspec_receiver(method_name)
57
+ return type ? { type: type, method: method_name } : nil
58
+ end
59
+
60
+ @_call_line = call_node.location.start_line
61
+ @_nesting = nesting
62
+
63
+ type = resolve_receiver(receiver, program_node, nil)
64
+ return nil unless type
65
+
66
+ { type: type, method: method_name }
67
+ ensure
68
+ @_call_line = nil
69
+ @_nesting = nil
70
+ end
71
+
72
+ # Public entry point for completion: returns the type string of a receiver node.
73
+ def resolve_receiver_type(receiver_node, program_node, nesting = nil)
74
+ @_nesting = nesting
75
+ resolve_receiver(receiver_node, program_node, nil)
76
+ ensure
77
+ @_nesting = nil
78
+ end
79
+
80
+ # Standalone server entry point. Returns { type:, method: } or nil.
81
+ def resolve_at(file, line, col)
82
+ return nil unless file && File.exist?(file)
83
+
84
+ source = File.read(file)
85
+ result = Prism.parse(source)
86
+
87
+ call = innermost_call_at(result.value, line, col)
88
+ return nil unless call
89
+
90
+ method_name = call.name.to_s
91
+ receiver = call.receiver
92
+
93
+ unless receiver
94
+ type = infer_rspec_receiver(method_name)
95
+ return type ? { type: type, method: method_name } : nil
96
+ end
97
+
98
+ type = resolve_receiver(receiver, result.value, source)
99
+ return nil unless type
100
+
101
+ { type: type, method: method_name }
102
+ end
103
+
104
+ private
105
+
106
+ # ------------------------------------------------------------------
107
+ # AST walking helpers
108
+ # ------------------------------------------------------------------
109
+
110
+ def innermost_call_at(root, lsp_line, lsp_col)
111
+ target_line = lsp_line + 1
112
+
113
+ best = nil
114
+ walk(root) do |node|
115
+ next unless node.is_a?(Prism::CallNode)
116
+ loc = node.location
117
+ next unless covers?(loc, target_line, lsp_col)
118
+ best = node if best.nil? || span(node) < span(best)
119
+ end
120
+ best
121
+ end
122
+
123
+ def covers?(loc, line, col)
124
+ return false if line < loc.start_line || line > loc.end_line
125
+ return false if line == loc.start_line && col < loc.start_column
126
+ return false if line == loc.end_line && col > loc.end_column
127
+ true
128
+ end
129
+
130
+ def span(node)
131
+ loc = node.location
132
+ (loc.end_line - loc.start_line) * 100_000 + (loc.end_column - loc.start_column)
133
+ end
134
+
135
+ def walk(node, &block)
136
+ return unless node.is_a?(Prism::Node)
137
+ block.call(node)
138
+ node.child_nodes.compact.each { |child| walk(child, &block) }
139
+ end
140
+
141
+ # ------------------------------------------------------------------
142
+ # Receiver resolution
143
+ # _seen guards against infinite recursion when local variables
144
+ # reference themselves (e.g. `entries = entries.first`).
145
+ # ------------------------------------------------------------------
146
+
147
+ def resolve_receiver(receiver, ast, source, _seen = [])
148
+ case receiver
149
+ when Prism::ConstantReadNode
150
+ const_name = receiver.name.to_s
151
+ resolve_constant(const_name, ast, source, _seen) || const_name
152
+ when Prism::ConstantPathNode
153
+ receiver.slice.delete_prefix("::")
154
+ when Prism::LocalVariableReadNode
155
+ name = receiver.name.to_s
156
+ guard = "lv:#{name}"
157
+ return nil if _seen.include?(guard)
158
+ find_local_var_type(name, ast, source, _seen + [guard])
159
+ when Prism::InstanceVariableReadNode
160
+ ivar = receiver.name.to_s
161
+ guard = "iv:#{ivar}"
162
+ return nil if _seen.include?(guard)
163
+ find_ivar_type(ivar, ast, source, _seen + [guard])
164
+ when Prism::CallNode
165
+ # T.must(x) → resolve x
166
+ if receiver.name == :must && t_module?(receiver.receiver)
167
+ arg = receiver.arguments&.arguments&.first
168
+ return arg ? resolve_receiver(arg, ast, source, _seen) : nil
169
+ end
170
+ # T.cast(x, Type) / T.let(x, Type) → return Type
171
+ if (receiver.name == :cast || receiver.name == :let) && t_module?(receiver.receiver)
172
+ type_arg = receiver.arguments&.arguments&.[](1)
173
+ return type_arg ? type_from_node(type_arg) : nil
174
+ end
175
+ # Constructor: Foo.new(...) → instance type is Foo.
176
+ if receiver.name == :new && receiver.receiver
177
+ case receiver.receiver
178
+ when Prism::ConstantReadNode then return receiver.receiver.name.to_s
179
+ when Prism::ConstantPathNode then return receiver.receiver.slice.delete_prefix("::")
180
+ end
181
+ end
182
+ inner_type = resolve_receiver(receiver.receiver, ast, source, _seen)
183
+ method_name = receiver.name.to_s
184
+ follow_chain(inner_type, method_name)
185
+ else
186
+ nil
187
+ end
188
+ end
189
+
190
+ def resolve_constant(const_name, ast, source, _seen = [])
191
+ AssignmentFinder.new(const_name).find(ast)&.then do |rhs|
192
+ resolve_rhs(rhs, ast, source, _seen)
193
+ end
194
+ end
195
+
196
+ def resolve_rhs(rhs, ast, source, _seen = [])
197
+ case rhs
198
+ when Prism::ConstantReadNode
199
+ rhs.name.to_s
200
+ when Prism::ConstantPathNode
201
+ rhs.slice.delete_prefix("::")
202
+ when Prism::CallNode
203
+ # Constructor shortcut — same reasoning as resolve_receiver.
204
+ if rhs.name == :new && rhs.receiver
205
+ case rhs.receiver
206
+ when Prism::ConstantReadNode then return rhs.receiver.name.to_s
207
+ when Prism::ConstantPathNode then return rhs.receiver.slice.delete_prefix("::")
208
+ end
209
+ end
210
+ inner_type = resolve_receiver(rhs.receiver, ast, source, _seen)
211
+ method_name = rhs.name.to_s
212
+ follow_chain(inner_type, method_name)
213
+ else
214
+ nil
215
+ end
216
+ end
217
+
218
+ def follow_chain(type, method_name)
219
+ return type if PASSTHROUGH_METHODS.include?(method_name)
220
+ return nil unless type
221
+
222
+ clean = unwrap_sorbet_wrapper(type)
223
+ ret = @rbi_index.return_type_for(clean, method_name)&.delete_prefix("::")
224
+ ret ? unwrap_sorbet_wrapper(ret) : nil
225
+ end
226
+
227
+ def unwrap_sorbet_wrapper(type)
228
+ return nil unless type
229
+ if (m = type.match(/\AT\.nilable\((.+)\)\z/))
230
+ return m[1].strip.delete_prefix("::")
231
+ end
232
+ if (m = type.match(/\AT\.any\((.+)\)\z/))
233
+ non_nil = m[1].split(",").map(&:strip).reject { |t| t =~ /\ANilClass\z/i }
234
+ return non_nil.first&.delete_prefix("::") unless non_nil.empty?
235
+ end
236
+ type
237
+ end
238
+
239
+ def t_module?(node)
240
+ node.is_a?(Prism::ConstantReadNode) && node.name == :T
241
+ end
242
+
243
+ def type_from_node(node)
244
+ case node
245
+ when Prism::ConstantReadNode then node.name.to_s
246
+ when Prism::ConstantPathNode then node.slice.delete_prefix("::")
247
+ end
248
+ end
249
+
250
+ def infer_rspec_receiver(method_name)
251
+ return "RSpec::Core::ExampleGroup" if RSPEC_EXAMPLE_GROUP_METHODS.include?(method_name)
252
+ return "RSpec" if RSPEC_MODULE_METHODS.include?(method_name)
253
+ nil
254
+ end
255
+
256
+ # ------------------------------------------------------------------
257
+ # Local variable type resolution
258
+ # ------------------------------------------------------------------
259
+
260
+ # Resolves the type of a local variable by:
261
+ # 1. Finding its most recent assignment and resolving the RHS.
262
+ # 2. Falling back to block parameter inference (entries.each { |e| }).
263
+ def find_local_var_type(var_name, ast, source, _seen = [])
264
+ # Direct assignments: var = <rhs>
265
+ LocalAssignmentFinder.new(var_name).find_all(ast).each do |rhs|
266
+ type = resolve_rhs(rhs, ast, source, _seen)
267
+ return type if type
268
+ end
269
+
270
+ # Strategy 2b: method parameter type from RBI sig
271
+ param_type = find_method_param_type(var_name)
272
+ return param_type if param_type
273
+
274
+ # Block parameter: method { |var| } — infer element type from receiver.
275
+ finder = BlockParamFinder.new(var_name)
276
+ finder.visit(ast)
277
+ return nil unless finder.enclosing_call&.receiver
278
+
279
+ call = finder.enclosing_call
280
+ receiver_type = resolve_receiver(call.receiver, ast, source, _seen)
281
+ return nil unless receiver_type
282
+
283
+ method_name = call.name.to_s
284
+ if ENUMERATION_METHODS.include?(method_name)
285
+ extract_element_type(receiver_type)
286
+ else
287
+ ret = @rbi_index.return_type_for(receiver_type, method_name)
288
+ ret ? extract_element_type(ret) : nil
289
+ end
290
+ end
291
+
292
+ # Extracts X from generic types like T::Array[X], T::Enumerator[X].
293
+ # Resolves the type of an instance variable by scanning its assignments.
294
+ def find_ivar_type(ivar_name, ast, source, _seen = [])
295
+ IvarTypeFinder.new(ivar_name).find_all(ast).each do |rhs|
296
+ type = resolve_rhs(rhs, ast, source, _seen)
297
+ return type if type
298
+ end
299
+ nil
300
+ end
301
+
302
+ # Looks up the Sorbet-declared type of a method parameter from the RBI index.
303
+ # Requires @_call_line and @_nesting to be set (done in resolve_from_call).
304
+ def find_method_param_type(param_name)
305
+ return nil unless @_call_line && @_nesting
306
+
307
+ class_node = @_nesting.reverse.find { |n| n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode) }
308
+ return nil unless class_node
309
+
310
+ current_type = class_node.constant_path.slice.delete_prefix("::")
311
+ method_name = EnclosingDefFinder.new(@_call_line).find(
312
+ @_nesting.find { |n| n.is_a?(Prism::ProgramNode) }
313
+ )
314
+ return nil unless method_name
315
+
316
+ params = @rbi_index.params_for(current_type, method_name)
317
+ raw = params&.[](param_name)
318
+ raw ? unwrap_sorbet_wrapper(raw) : nil
319
+ end
320
+
321
+ def extract_element_type(type_str)
322
+ return nil unless type_str
323
+ m = type_str.match(/\[(\w+(?:::\w+)*)\]$/)
324
+ m&.[](1)
325
+ end
326
+
327
+ # ------------------------------------------------------------------
328
+ # Inner classes
329
+ # ------------------------------------------------------------------
330
+
331
+ # Collects all RHS nodes for `@ivar = rhs` and `@ivar ||= rhs` writes.
332
+ class IvarTypeFinder < Prism::Visitor
333
+ def initialize(ivar_name)
334
+ @ivar_name = ivar_name
335
+ @results = []
336
+ end
337
+
338
+ def find_all(ast)
339
+ visit(ast)
340
+ @results
341
+ end
342
+
343
+ def visit_instance_variable_write_node(node)
344
+ @results << node.value if node.name.to_s == @ivar_name
345
+ super
346
+ end
347
+
348
+ def visit_instance_variable_or_write_node(node)
349
+ @results << node.value if node.name.to_s == @ivar_name
350
+ super
351
+ end
352
+ end
353
+
354
+ # Finds the name of the innermost def containing a target line.
355
+ class EnclosingDefFinder < Prism::Visitor
356
+ def initialize(target_line)
357
+ @target_line = target_line
358
+ @best_name = nil
359
+ @best_span = nil
360
+ end
361
+
362
+ def find(ast)
363
+ visit(ast)
364
+ @best_name
365
+ end
366
+
367
+ def visit_def_node(node)
368
+ loc = node.location
369
+ return super unless @target_line >= loc.start_line && @target_line <= loc.end_line
370
+
371
+ span = loc.end_line - loc.start_line
372
+ if @best_span.nil? || span < @best_span
373
+ @best_span = span
374
+ @best_name = node.name.to_s
375
+ end
376
+ super
377
+ end
378
+ end
379
+
380
+ class AssignmentFinder < Prism::Visitor
381
+ def initialize(const_name)
382
+ @const_name = const_name
383
+ @result = nil
384
+ end
385
+
386
+ def find(ast)
387
+ visit(ast)
388
+ @result
389
+ end
390
+
391
+ def visit_constant_write_node(node)
392
+ @result = node.value if node.name.to_s == @const_name
393
+ super
394
+ end
395
+
396
+ def visit_constant_path_write_node(node)
397
+ @result = node.value if node.target.slice == @const_name
398
+ super
399
+ end
400
+ end
401
+
402
+ # Collects all RHS nodes for `var_name = <rhs>` local variable writes.
403
+ class LocalAssignmentFinder < Prism::Visitor
404
+ def initialize(var_name)
405
+ @var_name = var_name
406
+ @results = []
407
+ end
408
+
409
+ def find_all(ast)
410
+ visit(ast)
411
+ @results
412
+ end
413
+
414
+ def visit_local_variable_write_node(node)
415
+ @results << node.value if node.name.to_s == @var_name
416
+ super
417
+ end
418
+
419
+ def visit_local_variable_operator_write_node(node)
420
+ @results << node.value if node.name.to_s == @var_name
421
+ super
422
+ end
423
+
424
+ def visit_local_variable_or_write_node(node)
425
+ @results << node.value if node.name.to_s == @var_name
426
+ super
427
+ end
428
+ end
429
+
430
+ # Finds the CallNode whose block introduces `var_name` as a parameter.
431
+ # e.g. entries.each { |entry| } → enclosing_call = entries.each(...)
432
+ class BlockParamFinder < Prism::Visitor
433
+ attr_reader :enclosing_call
434
+
435
+ def initialize(var_name)
436
+ @var_name = var_name
437
+ @enclosing_call = nil
438
+ @call_stack = []
439
+ end
440
+
441
+ def visit_call_node(node)
442
+ @call_stack.push(node)
443
+ super
444
+ @call_stack.pop
445
+ end
446
+
447
+ def visit_block_node(node)
448
+ return super if @enclosing_call
449
+
450
+ # BlockParametersNode wraps a ParametersNode which holds .requireds
451
+ block_params = node.parameters
452
+ return super unless block_params
453
+
454
+ inner = block_params.is_a?(Prism::BlockParametersNode) ? block_params.parameters : block_params
455
+ return super unless inner
456
+
457
+ found = inner.requireds&.any? do |p|
458
+ p.is_a?(Prism::RequiredParameterNode) && p.name.to_s == @var_name
459
+ end
460
+
461
+ if found
462
+ @enclosing_call = @call_stack.last
463
+ else
464
+ super
465
+ end
466
+ end
467
+ end
468
+ end
469
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module PinkSpoon
6
+ # Maps a fully-qualified type + method name to a source file and line number
7
+ # inside the installed gem.
8
+ #
9
+ # Strategy:
10
+ # 1. Convert the type name to a likely gem name (heuristic + Bundler lookup).
11
+ # 2. Find the gem's source directory via Gem::Specification.
12
+ # 3. Convert the type to a file path (Prometheus::Client::Gauge → prometheus/client/gauge.rb).
13
+ # 4. Parse that file with Prism and find the def line.
14
+ # 5. If not found in the direct file, walk parent classes via Prism.
15
+ class DefinitionFinder
16
+ def initialize(root_path)
17
+ @root_path = root_path
18
+ setup_bundler
19
+ end
20
+
21
+ # Returns { file: absolute_path, line: integer } or nil.
22
+ def find(type, method_name)
23
+ return nil unless type && method_name
24
+
25
+ candidates = candidate_files(type)
26
+ candidates.each do |file|
27
+ next unless File.exist?(file)
28
+ line = method_line(file, method_name.to_s)
29
+ return { file: file, line: line } if line
30
+ end
31
+
32
+ nil
33
+ end
34
+
35
+ private
36
+
37
+ def setup_bundler
38
+ gemfile = File.join(@root_path, "Gemfile")
39
+ return unless File.exist?(gemfile)
40
+
41
+ # Load Bundler's gem paths without clobbering the current environment.
42
+ require "bundler"
43
+ Bundler.load.specs # warm the spec cache
44
+ rescue => e
45
+ $stderr.puts "[pink-spoon] Bundler setup skipped: #{e.message}"
46
+ end
47
+
48
+ # ------------------------------------------------------------------
49
+ # Build a list of candidate source files for the given type.
50
+ # ------------------------------------------------------------------
51
+ def candidate_files(type)
52
+ parts = type.delete_prefix("::").split("::")
53
+
54
+ files = []
55
+
56
+ parts.length.downto(1) do |depth|
57
+ tail = parts.last(depth)
58
+
59
+ # Generate two suffix variants: standard underscore and a form where the
60
+ # first component is simply lowercased (handles acronyms like RSpec → rspec
61
+ # vs the incorrect underscore form r_spec).
62
+ suffixes = [
63
+ tail.map { |p| underscore(p) }.join("/") + ".rb",
64
+ ([tail.first.downcase] + tail.drop(1).map { |p| underscore(p) }).join("/") + ".rb",
65
+ ].uniq
66
+
67
+ # Use both underscore and plain-downcase as gem hints for the same reason.
68
+ gem_hints = [underscore(parts.first), parts.first.downcase].uniq
69
+
70
+ gem_hints.each do |hint|
71
+ gem_dirs_for(hint).each do |gem_dir|
72
+ suffixes.each do |suffix|
73
+ files << File.join(gem_dir, "lib", suffix)
74
+ files << File.join(gem_dir, suffix)
75
+ end
76
+ end
77
+ end
78
+
79
+ # Broader fallback: try all gem dirs.
80
+ all_gem_dirs.each do |gem_dir|
81
+ suffixes.each do |suffix|
82
+ files << File.join(gem_dir, "lib", suffix)
83
+ end
84
+ end
85
+ end
86
+
87
+ files.uniq
88
+ end
89
+
90
+ # Gem dirs whose name starts with the hint (e.g. "prometheus" → prometheus-client-*).
91
+ def gem_dirs_for(hint)
92
+ Gem::Specification.each.filter_map do |spec|
93
+ spec.gem_dir if spec.name.start_with?(hint) || spec.name.include?(hint)
94
+ end
95
+ rescue
96
+ []
97
+ end
98
+
99
+ def all_gem_dirs
100
+ @all_gem_dirs ||= Gem::Specification.map(&:gem_dir)
101
+ rescue
102
+ []
103
+ end
104
+
105
+ # ------------------------------------------------------------------
106
+ # Find the line number of `def method_name` inside a Ruby file.
107
+ # ------------------------------------------------------------------
108
+ def method_line(file, method_name)
109
+ result = Prism.parse_file(file)
110
+ finder = DefFinder.new(method_name)
111
+ finder.visit(result.value)
112
+ finder.line
113
+ rescue
114
+ nil
115
+ end
116
+
117
+ def underscore(str)
118
+ str
119
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
120
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
121
+ .downcase
122
+ .tr("-", "_")
123
+ end
124
+
125
+ # ------------------------------------------------------------------
126
+ # Finds the line of `def <name>` in a parsed AST.
127
+ # ------------------------------------------------------------------
128
+ class DefFinder < Prism::Visitor
129
+ attr_reader :line
130
+
131
+ def initialize(method_name)
132
+ @method_name = method_name
133
+ @line = nil
134
+ end
135
+
136
+ def visit_def_node(node)
137
+ if node.name.to_s == @method_name
138
+ @line ||= node.location.start_line
139
+ end
140
+ super
141
+ end
142
+ end
143
+ end
144
+ end