ruby-lsp 0.18.3 → 0.19.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/VERSION +1 -1
  3. data/exe/ruby-lsp-check +1 -1
  4. data/lib/core_ext/uri.rb +9 -4
  5. data/lib/ruby_indexer/lib/ruby_indexer/configuration.rb +6 -0
  6. data/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb +66 -8
  7. data/lib/ruby_indexer/lib/ruby_indexer/entry.rb +63 -32
  8. data/lib/ruby_indexer/lib/ruby_indexer/index.rb +8 -5
  9. data/lib/ruby_indexer/lib/ruby_indexer/rbs_indexer.rb +52 -8
  10. data/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb +324 -0
  11. data/lib/ruby_indexer/ruby_indexer.rb +1 -0
  12. data/lib/ruby_indexer/test/classes_and_modules_test.rb +23 -0
  13. data/lib/ruby_indexer/test/constant_test.rb +8 -0
  14. data/lib/ruby_indexer/test/enhancements_test.rb +2 -0
  15. data/lib/ruby_indexer/test/index_test.rb +3 -0
  16. data/lib/ruby_indexer/test/instance_variables_test.rb +12 -0
  17. data/lib/ruby_indexer/test/method_test.rb +10 -0
  18. data/lib/ruby_indexer/test/rbs_indexer_test.rb +22 -0
  19. data/lib/ruby_indexer/test/reference_finder_test.rb +242 -0
  20. data/lib/ruby_lsp/addon.rb +79 -17
  21. data/lib/ruby_lsp/base_server.rb +6 -0
  22. data/lib/ruby_lsp/erb_document.rb +9 -3
  23. data/lib/ruby_lsp/global_state.rb +8 -0
  24. data/lib/ruby_lsp/internal.rb +5 -1
  25. data/lib/ruby_lsp/listeners/completion.rb +1 -1
  26. data/lib/ruby_lsp/listeners/hover.rb +57 -0
  27. data/lib/ruby_lsp/listeners/semantic_highlighting.rb +24 -21
  28. data/lib/ruby_lsp/requests/code_action_resolve.rb +9 -3
  29. data/lib/ruby_lsp/requests/completion.rb +1 -0
  30. data/lib/ruby_lsp/requests/completion_resolve.rb +29 -0
  31. data/lib/ruby_lsp/requests/definition.rb +1 -0
  32. data/lib/ruby_lsp/requests/document_highlight.rb +1 -1
  33. data/lib/ruby_lsp/requests/hover.rb +1 -0
  34. data/lib/ruby_lsp/requests/on_type_formatting.rb +1 -1
  35. data/lib/ruby_lsp/requests/range_formatting.rb +55 -0
  36. data/lib/ruby_lsp/requests/references.rb +146 -0
  37. data/lib/ruby_lsp/requests/rename.rb +196 -0
  38. data/lib/ruby_lsp/requests/signature_help.rb +6 -1
  39. data/lib/ruby_lsp/requests/support/common.rb +2 -2
  40. data/lib/ruby_lsp/requests/support/formatter.rb +3 -0
  41. data/lib/ruby_lsp/requests/support/rubocop_formatter.rb +6 -0
  42. data/lib/ruby_lsp/requests/support/source_uri.rb +8 -1
  43. data/lib/ruby_lsp/requests/support/syntax_tree_formatter.rb +8 -0
  44. data/lib/ruby_lsp/ruby_document.rb +23 -8
  45. data/lib/ruby_lsp/scope.rb +47 -0
  46. data/lib/ruby_lsp/server.rb +127 -34
  47. data/lib/ruby_lsp/static_docs.rb +15 -0
  48. data/lib/ruby_lsp/store.rb +12 -0
  49. data/lib/ruby_lsp/test_helper.rb +1 -1
  50. data/lib/ruby_lsp/type_inferrer.rb +6 -1
  51. data/lib/ruby_lsp/utils.rb +3 -6
  52. data/static_docs/yield.md +81 -0
  53. metadata +21 -8
  54. data/lib/ruby_lsp/parameter_scope.rb +0 -33
