pink_spoon 0.1.3 → 0.1.4

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: 7a1dfe02635dfec7fd176f0727ddbf7feff1a23e17ef63fdcee94c283a4e008c
4
- data.tar.gz: 355c66c3af0e1cb8169d2b3b2887f161558376578a9b009bca577943d536e460
3
+ metadata.gz: 9b276ba7405e08362bb430d60c37fc1620923b76c64da8484d08108ac6206e18
4
+ data.tar.gz: 31f275f9b72e231be93b8d9f9377496bd507b2e40d7dd1c6eb6c85eda80855d5
5
5
  SHA512:
6
- metadata.gz: f9682658394a75acd7987bbe67df72a15041a81fe47cb8d560df327410d05b62dc421808341c8f7337d84b084b331d197dab0588d86ffdc6d4b5184cf44fe869
7
- data.tar.gz: a5738766fbfe3171f40148117075fa5379a5aa25893387ad43db668558f0e6b2e1786164d4b7fd6077f6352df4242da1abb447191b7672ee926bd51327afe8dd
6
+ metadata.gz: c8ba5f82e91947b993ae5784281518a2dc2c2685c2ecb6fd931eb85e56ae5342081d47457a9f31ce35b8ef2df4905f4cabd50974246004c443bea8af98b9b19c
7
+ data.tar.gz: 1dfcdad69156b586eebdaa38f4d5aba482c43f8cb3cc1f5cb906ff6e7727cdc02132d909003f05a9ac3ce557ddc06538bd0549ae16ce14b580b573df7bf9ea3e
@@ -18,6 +18,24 @@ module PinkSpoon
18
18
  class ConstantResolver
19
19
  PASSTHROUGH_METHODS = %w[freeze dup clone tap then yield_self].freeze
20
20
 
