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 +7 -0
- data/bin/install-addon +34 -0
- data/bin/pink-spoon +8 -0
- data/lib/pink_spoon/constant_resolver.rb +469 -0
- data/lib/pink_spoon/definition_finder.rb +144 -0
- data/lib/pink_spoon/doc_extractor.rb +265 -0
- data/lib/pink_spoon/rbi_index.rb +334 -0
- data/lib/pink_spoon/server.rb +173 -0
- data/lib/pink_spoon/version.rb +5 -0
- data/lib/pink_spoon.rb +7 -0
- data/lib/ruby_lsp/pink_spoon/addon.rb +52 -0
- data/lib/ruby_lsp/pink_spoon/code_lens_listener.rb +57 -0
- data/lib/ruby_lsp/pink_spoon/completion_listener.rb +200 -0
- data/lib/ruby_lsp/pink_spoon/definition_listener.rb +425 -0
- data/lib/ruby_lsp/pink_spoon/hover_listener.rb +94 -0
- metadata +80 -0
|
@@ -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
|