@@ -0,0 +1,242 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "test_helper"
5
+
6
+ module RubyIndexer
7
+ class ReferenceFinderTest < Minitest::Test
8
+ def test_finds_constant_references
9
+ refs = find_const_references("Foo::Bar", <<~RUBY)
10
+ module Foo
11
+ class Bar
12
+ end
13
+
14
+ Bar
15
+ end
16
+
17
+ Foo::Bar
18
+ RUBY
19
+
20
+ assert_equal("Bar", refs[0].name)
21
+ assert_equal(2, refs[0].location.start_line)
22
+
23
+ assert_equal("Bar", refs[1].name)
24
+ assert_equal(5, refs[1].location.start_line)
25
+
26
+ assert_equal("Foo::Bar", refs[2].name)
27
+ assert_equal(8, refs[2].location.start_line)
28
+ end
29
+
30
+ def test_finds_constant_references_inside_singleton_contexts
31
+ refs = find_const_references("Foo::<Class:Foo>::Bar", <<~RUBY)
32
+ class Foo
33
+ class << self
34
+ class Bar
35
+ end
36
+
37
+ Bar
38
+ end
39
+ end
40
+ RUBY
41
+
42
+ assert_equal("Bar", refs[0].name)
43
+ assert_equal(3, refs[0].location.start_line)
44
+
45
+ assert_equal("Bar", refs[1].name)
46
+ assert_equal(6, refs[1].location.start_line)
47
+ end
48
+
49
+ def test_finds_top_level_constant_references
50
+ refs = find_const_references("Bar", <<~RUBY)
51
+ class Bar
52
+ end
53
+
54
+ class Foo
55
+ ::Bar
56
+
57
+ class << self
58
+ ::Bar
59
+ end
60
+ end
61
+ RUBY
62
+
63
+ assert_equal("Bar", refs[0].name)
64
+ assert_equal(1, refs[0].location.start_line)
65
+
66
+ assert_equal("::Bar", refs[1].name)
67
+ assert_equal(5, refs[1].location.start_line)
68
+
69
+ assert_equal("::Bar", refs[2].name)
70
+ assert_equal(8, refs[2].location.start_line)
71
+ end
72
+
73
+ def test_finds_method_references
74
+ refs = find_method_references("foo", <<~RUBY)
75
+ class Bar
76
+ def foo
77
+ end
78
+
79
+ def baz
80
+ foo
81
+ end
82
+ end
83
+ RUBY
84
+
85
+ assert_equal(2, refs.size)
86
+
87
+ assert_equal("foo", refs[0].name)
88
+ assert_equal(2, refs[0].location.start_line)
89
+
90
+ assert_equal("foo", refs[1].name)
91
+ assert_equal(6, refs[1].location.start_line)
92
+ end
93
+
94
+ def test_does_not_mismatch_on_readers_and_writers
95
+ refs = find_method_references("foo", <<~RUBY)
96
+ class Bar
97
+ def foo
98
+ end
99
+
100
+ def foo=(value)
101
+ end
102
+
103
+ def baz
104
+ self.foo = 1
105
+ self.foo
106
+ end
107
+ end
108
+ RUBY
109
+
110
+ # We want to match `foo` but not `foo=`
111
+ assert_equal(2, refs.size)
112
+
113
+ assert_equal("foo", refs[0].name)
114
+ assert_equal(2, refs[0].location.start_line)
115
+
116
+ assert_equal("foo", refs[1].name)
117
+ assert_equal(10, refs[1].location.start_line)
118
+ end
119
+
120
+ def test_matches_writers
121
+ refs = find_method_references("foo=", <<~RUBY)
122
+ class Bar
123
+ def foo
124
+ end
125
+
126
+ def foo=(value)
127
+ end
128
+
129
+ def baz
130
+ self.foo = 1
131
+ self.foo
132
+ end
133
+ end
134
+ RUBY
135
+
136
+ # We want to match `foo=` but not `foo`
137
+ assert_equal(2, refs.size)
138
+
139
+ assert_equal("foo=", refs[0].name)
140
+ assert_equal(5, refs[0].location.start_line)
141
+
142
+ assert_equal("foo=", refs[1].name)
143
+ assert_equal(9, refs[1].location.start_line)
144
+ end
145
+
146
+ def test_find_inherited_methods
147
+ refs = find_method_references("foo", <<~RUBY)
148
+ class Bar
149
+ def foo
150
+ end
151
+ end
152
+
153
+ class Baz < Bar
154
+ super.foo
155
+ end
156
+ RUBY
157
+
158
+ assert_equal(2, refs.size)
159
+
160
+ assert_equal("foo", refs[0].name)
161
+ assert_equal(2, refs[0].location.start_line)
162
+
163
+ assert_equal("foo", refs[1].name)
164
+ assert_equal(7, refs[1].location.start_line)
165
+ end
166
+
167
+ def test_finds_methods_created_in_mixins
168
+ refs = find_method_references("foo", <<~RUBY)
169
+ module Mixin
170
+ def foo
171
+ end
172
+ end
173
+
174
+ class Bar
175
+ include Mixin
176
+ end
177
+
178
+ Bar.foo
179
+ RUBY
180
+
181
+ assert_equal(2, refs.size)
182
+
183
+ assert_equal("foo", refs[0].name)
184
+ assert_equal(2, refs[0].location.start_line)
185
+
186
+ assert_equal("foo", refs[1].name)
187
+ assert_equal(10, refs[1].location.start_line)
188
+ end
189
+
190
+ def test_finds_singleton_methods
191
+ # The current implementation matches on both `Bar.foo` and `Bar#foo` even though they are different
192
+
193
+ refs = find_method_references("foo", <<~RUBY)
194
+ class Bar
195
+ class << self
196
+ def foo
197
+ end
198
+ end
199
+
200
+ def foo
201
+ end
202
+ end
203
+
204
+ Bar.foo
205
+ RUBY
206
+
207
+ assert_equal(3, refs.size)
208
+
209
+ assert_equal("foo", refs[0].name)
210
+ assert_equal(3, refs[0].location.start_line)
211
+
212
+ assert_equal("foo", refs[1].name)
213
+ assert_equal(7, refs[1].location.start_line)
214
+
215
+ assert_equal("foo", refs[2].name)
216
+ assert_equal(11, refs[2].location.start_line)
217
+ end
218
+
219
+ private
220
+
221
+ def find_const_references(const_name, source)
222
+ target = ReferenceFinder::ConstTarget.new(const_name)
223
+ find_references(target, source)
224
+ end
225
+
226
+ def find_method_references(method_name, source)
227
+ target = ReferenceFinder::MethodTarget.new(method_name)
228
+ find_references(target, source)
229
+ end
230
+
231
+ def find_references(target, source)
232
+ file_path = "/fake.rb"
233
+ index = Index.new
234
+ index.index_single(IndexablePath.new(nil, file_path), source)
235
+ parse_result = Prism.parse(source)
236
+ dispatcher = Prism::Dispatcher.new
237
+ finder = ReferenceFinder.new(target, index, dispatcher)
238
+ dispatcher.visit(parse_result.value)
239
+ finder.references
240
+ end
241
+ end
242
+ end
@@ -2,7 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module RubyLsp
5
- # To register an addon, inherit from this class and implement both `name` and `activate`
5
+ # To register an add-on, inherit from this class and implement both `name` and `activate`
6
6
  #
