ruby-lsp 0.18.3 → 0.19.1

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.
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