21
+ # These always return String regardless of receiver type.
22
+ UNIVERSAL_STRING_METHODS = %w[to_s to_str inspect].freeze
23
+
24
+ # String methods that return String when called on a String receiver.
25
+ # Used as a fallback when no RBI data is available for the core library.
26
+ STRING_SELF_RETURNING_METHODS = %w[
27
+ tr tr_s encode force_encoding b
28
+ gsub sub gsub! sub!
29
+ strip lstrip rstrip strip! lstrip! rstrip!
30
+ chomp chop chomp! chop!
31
+ upcase downcase swapcase capitalize
32
+ upcase! downcase! swapcase! capitalize!
33
+ reverse reverse! squeeze delete scrub
34
+ unicode_normalize unicode_normalize!
35
+ replace insert prepend center ljust rjust
36
+ slice [] + * %
37
+ ].freeze
38
+
21
39
  RSPEC_EXAMPLE_GROUP_METHODS = %w[
22
40
  it example specify
23
41
  context describe
@@ -250,11 +268,19 @@ module PinkSpoon
250
268
 
251
269
  def follow_chain(type, method_name)
252
270
  return type if PASSTHROUGH_METHODS.include?(method_name)
271
+ return "String" if UNIVERSAL_STRING_METHODS.include?(method_name)
253
272
  return nil unless type
254
273
 
255
274
  clean = unwrap_sorbet_wrapper(type)
256
- ret = @rbi_index.return_type_for(clean, method_name)&.delete_prefix("::")
257
- ret ? unwrap_sorbet_wrapper(ret) : nil
275
+ # Strip generic parameters before RBI lookup: T::Array[Account] → T::Array
276
+ base = clean.sub(/\[.*\]\z/, "")
277
+ ret = @rbi_index.return_type_for(base, method_name)&.delete_prefix("::")
278
+ return unwrap_sorbet_wrapper(ret) if ret
279
+
280
+ # Fall back to known String method return types when RBI data is absent.
281
+ return base if base == "String" && STRING_SELF_RETURNING_METHODS.include?(method_name)
282
+
283
+ nil
258
284
  end
259
285
 
260
286
  def unwrap_sorbet_wrapper(type)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module PinkSpoon
4
6
  # Fetches YARD/RDoc comments from installed gem source files for a given
5
7
  # type + method. Handles both regular `def` and DSL-style definitions like
@@ -81,6 +83,22 @@ module PinkSpoon
81
83
  nil
82
84
  end
83
85
 
86
+ # Returns hover markdown for a known file+line (used as fallback when
87
+ # find_constant_location cannot locate the constant via gem-hint scanning).
88
+ def extract_for_location(loc)
89
+ return nil unless loc
90
+
91
+ raw = read_comments(loc[:file], loc[:line])
92
+ if raw.empty?
93
+ decl = declaration_line(loc[:file], loc[:line])
94
+ decl ? "```ruby\n#{decl}\n```" : nil
95
+ else
96
+ render(raw)
97
+ end
98
+ rescue
99
+ nil
100
+ end
101
+
84
102
  # Returns { file: absolute_path, line: integer } or nil.
85
103
  def find_source(type, method_name)
86
104
  cache_key = "loc:#{type}##{method_name}"
@@ -101,6 +119,8 @@ module PinkSpoon
101
119
  if loc
102
120
  raw = read_comments(loc[:file], loc[:line])
103
121
  raw.empty? ? nil : render(raw)
122
+ else
123
+ extract_via_ri(type, method_name)
104
124
  end
105
125
  end
106
126
  rescue
@@ -114,45 +134,52 @@ module PinkSpoon
114
134
  name = parts.last
115
135
  hint = parts.first.to_s.downcase
116
136
  escaped = Regexp.escape(name)
137
+ target = parts.join("::")
117
138
 
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
-
139
+ # Gem files: fast single-line regex scan.
140
+ # Gem source is usually well-structured (one class per file, inline namespaces),
141
+ # so the regex false-positive risk is low and parsing every gem file is too slow.
142
+ gem_pattern = /^\s*(?:(?:class|module)\s+(?:\w+::)*#{escaped}\b|#{escaped}\s*=)/
122
143
  gem_dirs_for(hint).each do |gem_dir|
123
144
  Dir.glob("#{gem_dir}/lib/**/*.rb").sort.each do |file|
124
- line = first_line_matching(file, pattern)
145
+ line = first_line_matching(file, gem_pattern)
125
146
  return { file: file, line: line } if line
126
147
  end
127
148
  end
128
149
 
129
- # For project files: try the conventional Zeitwerk path first so that a
130
- # fully-qualified type like Acme::Billing::Worker resolves directly to
131
- # lib/acme/billing/worker.rb rather than returning the first alphabetical
132
- # match from a glob (which may be an unrelated class with the same name).
150
+ # Project files: parse with Prism so we track the fully-qualified name.
151
+ # A regex on individual lines cannot tell whether `module Restrictions` is
152
+ # top-level or nested inside `module Routes { module Restrictions }` — only
153
+ # a full parse can distinguish them.
154
+ prefilter = /\b#{escaped}\b/
155
+
133
156
  %w[lib app spec test].each do |subdir|
134
157
  dir = File.join(@root_path, subdir)
135
158
  next unless File.directory?(dir)
136
159
 
137
160
  if parts.size > 1
161
+ # Zeitwerk conventional path: try the direct file first.
138
162
  candidate = File.join(dir, *parts.map { |p| underscore(p) }) + ".rb"
139
163
  if File.exist?(candidate)
140
- line = first_line_matching(candidate, pattern)
164
+ line = exact_constant_line(candidate, target)
141
165
  return { file: candidate, line: line || 1 } if line
142
166
  end
143
- # Glob fallback: require the file path to contain the full
144
- # intermediate namespace so that lib/nexus/au/.../download.rb is
145
- # not returned when looking for Nexus::Gb::Bacs::Form3::Retrievals::Download.
146
- # Also accepts a single-file namespace (lib/my_gem.rb for MyGem::Foo).
147
- intermediate = parts[0..-2].map { |p| underscore(p) }.join("/")
148
- Dir.glob("#{dir}/**/*.rb").sort.each do |file|
149
- next unless file.include?("/#{intermediate}/") || file.end_with?("/#{intermediate}.rb")
150
- line = first_line_matching(file, pattern)
151
- return { file: file, line: line } if line
167
+ # Glob fallback with progressive namespace prefix shortening.
168
+ # Full prefix first preserves false-positive safety for deep hierarchies.
169
+ all_files = Dir.glob("#{dir}/**/*.rb").sort
170
+ (parts.size - 1).downto(1) do |depth|
171
+ intermediate = parts[0...depth].map { |p| underscore(p) }.join("/")
172
+ all_files.each do |file|
173
+ next unless file.include?("/#{intermediate}/") || file.end_with?("/#{intermediate}.rb")
174
+ next unless first_line_matching(file, prefilter)
175
+ line = exact_constant_line(file, target)
176
+ return { file: file, line: line } if line
177
+ end
152
178
  end
153
179
  else
154
180
  Dir.glob("#{dir}/**/*.rb").sort.each do |file|
155
- line = first_line_matching(file, pattern)
181
+ next unless first_line_matching(file, prefilter)
182
+ line = exact_constant_line(file, target)
156
183
  return { file: file, line: line } if line
157
184
  end
158
185
  end
@@ -161,6 +188,21 @@ module PinkSpoon
161
188
  nil
162
189
  end
163
190
 
191
+ # Returns the line number where +target+ (fully-qualified, e.g. "Restrictions"
192
+ # or "Routes::Restrictions") is defined in +file+, or nil if not found.
193
+ # Parses the file with Prism and tracks the scope stack so that a nested
194
+ # `module Restrictions` inside `module Routes` is NOT mistaken for the
195
+ # top-level `Restrictions` module.
196
+ def exact_constant_line(file, target)
197
+ source = File.read(file)
198
+ result = Prism.parse(source)
199
+ finder = ConstantDefinitionFinder.new(target)
200
+ finder.visit(result.value)
201
+ finder.line
202
+ rescue
203
+ nil
204
+ end
205
+
164
206
  def underscore(str)
165
207
  str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
166
208
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
@@ -224,7 +266,11 @@ module PinkSpoon
224
266
  nil
225
267
  end
226
268
 
269
+ DIRECTIVE_RE = /^#\s*(?:rubocop:|frozen_string_literal:|typed:|encoding:|source:\/\/)/.freeze
270
+
227
271
  # Reads the contiguous comment block immediately above line_no (1-based).
272
+ # Stops at non-comment lines and at directive comments (rubocop, sorbet typed,
273
+ # frozen_string_literal, encoding, source://) which are never documentation.
228
274
  def read_comments(file, line_no)
229
275
  lines = File.readlines(file, chomp: true)
230
276
  block = []
@@ -233,6 +279,7 @@ module PinkSpoon
233
279
  while i >= 0
234
280
  stripped = lines[i].strip
235
281
  break unless stripped.start_with?("#")
282
+ break if stripped.match?(DIRECTIVE_RE)
236
283
  block.unshift(stripped.sub(/^#[ \t]?/, ""))
237
284
  i -= 1
238
285
  end
@@ -250,6 +297,20 @@ module PinkSpoon
250
297
  []
251
298
  end
252
299
 
300
+ # Fetches documentation for core Ruby types (String, Integer, etc.) via the
301
+ # `ri` tool. Used when `find_location` returns nil because the type has no
302
+ # gem source (built-in C extension methods like String#tr, String#encode).
303
+ def extract_via_ri(type, method_name)
304
+ out, _err, status = Open3.capture3("ri", "--no-pager", "--format=markdown", "#{type}##{method_name}")
305
+ return nil unless status.success?
306
+ content = out.strip
307
+ return nil if content.empty? || content.include?("Nothing known about")
308
+ # Strip the "# Type#method" title — the hover panel renders its own header.
309
+ content.sub(/\A#[^\n]+\n+/, "").strip.then { |s| s.empty? ? nil : s }
310
+ rescue
311
+ nil
312
+ end
313
+
253
314
  YARD_TAG_RE = /^@(\w+)(?:\s+(.*))?$/.freeze
254
315
 
255
316
  # Converts a raw comment array into a markdown string.
@@ -289,5 +350,42 @@ module PinkSpoon
289
350
  result = output.join("\n").strip
290
351
  result.empty? ? nil : result
291
352
  end
353
+
354
+ # Walks a Prism AST and records the line where +target+ is defined.
355
+ # Tracks the full qualified name via a scope stack, so a nested
356
+ # `module Restrictions` inside `module Routes` only matches
357
+ # "Routes::Restrictions", never bare "Restrictions".
358
+ class ConstantDefinitionFinder < Prism::Visitor
359
+ attr_reader :line
360
+
361
+ def initialize(target)
362
+ @target = target.delete_prefix("::")
363
+ @scope = []
364
+ @line = nil
365
+ end
366
+
367
+ def visit_module_node(node)
368
+ push_scope(node.constant_path.slice.delete_prefix("::"), node.location.start_line) { super }
369
+ end
370
+
371
+ def visit_class_node(node)
372
+ push_scope(node.constant_path.slice.delete_prefix("::"), node.location.start_line) { super }
373
+ end
374
+
375
+ def visit_constant_write_node(node)
376
+ fqn = (@scope + [node.name.to_s]).join("::")
377
+ @line ||= node.location.start_line if fqn == @target
378
+ super
379
+ end
380
+
381
+ private
382
+
383
+ def push_scope(name, start_line)
384
+ @scope.push(name)
385
+ @line ||= start_line if @scope.join("::") == @target
386
+ yield
387
+ @scope.pop
388
+ end
389
+ end
292
390
  end
293
391
  end
@@ -332,6 +332,15 @@ module PinkSpoon
332
332
  super
333
333
  end
334
334
 
335
+ def visit_constant_write_node(node)
336
+ source_comment = source_comment_at(node.location.start_line)
337
+ if source_comment
338
+ full_name = (@scope + [node.name.to_s]).join("::")
339
+ @const_sources[full_name] = source_comment
340
+ end
341
+ super
342
+ end
343
+
335
344
  def visit_def_node(node)
336
345
  method_name = node.name.to_s
337
346
  sig = @pending_sig
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PinkSpoon
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
@@ -36,6 +36,10 @@ module RubyLsp
36
36
  INSERT_FORMAT_SNIPPET = 2
37
37
  INSERT_FORMAT_PLAINTEXT = 1
38
38
 
39
+ # Methods universal to all Ruby objects — excluded from core-type completions
40
+ # so we don't flood the list with nil?, frozen?, object_id, etc.
41
+ OBJECT_METHODS = Object.public_instance_methods.freeze
42
+
39
43
  def initialize(response_builder, node_context, dispatcher, uri, resolver, rbi_index)
40
44
  @response_builder = response_builder
41
45
  @node_context = node_context
@@ -81,7 +85,10 @@ module RubyLsp
81
85
 
82
86
  range = loc_to_range(node.message_loc || node.location)
83
87
 
84
- @rbi_index.methods_for(type).each do |method_name, sig_or_def|
88
+ rbi_methods = @rbi_index.methods_for(type)
89
+ methods = rbi_methods.empty? ? core_ruby_methods_for(type) : rbi_methods
90
+
91
+ methods.each do |method_name, sig_or_def|
85
92
  @response_builder << Interface::CompletionItem.new(
86
93
  label: method_name,
87
94
  filter_text: method_name,
@@ -188,6 +195,18 @@ module RubyLsp
188
195
  end
189
196
  end
190
197
 
198
+ # Returns method names for core Ruby types (String, Integer, Array, etc.)
199
+ # via reflection when the RBI index has no entries for the type.
200
+ # Excludes Object/Kernel/BasicObject methods to keep the list focused.
201
+ def core_ruby_methods_for(type)
202
+ klass = Object.const_get(type)
203
+ return {} unless klass.is_a?(Module)
204
+ (klass.public_instance_methods - OBJECT_METHODS).sort
205
+ .each_with_object({}) { |m, h| h[m.to_s] = nil }
206
+ rescue NameError, TypeError
207
+ {}
208
+ end
209
+
191
210
  # Converts a Prism location (1-based lines) to an LSP Range (0-based).
192
211
  def loc_to_range(loc)
193
212
  Interface::Range.new(
@@ -83,7 +83,66 @@ module RubyLsp
83
83
 
84
84
  def on_constant_read_node_enter(node)
85
85
  return unless node.equal?(@node_context.node)
86
- goto_constant(node.name.to_s)
86
+
87
+ bare_name = node.name.to_s
88
+ nesting = @node_context.instance_variable_get(:@nesting_nodes)
89
+
90
+ # When the constant is the leading segment of a :: path, look up the full
91
+ # path first. This prevents a same-named ancestor namespace (e.g.
92
+ # Routes::Restrictions) from winning over the intended top-level one.
93
+ if (full_path = leading_constant_path(node))
94
+ lexical_candidates(full_path, nesting).each do |candidate|
95
+ file_loc = @rbi_index.const_source_for(candidate)
96
+ # Reject wrong RBI entries — Tapioca can point to the wrong file.
97
+ if file_loc && @doc_extractor
98
+ file_loc = nil unless @doc_extractor.exact_constant_line(file_loc[:file], candidate)
99
+ end
100
+ file_loc ||= @doc_extractor&.find_constant_source(candidate)
101
+ next unless file_loc
102
+
103
+ # Right file found via the full path. Navigate to where bare_name is
104
+ # defined within it (the enclosing module, not the child class).
105
+ line = @doc_extractor&.exact_constant_line(file_loc[:file], bare_name)
106
+ # Navigate even when bare_name has no separate declaration
107
+ # (e.g. `class Restrictions::CreditorRestrictor` with no `module Restrictions`).
108
+ # The RBI verification above already confirmed this is the right file.
109
+ push_location(file_loc[:file], line || 1)
110
+ return
111
+ end
112
+ end
113
+
114
+ # Successor-filter fallback: read the immediate child from the parent
115
+ # ConstantPathNode directly. This runs whether or not leading_constant_path
116
+ # succeeded — the parent is always available from @node_context. Candidate
117
+ # "Routes::Restrictions" is rejected when "Routes::Restrictions::CreditorRestrictor"
118
+ # doesn't exist; "Restrictions" is accepted because "Restrictions::CreditorRestrictor" does.
119
+ path_parent = @node_context.parent
120
+ if path_parent.is_a?(Prism::ConstantPathNode)
121
+ suffix = path_parent.child.slice.delete_prefix("::") rescue nil
122
+ if suffix && !suffix.empty?
123
+ lexical_candidates(bare_name, nesting).each do |candidate|
124
+ qualified_child = "#{candidate}::#{suffix}"
125
+ child_loc = @rbi_index.const_source_for(qualified_child)
126
+ if child_loc && @doc_extractor
127
+ child_loc = nil unless @doc_extractor.exact_constant_line(child_loc[:file], qualified_child)
128
+ end
129
+ child_loc ||= @doc_extractor&.find_constant_source(qualified_child)
130
+ next unless child_loc
131
+
132
+ location = @rbi_index.const_source_for(candidate)
133
+ if location && @doc_extractor
134
+ location = nil unless @doc_extractor.exact_constant_line(location[:file], candidate)
135
+ end
136
+ location ||= @doc_extractor&.find_constant_source(candidate)
137
+ next unless location
138
+
139
+ push_location(location[:file], location[:line])
140
+ return
141
+ end
142
+ end
143
+ end
144
+
145
+ goto_constant(bare_name)
87
146
  end
88
147
 
89
148
  def on_constant_path_node_enter(node)
@@ -293,6 +352,15 @@ module RubyLsp
293
352
 
294
353
  lexical_candidates(bare_name, nesting).each do |candidate|
295
354
  location = @rbi_index.const_source_for(candidate)
355
+
356
+ # Verify the RBI source comment actually defines this constant.
357
+ # Tapioca can write a wrong path (e.g. Routes::Restrictions when the
358
+ # real constant is top-level Restrictions). Reject the RBI result when
359
+ # the target file does not define the expected fully-qualified name.
360
+ if location && @doc_extractor
361
+ location = nil unless @doc_extractor.exact_constant_line(location[:file], candidate)
362
+ end
363
+
296
364
  location ||= @doc_extractor&.find_constant_source(candidate)
297
365
  next unless location
298
366
 
@@ -355,6 +423,25 @@ module RubyLsp
355
423
  nil
356
424
  end
357
425
 
426
+ # Returns the full constant path string when +node+ is the leading
427
+ # constant of a ConstantPathNode (e.g. returns "Restrictions::CreditorRestrictor"
428
+ # when +node+ is the `Restrictions` ConstantReadNode in that expression).
429
+ # Returns nil when the constant stands alone (not part of a :: path).
430
+ #
431
+ # ruby-lsp sets NodeContext#parent to the immediate AST parent of the cursor
432
+ # node. For `Restrictions` in `Restrictions::CreditorRestrictor`, that parent
433
+ # is the ConstantPathNode. We confirm our node is the left-hand side by
434
+ # checking that the path's own .parent field (Prism's LHS of ::) is our node.
435
+ def leading_constant_path(node)
436
+ path_node = @node_context.parent
437
+ return nil unless path_node.is_a?(Prism::ConstantPathNode)
438
+ return nil unless path_node.parent.equal?(node)
439
+
440
+ path_node.slice.delete_prefix("::")
441
+ rescue
442
+ nil
443
+ end
444
+
358
445
  def push_location(file, line)
359
446
  target_uri = "file://#{file}"
360
447
 
@@ -180,6 +180,12 @@ module RubyLsp
180
180
  return unless @doc_extractor
181
181
 
182
182
  doc = @doc_extractor.extract_for_constant(type)
183
+
184
+ if doc.nil?
185
+ loc = @rbi_index.const_source_for(type)
186
+ doc = @doc_extractor.extract_for_location(loc) if loc
187
+ end
188
+
183
189
  return unless doc
184
190
 
185
191
  @response_builder.push("**`#{type}`**\n\n#{doc}", category: :documentation)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pink_spoon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jānis Harbs
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
10
+ date: 2026-06-25 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: prism
@@ -40,7 +39,6 @@ dependencies:
40
39
  version: '0.22'
41
40
  description: Augments ruby-lsp hover and go-to-definition with type information from
42
41
  sorbet/rbi/**/*.rbi. Drop it in your Gemfile and it just works.
43
- email:
44
42
  executables:
45
43
  - pink-spoon
46
44
  extensions: []
@@ -60,10 +58,8 @@ files:
60
58
  - lib/ruby_lsp/pink_spoon/completion_listener.rb
61
59
  - lib/ruby_lsp/pink_spoon/definition_listener.rb
62
60
  - lib/ruby_lsp/pink_spoon/hover_listener.rb
63
- homepage:
64
61
  licenses: []
65
62
  metadata: {}
66
- post_install_message:
67
63
  rdoc_options: []
68
64
  require_paths:
69
65
  - lib
@@ -78,8 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
74
  - !ruby/object:Gem::Version
79
75
  version: '0'
80
76
  requirements: []
81
- rubygems_version: 3.4.19
82
- signing_key:
77
+ rubygems_version: 3.6.2
83
78
  specification_version: 4
84
79
  summary: 'ruby-lsp addon: resolves types from Sorbet RBI files'
85
80
  test_files: []