7
7
  # # Example
8
8
  #
@@ -14,7 +14,7 @@ module RubyLsp
14
14
  # end
15
15
  #
16
16
  # def name
17
- # "My addon name"
17
+ # "My add-on name"
18
18
  # end
19
19
  # end
20
20
  # end
@@ -27,11 +27,13 @@ module RubyLsp
27
27
 
28
28
  @addons = T.let([], T::Array[Addon])
29
29
  @addon_classes = T.let([], T::Array[T.class_of(Addon)])
30
- # Addon instances that have declared a handler to accept file watcher events
30
+ # Add-on instances that have declared a handler to accept file watcher events
31
31
  @file_watcher_addons = T.let([], T::Array[Addon])
32
32
 
33
33
  AddonNotFoundError = Class.new(StandardError)
34
34
 
35
+ class IncompatibleApiError < StandardError; end
36
+
35
37
  class << self
36
38
  extend T::Sig
37
39
 
@@ -51,15 +53,30 @@ module RubyLsp
51
53
  super
52
54
  end
53
55
 
54
- # Discovers and loads all addons. Returns a list of errors when trying to require addons
56
+ # Discovers and loads all add-ons. Returns a list of errors when trying to require add-ons
55
57
  sig do
56
- params(global_state: GlobalState, outgoing_queue: Thread::Queue).returns(T::Array[StandardError])
58
+ params(
59
+ global_state: GlobalState,
60
+ outgoing_queue: Thread::Queue,
61
+ include_project_addons: T::Boolean,
62
+ ).returns(T::Array[StandardError])
57
63
  end
