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.
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PinkSpoon
4
+ # Fetches YARD/RDoc comments from installed gem source files for a given
5
+ # type + method. Handles both regular `def` and DSL-style definitions like
6
+ # `define_example_method :it` or `define_example_group_method :describe`.
7
+ #
8
+ # Gem lookup uses the downcased first namespace component so that
9
+ # `RSpec::Core::ExampleGroup` correctly finds the `rspec-core` gem
10
+ # rather than trying the broken underscore form `r_spec`.
11
+ class DocExtractor
12
+ def initialize(root_path)
13
+ @root_path = root_path
14
+ @cache = {}
15
+ end
16
+
17
+ # Returns { file:, line: } for the first assignment of ivar_name (@foo = ...)
18
+ # in any source file belonging to the given type's gem or project.
19
+ def find_ivar_in_type(type, ivar_name)
20
+ cache_key = "ivar:#{type}##{ivar_name}"
21
+ return @cache[cache_key] if @cache.key?(cache_key)
22
+
23
+ @cache[cache_key] = begin
24
+ escaped = Regexp.escape(ivar_name)
25
+ pattern = /#{escaped}\s*=/
26
+ hint = type.split("::").first.to_s.downcase
27
+
28
+ gem_dirs_for(hint).each do |gem_dir|
29
+ Dir.glob("#{gem_dir}/lib/**/*.rb").sort.each do |file|
30
+ line = first_line_matching(file, pattern)
31
+ return { file: file, line: line } if line
32
+ end
33
+ end
34
+
35
+ %w[lib app].each do |subdir|
36
+ dir = File.join(@root_path, subdir)
37
+ next unless File.directory?(dir)
38
+ Dir.glob("#{dir}/**/*.rb").sort.each do |file|
39
+ line = first_line_matching(file, pattern)
40
+ return { file: file, line: line } if line
41
+ end
42
+ end
43
+
44
+ nil
45
+ end
46
+ rescue
47
+ nil
48
+ end
49
+
50
+ # Returns { file: absolute_path, line: integer } for a class/module definition.
51
+ def find_constant_source(type)
52
+ cache_key = "const_loc:#{type}"
53
+ return @cache[cache_key] if @cache.key?(cache_key)
54
+
55
+ @cache[cache_key] = find_constant_location(type)
56
+ rescue
57
+ nil
58
+ end
59
+
60
+ # Returns markdown docs for a class/module/constant.
61
+ # When there is a YARD comment above the definition, renders it.
62
+ # When there is no comment, falls back to showing the declaration line itself.
63
+ def extract_for_constant(type)
64
+ cache_key = "const_doc:#{type}"
65
+ return @cache[cache_key] if @cache.key?(cache_key)
66
+
67
+ @cache[cache_key] = begin
68
+ loc = find_constant_location(type)
69
+ if loc
70
+ raw = read_comments(loc[:file], loc[:line])
71
+
72
+ if raw.empty?
73
+ decl = declaration_line(loc[:file], loc[:line])
74
+ decl ? "```ruby\n#{decl}\n```" : nil
75
+ else
76
+ render(raw)
77
+ end
78
+ end
79
+ end
80
+ rescue
81
+ nil
82
+ end
83
+
84
+ # Returns { file: absolute_path, line: integer } or nil.
85
+ def find_source(type, method_name)
86
+ cache_key = "loc:#{type}##{method_name}"
87
+ return @cache[cache_key] if @cache.key?(cache_key)
88
+
89
+ @cache[cache_key] = find_location(type, method_name)
90
+ rescue
91
+ nil
92
+ end
93
+
94
+ # Returns a markdown string with the doc comment, or nil.
95
+ def extract(type, method_name)
96
+ cache_key = "doc:#{type}##{method_name}"
97
+ return @cache[cache_key] if @cache.key?(cache_key)
98
+
99
+ @cache[cache_key] = begin
100
+ loc = find_location(type, method_name)
101
+ if loc
102
+ raw = read_comments(loc[:file], loc[:line])
103
+ raw.empty? ? nil : render(raw)
104
+ end
105
+ end
106
+ rescue
107
+ nil
108
+ end
109
+
110
+ private
111
+
112
+ def find_constant_location(type)
113
+ parts = type.delete_prefix("::").split("::")
114
+ name = parts.last
115
+ hint = parts.first.to_s.downcase
116
+ escaped = Regexp.escape(name)
117
+
118
+ # Matches class/module definitions AND constant value assignments.
119
+ # e.g. class Formatters, module Core, SOME_CONST = "value"
120
+ pattern = /^\s*(?:(?:class|module)\s+(?:\w+::)*#{escaped}\b|#{escaped}\s*=)/
121
+
122
+ gem_dirs_for(hint).each do |gem_dir|
123
+ Dir.glob("#{gem_dir}/lib/**/*.rb").sort.each do |file|
124
+ line = first_line_matching(file, pattern)
125
+ return { file: file, line: line } if line
126
+ end
127
+ end
128
+
129
+ # Fall back to project source (lib/, app/).
130
+ %w[lib app].each do |subdir|
131
+ dir = File.join(@root_path, subdir)
132
+ next unless File.directory?(dir)
133
+ Dir.glob("#{dir}/**/*.rb").sort.each do |file|
134
+ line = first_line_matching(file, pattern)
135
+ return { file: file, line: line } if line
136
+ end
137
+ end
138
+
139
+ nil
140
+ end
141
+
142
+ def first_line_matching(file, pattern)
143
+ File.foreach(file).with_index(1) { |l, i| return i if l.match?(pattern) }
144
+ nil
145
+ rescue
146
+ nil
147
+ end
148
+
149
+ def declaration_line(file, line_no)
150
+ line = File.readlines(file, chomp: true)[line_no - 1]&.strip
151
+ return nil if line.nil? || line.empty?
152
+ line.length > 120 ? "#{line[0..116]}..." : line
153
+ rescue
154
+ nil
155
+ end
156
+
157
+ def find_location(type, method_name)
158
+ gem_hint = type.split("::").first.to_s.downcase
159
+
160
+ gem_dirs_for(gem_hint).each do |gem_dir|
161
+ Dir.glob("#{gem_dir}/lib/**/*.rb").sort.each do |file|
162
+ line_no = find_method_line(file, method_name)
163
+ return { file: file, line: line_no } if line_no
164
+ end
165
+ end
166
+
167
+ # Fall back to project source (lib/, app/) for project-defined methods.
168
+ %w[lib app].each do |subdir|
169
+ dir = File.join(@root_path, subdir)
170
+ next unless File.directory?(dir)
171
+ Dir.glob("#{dir}/**/*.rb").sort.each do |file|
172
+ line_no = find_method_line(file, method_name)
173
+ return { file: file, line: line_no } if line_no
174
+ end
175
+ end
176
+
177
+ nil
178
+ end
179
+
180
+ # Searches a file for a method definition by name.
181
+ # Matches both regular defs and DSL-style macro calls:
182
+ # def method_name / def self.method_name
183
+ # some_macro :method_name (e.g. define_example_method :it)
184
+ def find_method_line(file, method_name)
185
+ escaped = Regexp.escape(method_name)
186
+ def_re = /\bdef\s+(?:self\.)?#{escaped}(?=[\s\(;]|\z)/
187
+ dsl_re = /\b\w[\w!?]*\s+:#{escaped}\s*(?:#.*)?$/
188
+
189
+ File.foreach(file).with_index(1) do |line, i|
190
+ stripped = line.strip
191
+ return i if stripped.match?(def_re) || stripped.match?(dsl_re)
192
+ end
193
+
194
+ nil
195
+ rescue
196
+ nil
197
+ end
198
+
199
+ # Reads the contiguous comment block immediately above line_no (1-based).
200
+ def read_comments(file, line_no)
201
+ lines = File.readlines(file, chomp: true)
202
+ block = []
203
+ i = line_no - 2 # one line above the target, 0-indexed
204
+
205
+ while i >= 0
206
+ stripped = lines[i].strip
207
+ break unless stripped.start_with?("#")
208
+ block.unshift(stripped.sub(/^#[ \t]?/, ""))
209
+ i -= 1
210
+ end
211
+
212
+ block
213
+ end
214
+
215
+ def gem_dirs_for(hint)
216
+ return [] if hint.empty?
217
+
218
+ Gem::Specification.each.filter_map do |spec|
219
+ spec.gem_dir if spec.name.start_with?(hint) || spec.name.include?(hint)
220
+ end
221
+ rescue
222
+ []
223
+ end
224
+
225
+ YARD_TAG_RE = /^@(\w+)(?:\s+(.*))?$/.freeze
226
+
227
+ # Converts a raw comment array into a markdown string.
228
+ def render(lines)
229
+ output = []
230
+
231
+ lines.each do |line|
232
+ if (m = line.match(YARD_TAG_RE))
233
+ tag, rest = m[1], m[2].to_s.strip
234
+ case tag
235
+ when "param"
236
+ name, desc = rest.split(/\s+/, 2)
237
+ output << "**`#{name}`** — #{desc}"
238
+ when "return"
239
+ output << "**Returns:** #{rest}"
240
+ when "see"
241
+ output << "**See Also:** `#{rest}`"
242
+ when "example"
243
+ label = rest.empty? ? "" : " #{rest}"
244
+ output << "**Example:**#{label}"
245
+ when "raise", "raises"
246
+ output << "**Raises:** `#{rest}`"
247
+ when "deprecated"
248
+ output << "**Deprecated.** #{rest}"
249
+ when "note"
250
+ output << "> #{rest}"
251
+ when "api"
252
+ nil
253
+ else
254
+ output << "_@#{tag}_ #{rest}".strip
255
+ end
256
+ else
257
+ output << line
258
+ end
259
+ end
260
+
261
+ result = output.join("\n").strip
262
+ result.empty? ? nil : result
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module PinkSpoon
6
+ # Parses every sorbet/rbi/**/*.rbi file in the project and builds:
7
+ #
8
+ # @sigs[type][method] → sig string
9
+ # @types[type][method] → return type string
10
+ # @mixins[type] → [mixin_type, ...] from extend/include calls
11
+ #
12
+ # Type keys are fully qualified with "::" prefix normalised away.
13
+ # Lookups walk the mixin chain so Hesiod.register_gauge resolves even
14
+ # though the method is defined on Hesiod::Gauge (which Hesiod extends).
15
+ class RbiIndex
16
+ def initialize(root_path)
17
+ @root_path = root_path
18
+ @sigs = Hash.new { |h, k| h[k] = {} }
19
+ @types = Hash.new { |h, k| h[k] = {} }
20
+ @defs = Hash.new { |h, k| h[k] = {} }
21
+ @sources = Hash.new { |h, k| h[k] = {} }
22
+ @locations = Hash.new { |h, k| h[k] = {} }
23
+ @mixins = Hash.new { |h, k| h[k] = [] }
24
+ @params = Hash.new { |h, k| h[k] = {} }
25
+ build
26
+ end
27
+
28
+ # Returns the sig string for type#method, or nil.
29
+ # Follows extend/include chain when not found directly.
30
+ def signature_for(type, method_name, _seen = [])
31
+ t = normalise(type)
32
+ return nil if _seen.include?(t)
33
+
34
+ direct = @sigs[t][method_name.to_s]
35
+ return direct if direct
36
+
37
+ @mixins[t].each do |mixin|
38
+ result = signature_for(mixin, method_name, _seen + [t])
39
+ return result if result
40
+ end
41
+
42
+ nil
43
+ end
44
+
45
+ # Returns the return-type string for type#method, or nil.
46
+ # Follows extend/include chain when not found directly.
47
+ def return_type_for(type, method_name, _seen = [])
48
+ t = normalise(type)
49
+ return nil if _seen.include?(t)
50
+
51
+ direct = @types[t][method_name.to_s]
52
+ return direct if direct
53
+
54
+ @mixins[t].each do |mixin|
55
+ result = return_type_for(mixin, method_name, _seen + [t])
56
+ return result if result
57
+ end
58
+
59
+ nil
60
+ end
61
+
62
+ # Returns markdown hover content for type#method, or nil if we know
63
+ # nothing about the method at all. Prefers the Sorbet sig; falls back
64
+ # to the plain def line so callers always get something useful.
65
+ def hover_content_for(type, method_name, _seen = [])
66
+ t = normalise(type)
67
+ return nil if _seen.include?(t)
68
+
69
+ sig = @sigs[t][method_name.to_s]
70
+ def_line = @defs[t][method_name.to_s]
71
+ source = @sources[t][method_name.to_s]
72
+
73
+ if sig || def_line
74
+ parts = []
75
+ parts << source if source
76
+ parts << sig if sig
77
+ parts << def_line if def_line
78
+ body = parts.join("\n")
79
+ return "**`#{t}##{method_name}`** _(via RBI)_\n\n```ruby\n#{body}\n```"
80
+ end
81
+
82
+ @mixins[t].each do |mixin|
83
+ result = hover_content_for(mixin, method_name, _seen + [t])
84
+ return result if result
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ # Returns { file:, line: } for the .rbi definition of type#method, or nil.
91
+ # Follows extend/include/superclass chain when not found directly.
92
+ def rbi_location_for(type, method_name, _seen = [])
93
+ t = normalise(type)
94
+ return nil if _seen.include?(t)
95
+
96
+ loc = @locations[t][method_name.to_s]
97
+ return loc if loc
98
+
99
+ @mixins[t].each do |mixin|
100
+ result = rbi_location_for(mixin, method_name, _seen + [t])
101
+ return result if result
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ # Returns the direct parent/mixin types for the given type (one level, no chain walk).
108
+ def mixins_for(type)
109
+ @mixins[normalise(type)].dup
110
+ end
111
+
112
+ # Returns { param_name => type_string } for type#method, or nil.
113
+ # Follows the mixin chain.
114
+ def params_for(type, method_name, _seen = [])
115
+ t = normalise(type)
116
+ return nil if _seen.include?(t)
117
+
118
+ direct = @params[t][method_name.to_s]
119
+ return direct if direct && !direct.empty?
120
+
121
+ @mixins[t].each do |mixin|
122
+ result = params_for(mixin, method_name, _seen + [t])
123
+ return result if result && !result.empty?
124
+ end
125
+
126
+ nil
127
+ end
128
+
129
+ # Returns { method_name => sig_or_def_line } for a type including all mixins.
130
+ # Used by the completion listener to enumerate available methods.
131
+ def methods_for(type, _seen = [])
132
+ t = normalise(type)
133
+ return {} if _seen.include?(t)
134
+
135
+ result = {}
136
+ @mixins[t].each do |mixin|
137
+ result.merge!(methods_for(mixin, _seen + [t]))
138
+ end
139
+
140
+ @defs[t].each do |method, def_line|
141
+ result[method] = @sigs[t][method] || def_line
142
+ end
143
+
144
+ result
145
+ end
146
+
147
+ # All known types (for debugging / testing).
148
+ def types = @sigs.keys
149
+
150
+ private
151
+
152
+ def build
153
+ rbi_dir = File.join(@root_path, "sorbet", "rbi")
154
+ return unless File.directory?(rbi_dir)
155
+
156
+ Dir.glob("#{rbi_dir}/**/*.rbi").each do |path|
157
+ parse_file(path)
158
+ rescue => e
159
+ $stderr.puts "[pink-spoon] skipping #{path}: #{e.message}"
160
+ end
161
+
162
+ $stderr.puts "[pink-spoon] indexed #{@sigs.values.sum(&:size)} signatures " \
163
+ "across #{@sigs.size} types (#{@mixins.size} with mixins)"
164
+ end
165
+
166
+ def parse_file(path)
167
+ source = File.read(path)
168
+ result = Prism.parse(source)
169
+
170
+ comment_map = {}
171
+ result.comments.each { |c| comment_map[c.location.start_line] = c.slice }
172
+
173
+ visitor = RbiVisitor.new(comment_map)
174
+ visitor.visit(result.value)
175
+
176
+ visitor.entries.each do |entry|
177
+ type = normalise(entry[:type])
178
+ @sigs[type][entry[:method]] = entry[:sig]
179
+ @types[type][entry[:method]] = entry[:return_type]
180
+ @defs[type][entry[:method]] = entry[:def_line]
181
+ @params[type][entry[:method]] = entry[:params] if entry[:params]
182
+ @sources[type][entry[:method]] = entry[:source] if entry[:source]
183
+ @locations[type][entry[:method]] = { file: path, line: entry[:line] }
184
+ end
185
+
186
+ visitor.mixins.each do |type, mixin_list|
187
+ @mixins[normalise(type)].concat(mixin_list.map { normalise(_1) }).uniq!
188
+ end
189
+ end
190
+
191
+ def normalise(type)
192
+ type.to_s.delete_prefix("::")
193
+ end
194
+
195
+ # ------------------------------------------------------------------
196
+ # Prism AST visitor that walks RBI files and extracts:
197
+ # - sig+def pairs (entries)
198
+ # - extend/include calls (mixins)
199
+ # ------------------------------------------------------------------
200
+ class RbiVisitor < Prism::Visitor
201
+ attr_reader :entries, :mixins
202
+
203
+ def initialize(comment_map = {})
204
+ @entries = []
205
+ @mixins = Hash.new { |h, k| h[k] = [] }
206
+ @scope = []
207
+ @pending_sig = nil
208
+ @comment_map = comment_map
209
+ end
210
+
211
+ def visit_module_node(node)
212
+ push_scope(node.constant_path) { super }
213
+ end
214
+
215
+ def visit_class_node(node)
216
+ push_scope(node.constant_path) do
217
+ # Record superclass as a parent so method lookups walk the chain.
218
+ if node.superclass
219
+ type = @scope.join("::")
220
+ parent = const_path_to_string(node.superclass).delete_prefix("::")
221
+ @mixins[type] << parent unless @mixins[type].include?(parent)
222
+ end
223
+ super
224
+ end
225
+ end
226
+
227
+ def visit_call_node(node)
228
+ if node.name == :sig && node.receiver.nil?
229
+ @pending_sig = node.slice
230
+ elsif (node.name == :extend || node.name == :include) && node.receiver.nil?
231
+ record_mixins(node)
232
+ end
233
+ super
234
+ end
235
+
236
+ def visit_def_node(node)
237
+ method_name = node.name.to_s
238
+ sig = @pending_sig
239
+ @pending_sig = nil
240
+
241
+ # Build a plain def line even when no Sorbet sig exists.
242
+ # e.g. "def init_label_set(labels)"
243
+ params = node.parameters&.slice
244
+ def_line = params ? "def #{method_name}(#{params})" : "def #{method_name}"
245
+
246
+ # Check for a "# source://" comment on the line immediately before this def.
247
+ preceding = @comment_map[node.location.start_line - 1]
248
+ source = preceding&.match?(/# source:\/\//) ? preceding : nil
249
+
250
+ @entries << {
251
+ type: @scope.join("::"),
252
+ method: method_name,
253
+ sig: sig,
254
+ def_line: def_line,
255
+ return_type: sig ? extract_return_type(sig) : nil,
256
+ params: sig ? extract_params(sig) : {},
257
+ source: source,
258
+ line: node.location.start_line,
259
+ }
260
+
261
+ super
262
+ end
263
+
264
+ private
265
+
266
+ def record_mixins(call_node)
267
+ type = @scope.join("::")
268
+ return if type.empty?
269
+
270
+ call_node.arguments&.arguments&.each do |arg|
271
+ name = case arg
272
+ when Prism::ConstantReadNode then arg.name.to_s
273
+ when Prism::ConstantPathNode then arg.slice.delete_prefix("::")
274
+ end
275
+ @mixins[type] << name if name
276
+ end
277
+ end
278
+
279
+ def push_scope(const_path, &block)
280
+ @scope.push(const_path_to_string(const_path))
281
+ block.call
282
+ @scope.pop
283
+ end
284
+
285
+ def const_path_to_string(node)
286
+ return nil if node.nil?
287
+ case node
288
+ when Prism::ConstantReadNode then node.name.to_s
289
+ when Prism::ConstantPathNode then [const_path_to_string(node.parent), node.name.to_s].compact.join("::")
290
+ else node.slice
291
+ end
292
+ end
293
+
294
+ def extract_return_type(sig)
295
+ match = sig.match(/returns\(\s*([\w:]+)\s*\)/)
296
+ match&.[](1)
297
+ end
298
+
299
+ def extract_params(sig)
300
+ m = sig.match(/params\((.+)\)/m)
301
+ return {} unless m
302
+
303
+ params_str = m[1].gsub(/\s+/, " ").strip
304
+ result = {}
305
+ depth = 0
306
+ current = +""
307
+
308
+ params_str.each_char do |c|
309
+ case c
310
+ when "(", "[", "<" then depth += 1; current << c
311
+ when ")", "]", ">" then depth -= 1; current << c
312
+ when ","
313
+ if depth == 0
314
+ store_param(current.strip, result)
315
+ current = +""
316
+ else
317
+ current << c
318
+ end
319
+ else
320
+ current << c
321
+ end
322
+ end
323
+ store_param(current.strip, result) unless current.strip.empty?
324
+ result
325
+ end
326
+
327
+ def store_param(pair, result)
328
+ m = pair.match(/^(\w+):\s*(.+)$/)
329
+ return unless m
330
+ result[m[1]] = m[2].strip.delete_prefix("::")
331
+ end
332
+ end
333
+ end
334
+ end