58
- def load_addons(global_state, outgoing_queue)
59
- # Require all addons entry points, which should be placed under
60
- # `some_gem/lib/ruby_lsp/your_gem_name/addon.rb`
61
- errors = Gem.find_files("ruby_lsp/**/addon.rb").filter_map do |addon|
62
- require File.expand_path(addon)
64
+ def load_addons(global_state, outgoing_queue, include_project_addons: true)
65
+ # Require all add-ons entry points, which should be placed under
66
+ # `some_gem/lib/ruby_lsp/your_gem_name/addon.rb` or in the workspace under
67
+ # `your_project/ruby_lsp/project_name/addon.rb`
68
+ addon_files = Gem.find_files("ruby_lsp/**/addon.rb")
69
+
70
+ if include_project_addons
71
+ addon_files.concat(Dir.glob(File.join(global_state.workspace_path, "**", "ruby_lsp/**/addon.rb")))
72
+ end
73
+
74
+ errors = addon_files.filter_map do |addon_path|
75
+ # Avoid requiring this file twice. This may happen if you're working on the Ruby LSP itself and at the same
76
+ # time have `ruby-lsp` installed as a vendored gem
77
+ next if File.basename(File.dirname(addon_path)) == "ruby_lsp"
78
+
79
+ require File.expand_path(addon_path)
63
80
  nil
64
81
  rescue => e
65
82
  e
@@ -69,7 +86,7 @@ module RubyLsp
69
86
  self.addons = addon_classes.map(&:new)
70
87
  self.file_watcher_addons = addons.select { |addon| addon.respond_to?(:workspace_did_change_watched_files) }
71
88
 
72
- # Activate each one of the discovered addons. If any problems occur in the addons, we don't want to
89
+ # Activate each one of the discovered add-ons. If any problems occur in the add-ons, we don't want to
73
90
  # fail to boot the server
74
91
  addons.each do |addon|
75
92
  addon.activate(global_state, outgoing_queue)
@@ -80,13 +97,53 @@ module RubyLsp
80
97
  errors
81
98
  end
82
99
 
83
- sig { params(addon_name: String).returns(Addon) }
84
- def get(addon_name)
100
+ # Get a reference to another add-on object by name and version. If an add-on exports an API that can be used by
101
+ # other add-ons, this is the way to get access to that API.
102
+ #
103
+ # Important: if the add-on is not found, AddonNotFoundError will be raised. If the add-on is found, but its
104
+ # current version does not satisfy the given version constraint, then IncompatibleApiError will be raised. It is
105
+ # the responsibility of the add-ons using this API to handle these errors appropriately.
106
+ sig { params(addon_name: String, version_constraints: String).returns(Addon) }
107
+ def get(addon_name, *version_constraints)
108
+ if version_constraints.empty?
109
+ raise IncompatibleApiError, "Must specify version constraints when accessing other add-ons"
110
+ end
111
+
85
112
  addon = addons.find { |addon| addon.name == addon_name }
86
- raise AddonNotFoundError, "Could not find addon '#{addon_name}'" unless addon
113
+ raise AddonNotFoundError, "Could not find add-on '#{addon_name}'" unless addon
114
+
115
+ version_object = Gem::Version.new(addon.version)
116
+
117
+ unless version_constraints.all? { |constraint| Gem::Requirement.new(constraint).satisfied_by?(version_object) }
118
+ raise IncompatibleApiError,
119
+ "Constraints #{version_constraints.inspect} is incompatible with #{addon_name} version #{addon.version}"
120
+ end
87
121
 
88
122
  addon
89
123
  end
124
+
125
+ # Depend on a specific version of the Ruby LSP. This method should only be used if the add-on is distributed in a
126
+ # gem that does not have a runtime dependency on the ruby-lsp gem. This method should be invoked at the top of the
127
+ # `addon.rb` file before defining any classes or requiring any files. For example:
128
+ #
129
+ # ```ruby
130
+ # RubyLsp::Addon.depend_on_ruby_lsp!(">= 0.18.0")
131
+ #
132
+ # module MyGem
133
+ # class MyAddon < RubyLsp::Addon
134
+ # # ...
135
+ # end
136
+ # end
137
+ # ```
138
+ sig { params(version_constraints: String).void }
139
+ def depend_on_ruby_lsp!(*version_constraints)
140
+ version_object = Gem::Version.new(RubyLsp::VERSION)
141
+
142
+ unless version_constraints.all? { |constraint| Gem::Requirement.new(constraint).satisfied_by?(version_object) }
143
+ raise IncompatibleApiError,
144
+ "Add-on is not compatible with this version of the Ruby LSP. Skipping its activation"
145
+ end
146
+ end
90
147
  end
91
148
 
92
149
  sig { void }
@@ -118,20 +175,25 @@ module RubyLsp
118
175
  @errors.map(&:full_message).join("\n\n")
119
176
  end
120
177
 
121
- # Each addon should implement `MyAddon#activate` and use to perform any sort of initialization, such as
178
+ # Each add-on should implement `MyAddon#activate` and use to perform any sort of initialization, such as
122
179
  # reading information into memory or even spawning a separate process
123
180
  sig { abstract.params(global_state: GlobalState, outgoing_queue: Thread::Queue).void }
124
181
  def activate(global_state, outgoing_queue); end
125
182
 
126
- # Each addon should implement `MyAddon#deactivate` and use to perform any clean up, like shutting down a
183
+ # Each add-on should implement `MyAddon#deactivate` and use to perform any clean up, like shutting down a
127
184
  # child process
128
185
  sig { abstract.void }
129
186
  def deactivate; end
130
187
 
131
- # Addons should override the `name` method to return the addon name
188
+ # Add-ons should override the `name` method to return the add-on name
132
189
  sig { abstract.returns(String) }
133
190
  def name; end
134
191
 
192
+ # Add-ons should override the `version` method to return a semantic version string representing the add-on's
193
+ # version. This is used for compatibility checks
194
+ sig { abstract.returns(String) }
195
+ def version; end
196
+
135
197
  # Creates a new CodeLens listener. This method is invoked on every CodeLens request
136
198
  sig do
137
199
  overridable.params(
@@ -130,6 +130,12 @@ module RubyLsp
130
130
  sig { abstract.void }
131
131
  def shutdown; end
132
132
 
133
+ sig { params(id: Integer, message: String, type: Integer).void }
134
+ def fail_request_and_notify(id, message, type: Constant::MessageType::INFO)
135
+ send_message(Error.new(id: id, code: Constant::ErrorCodes::REQUEST_FAILED, message: message))
136
+ send_message(Notification.window_show_message(message, type: type))
137
+ end
138
+
133
139
  sig { returns(Thread) }
134
140
  def new_worker
135
141
  Thread.new do
@@ -27,8 +27,9 @@ module RubyLsp
27
27
  scanner = ERBScanner.new(@source)
28
28
  scanner.scan
29
29
  @host_language_source = scanner.host_language
30
- # assigning empty scopes to turn Prism into eval mode
31
- @parse_result = Prism.parse(scanner.ruby, scopes: [[]])
30
+ # Use partial script to avoid syntax errors in ERB files where keywords may be used without the full context in
31
+ # which they will be evaluated
32
+ @parse_result = Prism.parse(scanner.ruby, partial_script: true)
32
33
  true
33
34
  end
34
35
 
@@ -49,7 +50,12 @@ module RubyLsp
49
50
  ).returns(NodeContext)
50
51
  end
51
52
  def locate_node(position, node_types: [])
52
- RubyDocument.locate(@parse_result.value, create_scanner.find_char_position(position), node_types: node_types)
53
+ RubyDocument.locate(
54
+ @parse_result.value,
55
+ create_scanner.find_char_position(position),
56
+ node_types: node_types,
57
+ encoding: @encoding,
58
+ )
53
59
  end
54
60
 
55
61
  sig { params(char_position: Integer).returns(T.nilable(T::Boolean)) }
@@ -23,6 +23,9 @@ module RubyLsp
23
23
  sig { returns(T::Boolean) }
24
24
  attr_reader :supports_watching_files, :experimental_features, :supports_request_delegation
25
25
 
26
+ sig { returns(T::Array[String]) }
27
+ attr_reader :supported_resource_operations
28
+
26
29
  sig { returns(TypeInferrer) }
27
30
  attr_reader :type_inferrer
28
31
 
@@ -42,6 +45,7 @@ module RubyLsp
42
45
  @type_inferrer = T.let(TypeInferrer.new(@index), TypeInferrer)
43
46
  @addon_settings = T.let({}, T::Hash[String, T.untyped])
44
47
  @supports_request_delegation = T.let(false, T::Boolean)
48
+ @supported_resource_operations = T.let([], T::Array[String])
45
49
  end
46
50
 
47
51
  sig { params(addon_name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
@@ -117,6 +121,7 @@ module RubyLsp
117
121
  else
118
122
  Encoding::UTF_32
119
123
  end
124
+ @index.configuration.encoding = @encoding
120
125
 
121
126
  file_watching_caps = options.dig(:capabilities, :workspace, :didChangeWatchedFiles)
122
127
  if file_watching_caps&.dig(:dynamicRegistration) && file_watching_caps&.dig(:relativePatternSupport)
@@ -132,6 +137,9 @@ module RubyLsp
132
137
  end
133
138
 
134
139
  @supports_request_delegation = options.dig(:capabilities, :experimental, :requestDelegation) || false
140
+ supported_resource_operations = options.dig(:capabilities, :workspace, :workspaceEdit, :resourceOperations)
141
+ @supported_resource_operations = supported_resource_operations if supported_resource_operations
142
+
135
143
  notifications
136
144
  end
137
145
 
@@ -26,7 +26,8 @@ require "ruby_lsp/base_server"
26
26
  require "ruby_indexer/ruby_indexer"
27
27
  require "core_ext/uri"
28
28
  require "ruby_lsp/utils"
29
- require "ruby_lsp/parameter_scope"
29
+ require "ruby_lsp/static_docs"
30
+ require "ruby_lsp/scope"
30
31
  require "ruby_lsp/global_state"
31
32
  require "ruby_lsp/server"
32
33
  require "ruby_lsp/type_inferrer"
@@ -74,6 +75,9 @@ require "ruby_lsp/requests/hover"
74
75
  require "ruby_lsp/requests/inlay_hints"
75
76
  require "ruby_lsp/requests/on_type_formatting"
76
77
  require "ruby_lsp/requests/prepare_type_hierarchy"
78
+ require "ruby_lsp/requests/range_formatting"
79
+ require "ruby_lsp/requests/references"
80
+ require "ruby_lsp/requests/rename"
77
81
  require "ruby_lsp/requests/selection_ranges"
78
82
  require "ruby_lsp/requests/semantic_highlighting"
79
83
  require "ruby_lsp/requests/show_syntax_tree"
@@ -438,7 +438,7 @@ module RubyLsp
438
438
  text_edit: Interface::TextEdit.new(range: range, new_text: keyword),
439
439
  kind: Constant::CompletionItemKind::KEYWORD,
440
440
  data: {
441
- skip_resolve: true,
441
+ keyword: true,
442
442
  },
443
443
  )
444
444
  end
@@ -21,8 +21,10 @@ module RubyLsp
21
21
  Prism::InstanceVariableWriteNode,
22
22
  Prism::SymbolNode,
23
23
  Prism::StringNode,
24
+ Prism::InterpolatedStringNode,
24
25
  Prism::SuperNode,
25
26
  Prism::ForwardingSuperNode,
27
+ Prism::YieldNode,
26
28
  ],
27
29
  T::Array[T.class_of(Prism::Node)],
28
30
  )
@@ -68,9 +70,22 @@ module RubyLsp
68
70
  :on_instance_variable_target_node_enter,
69
71
  :on_super_node_enter,
70
72
  :on_forwarding_super_node_enter,
73
+ :on_string_node_enter,
74
+ :on_interpolated_string_node_enter,
75
+ :on_yield_node_enter,
71
76
  )
72
77
  end
73
78
 
79
+ sig { params(node: Prism::StringNode).void }
80
+ def on_string_node_enter(node)
81
+ generate_heredoc_hover(node)
82
+ end
83
+
84
+ sig { params(node: Prism::InterpolatedStringNode).void }
85
+ def on_interpolated_string_node_enter(node)
86
+ generate_heredoc_hover(node)
87
+ end
88
+
74
89
  sig { params(node: Prism::ConstantReadNode).void }
75
90
  def on_constant_read_node_enter(node)
76
91
  return if @sorbet_level != RubyDocument::SorbetLevel::Ignore
@@ -153,8 +168,50 @@ module RubyLsp
153
168
  handle_super_node_hover
154
169
  end
155
170
 
171
+ sig { params(node: Prism::YieldNode).void }
172
+ def on_yield_node_enter(node)
173
+ handle_keyword_documentation(node.keyword)
174
+ end
175
+
156
176
  private
157
177
 
178
+ sig { params(node: T.any(Prism::InterpolatedStringNode, Prism::StringNode)).void }
179
+ def generate_heredoc_hover(node)
180
+ return unless node.heredoc?
181
+
182
+ opening_content = node.opening_loc&.slice
183
+ return unless opening_content
184
+
185
+ match = /(<<(?<type>(-|~)?))(?<quote>['"`]?)(?<delimiter>\w+)\k<quote>/.match(opening_content)
186
+ return unless match
187
+
188
+ heredoc_delimiter = match.named_captures["delimiter"]
189
+
190
+ if heredoc_delimiter
191
+ message = if match["type"] == "~"
192
+ "This is a squiggly heredoc definition using the `#{heredoc_delimiter}` delimiter. " \
193
+ "Indentation will be ignored in the resulting string."
194
+ else
195
+ "This is a heredoc definition using the `#{heredoc_delimiter}` delimiter. " \
196
+ "Indentation will be considered part of the string."
197
+ end
198
+
199
+ @response_builder.push(message, category: :documentation)
200
+ end
201
+ end
202
+
203
+ sig { params(keyword: String).void }
204
+ def handle_keyword_documentation(keyword)
205
+ content = KEYWORD_DOCS[keyword]
206
+ return unless content
207
+
208
+ doc_path = File.join(STATIC_DOCS_PATH, "#{keyword}.md")
209
+
210
+ @response_builder.push("```ruby\n#{keyword}\n```", category: :title)
211
+ @response_builder.push("[Read more](#{doc_path})", category: :links)
212
+ @response_builder.push(content, category: :documentation)
213
+ end
214
+
158
215
  sig { void }
159
216
  def handle_super_node_hover
160
217
  # Sorbet can handle super hover on typed true or higher