ruby-lsp 0.14.6 → 0.16.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/VERSION +1 -1
  3. data/exe/ruby-lsp +1 -16
  4. data/exe/ruby-lsp-check +13 -22
  5. data/exe/ruby-lsp-doctor +9 -0
  6. data/lib/ruby_indexer/lib/ruby_indexer/collector.rb +14 -1
  7. data/lib/ruby_indexer/lib/ruby_indexer/configuration.rb +11 -23
  8. data/lib/ruby_indexer/lib/ruby_indexer/entry.rb +4 -0
  9. data/lib/ruby_indexer/test/classes_and_modules_test.rb +46 -0
  10. data/lib/ruby_indexer/test/configuration_test.rb +2 -11
  11. data/lib/ruby_lsp/addon.rb +18 -9
  12. data/lib/ruby_lsp/base_server.rb +147 -0
  13. data/lib/ruby_lsp/document.rb +0 -5
  14. data/lib/ruby_lsp/{requests/support/dependency_detector.rb → global_state.rb} +41 -9
  15. data/lib/ruby_lsp/internal.rb +4 -1
  16. data/lib/ruby_lsp/listeners/code_lens.rb +13 -9
  17. data/lib/ruby_lsp/listeners/completion.rb +13 -14
  18. data/lib/ruby_lsp/listeners/definition.rb +4 -3
  19. data/lib/ruby_lsp/listeners/document_symbol.rb +91 -3
  20. data/lib/ruby_lsp/listeners/hover.rb +6 -5
  21. data/lib/ruby_lsp/listeners/signature_help.rb +7 -4
  22. data/lib/ruby_lsp/load_sorbet.rb +62 -0
  23. data/lib/ruby_lsp/requests/code_lens.rb +3 -2
  24. data/lib/ruby_lsp/requests/completion.rb +15 -4
  25. data/lib/ruby_lsp/requests/completion_resolve.rb +56 -0
  26. data/lib/ruby_lsp/requests/definition.rb +11 -4
  27. data/lib/ruby_lsp/requests/diagnostics.rb +6 -12
  28. data/lib/ruby_lsp/requests/document_symbol.rb +3 -3
  29. data/lib/ruby_lsp/requests/formatting.rb +7 -43
  30. data/lib/ruby_lsp/requests/hover.rb +4 -4
  31. data/lib/ruby_lsp/requests/request.rb +2 -0
  32. data/lib/ruby_lsp/requests/semantic_highlighting.rb +1 -1
  33. data/lib/ruby_lsp/requests/signature_help.rb +4 -3
  34. data/lib/ruby_lsp/requests/support/common.rb +16 -5
  35. data/lib/ruby_lsp/requests/support/formatter.rb +26 -0
  36. data/lib/ruby_lsp/requests/support/rubocop_formatter.rb +50 -0
  37. data/lib/ruby_lsp/requests/support/rubocop_runner.rb +4 -0
  38. data/lib/ruby_lsp/requests/support/{syntax_tree_formatting_runner.rb → syntax_tree_formatter.rb} +13 -3
  39. data/lib/ruby_lsp/requests/workspace_symbol.rb +5 -4
  40. data/lib/ruby_lsp/requests.rb +3 -1
  41. data/lib/ruby_lsp/server.rb +770 -142
  42. data/lib/ruby_lsp/store.rb +0 -8
  43. data/lib/ruby_lsp/test_helper.rb +52 -0
  44. data/lib/ruby_lsp/utils.rb +68 -33
  45. metadata +11 -9
  46. data/lib/ruby_lsp/executor.rb +0 -614
  47. data/lib/ruby_lsp/requests/support/formatter_runner.rb +0 -18
  48. data/lib/ruby_lsp/requests/support/rubocop_diagnostics_runner.rb +0 -34
  49. data/lib/ruby_lsp/requests/support/rubocop_formatting_runner.rb +0 -35
@@ -2,168 +2,796 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module RubyLsp
5
- # rubocop:disable RubyLsp/UseLanguageServerAliases
6
- Interface = LanguageServer::Protocol::Interface
7
- Constant = LanguageServer::Protocol::Constant
8
- Transport = LanguageServer::Protocol::Transport
9
- # rubocop:enable RubyLsp/UseLanguageServerAliases
10
-
11
- class Server
5
+ class Server < BaseServer
12
6
  extend T::Sig
13
7
 
14
- sig { void }
15
- def initialize
16
- @writer = T.let(Transport::Stdio::Writer.new, Transport::Stdio::Writer)
17
- @reader = T.let(Transport::Stdio::Reader.new, Transport::Stdio::Reader)
18
- @store = T.let(Store.new, Store)
19
-
20
- # The job queue is the actual list of requests we have to process
21
- @job_queue = T.let(Thread::Queue.new, Thread::Queue)
22
- # The jobs hash is just a way of keeping a handle to jobs based on the request ID, so we can cancel them
23
- @jobs = T.let({}, T::Hash[T.any(String, Integer), Job])
24
- @mutex = T.let(Mutex.new, Mutex)
25
- @worker = T.let(new_worker, Thread)
26
-
27
- # The messages queue includes requests and notifications to be sent to the client
28
- @message_queue = T.let(Thread::Queue.new, Thread::Queue)
29
-
30
- # The executor is responsible for executing requests
31
- @executor = T.let(Executor.new(@store, @message_queue), Executor)
32
-
33
- # Create a thread to watch the messages queue and send them to the client
34
- @message_dispatcher = T.let(
35
- Thread.new do
36
- current_request_id = 1
37
-
38
- loop do
39
- message = @message_queue.pop
40
- break if message.nil?
41
-
42
- @mutex.synchronize do
43
- case message
44
- when Notification
45
- @writer.write(method: message.message, params: message.params)
46
- when Request
47
- @writer.write(id: current_request_id, method: message.message, params: message.params)
48
- current_request_id += 1
49
- end
50
- end
51
- end
52
- end,
53
- Thread,
54
- )
8
+ # Only for testing
9
+ sig { returns(GlobalState) }
10
+ attr_reader :global_state
55
11
 
56
- Thread.main.priority = 1
12
+ sig { params(test_mode: T::Boolean).void }
13
+ def initialize(test_mode: false)
14
+ super
15
+ @global_state = T.let(GlobalState.new, GlobalState)
16
+ end
17
+
18
+ sig { override.params(message: T::Hash[Symbol, T.untyped]).void }
19
+ def process_message(message)
20
+ case message[:method]
21
+ when "initialize"
22
+ $stderr.puts("Initializing Ruby LSP v#{VERSION}...")
23
+ run_initialize(message)
24
+ when "initialized"
25
+ $stderr.puts("Finished initializing Ruby LSP!") unless @test_mode
26
+ run_initialized
27
+ when "textDocument/didOpen"
28
+ text_document_did_open(message)
29
+ when "textDocument/didClose"
30
+ text_document_did_close(message)
31
+ when "textDocument/didChange"
32
+ text_document_did_change(message)
33
+ when "textDocument/selectionRange"
34
+ text_document_selection_range(message)
35
+ when "textDocument/documentSymbol"
36
+ text_document_document_symbol(message)
37
+ when "textDocument/documentLink"
38
+ text_document_document_link(message)
39
+ when "textDocument/codeLens"
40
+ text_document_code_lens(message)
41
+ when "textDocument/semanticTokens/full"
42
+ text_document_semantic_tokens_full(message)
43
+ when "textDocument/foldingRange"
44
+ text_document_folding_range(message)
45
+ when "textDocument/semanticTokens/range"
46
+ text_document_semantic_tokens_range(message)
47
+ when "textDocument/formatting"
48
+ text_document_formatting(message)
49
+ when "textDocument/documentHighlight"
50
+ text_document_document_highlight(message)
51
+ when "textDocument/onTypeFormatting"
52
+ text_document_on_type_formatting(message)
53
+ when "textDocument/hover"
54
+ text_document_hover(message)
55
+ when "textDocument/inlayHint"
56
+ text_document_inlay_hint(message)
57
+ when "textDocument/codeAction"
58
+ text_document_code_action(message)
59
+ when "codeAction/resolve"
60
+ code_action_resolve(message)
61
+ when "textDocument/diagnostic"
62
+ text_document_diagnostic(message)
63
+ when "textDocument/completion"
64
+ text_document_completion(message)
65
+ when "completionItem/resolve"
66
+ text_document_completion_item_resolve(message)
67
+ when "textDocument/signatureHelp"
68
+ text_document_signature_help(message)
69
+ when "textDocument/definition"
70
+ text_document_definition(message)
71
+ when "workspace/didChangeWatchedFiles"
72
+ workspace_did_change_watched_files(message)
73
+ when "workspace/symbol"
74
+ workspace_symbol(message)
75
+ when "rubyLsp/textDocument/showSyntaxTree"
76
+ text_document_show_syntax_tree(message)
77
+ when "rubyLsp/workspace/dependencies"
78
+ workspace_dependencies(message)
79
+ when "$/cancelRequest"
80
+ @mutex.synchronize { @cancelled_requests << message[:params][:id] }
81
+ end
82
+ rescue StandardError, LoadError => e
83
+ # If an error occurred in a request, we have to return an error response or else the editor will hang
84
+ if message[:id]
85
+ send_message(Error.new(id: message[:id], code: Constant::ErrorCodes::INTERNAL_ERROR, message: e.full_message))
86
+ end
87
+
88
+ $stderr.puts("Error processing #{message[:method]}: #{e.full_message}")
57
89
  end
58
90
 
59
91
  sig { void }
60
- def start
61
- $stderr.puts("Starting Ruby LSP v#{VERSION}...")
62
-
63
- # Requests that have to be executed sequentially or in the main process are implemented here. All other requests
64
- # fall under the else branch which just pushes requests to the queue
65
- @reader.read do |request|
66
- case request[:method]
67
- when "initialize", "initialized", "textDocument/didOpen", "textDocument/didClose", "textDocument/didChange"
68
- result = @executor.execute(request)
69
- finalize_request(result, request)
70
- when "$/cancelRequest"
71
- # Cancel the job if it's still in the queue
72
- @mutex.synchronize { @jobs[request[:params][:id]]&.cancel }
73
- when "$/setTrace"
74
- VOID
75
- when "shutdown"
76
- $stderr.puts("Shutting down Ruby LSP...")
77
-
78
- @message_queue.close
79
- # Close the queue so that we can no longer receive items
80
- @job_queue.close
81
- # Clear any remaining jobs so that the thread can terminate
82
- @job_queue.clear
83
- @jobs.clear
84
- # Wait until the thread is finished
85
- @worker.join
86
- @message_dispatcher.join
87
- @store.clear
88
-
89
- Addon.addons.each(&:deactivate)
90
- finalize_request(Result.new(response: nil), request)
91
- when "exit"
92
- # We return zero if shutdown has already been received or one otherwise as per the recommendation in the spec
93
- # https://microsoft.github.io/language-server-protocol/specification/#exit
94
- status = @store.empty? ? 0 : 1
95
- $stderr.puts("Shutdown complete with status #{status}")
96
- exit(status)
97
- else
98
- # Default case: push the request to the queue to be executed by the worker
99
- job = Job.new(request: request, cancelled: false)
100
-
101
- @mutex.synchronize do
102
- # Remember a handle to the job, so that we can cancel it
103
- @jobs[request[:id]] = job
104
-
105
- # We must parse the document under a mutex lock or else we might switch threads and accept text edits in the
106
- # source. Altering the source reference during parsing will put the parser in an invalid internal state,
107
- # since it started parsing with one source but then it changed in the middle
108
- uri = request.dig(:params, :textDocument, :uri)
109
- @store.get(URI(uri)).parse if uri
110
- end
92
+ def load_addons
93
+ Addon.load_addons(@global_state, @outgoing_queue)
94
+ errored_addons = Addon.addons.select(&:error?)
111
95
 
112
- @job_queue << job
113
- end
96
+ if errored_addons.any?
97
+ send_message(
98
+ Notification.new(
99
+ method: "window/showMessage",
100
+ params: Interface::ShowMessageParams.new(
101
+ type: Constant::MessageType::WARNING,
102
+ message: "Error loading addons:\n\n#{errored_addons.map(&:formatted_errors).join("\n\n")}",
103
+ ),
104
+ ),
105
+ )
106
+
107
+ $stderr.puts(errored_addons.map(&:backtraces).join("\n\n"))
114
108
  end
115
109
  end
116
110
 
117
111
  private
118
112
 
119
- sig { returns(Thread) }
120
- def new_worker
121
- Thread.new do
122
- # Thread::Queue#pop is thread safe and will wait until an item is available
123
- loop do
124
- job = T.let(@job_queue.pop, T.nilable(Job))
125
- # The only time when the job is nil is when the queue is closed and we can then terminate the thread
126
- break if job.nil?
127
-
128
- request = job.request
129
- @mutex.synchronize { @jobs.delete(request[:id]) }
130
-
131
- result = if job.cancelled
132
- # We need to return nil to the client even if the request was cancelled
133
- Result.new(response: nil)
134
- else
135
- @executor.execute(request)
136
- end
113
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
114
+ def run_initialize(message)
115
+ options = message[:params]
116
+ @global_state.apply_options(options)
117
+
118
+ client_name = options.dig(:clientInfo, :name)
119
+ @store.client_name = client_name if client_name
120
+
121
+ encodings = options.dig(:capabilities, :general, :positionEncodings)
122
+ @store.encoding = if encodings.nil? || encodings.empty?
123
+ Constant::PositionEncodingKind::UTF16
124
+ elsif encodings.include?(Constant::PositionEncodingKind::UTF8)
125
+ Constant::PositionEncodingKind::UTF8
126
+ else
127
+ encodings.first
128
+ end
129
+
130
+ progress = options.dig(:capabilities, :window, :workDoneProgress)
131
+ @store.supports_progress = progress.nil? ? true : progress
132
+ formatter = options.dig(:initializationOptions, :formatter) || "auto"
133
+
134
+ configured_features = options.dig(:initializationOptions, :enabledFeatures)
135
+ @store.experimental_features = options.dig(:initializationOptions, :experimentalFeaturesEnabled) || false
137
136
 
138
- finalize_request(result, request)
137
+ configured_hints = options.dig(:initializationOptions, :featuresConfiguration, :inlayHint)
138
+ T.must(@store.features_configuration.dig(:inlayHint)).configuration.merge!(configured_hints) if configured_hints
139
+
140
+ enabled_features = case configured_features
141
+ when Array
142
+ # If the configuration is using an array, then absent features are disabled and present ones are enabled. That's
143
+ # why we use `false` as the default value
144
+ Hash.new(false).merge!(configured_features.to_h { |feature| [feature, true] })
145
+ when Hash
146
+ # If the configuration is already a hash, merge it with a default value of `true`. That way clients don't have
147
+ # to opt-in to every single feature
148
+ Hash.new(true).merge!(configured_features)
149
+ else
150
+ # If no configuration was passed by the client, just enable every feature
151
+ Hash.new(true)
152
+ end
153
+
154
+ document_symbol_provider = Requests::DocumentSymbol.provider if enabled_features["documentSymbols"]
155
+ document_link_provider = Requests::DocumentLink.provider if enabled_features["documentLink"]
156
+ code_lens_provider = Requests::CodeLens.provider if enabled_features["codeLens"]
157
+ hover_provider = Requests::Hover.provider if enabled_features["hover"]
158
+ folding_ranges_provider = Requests::FoldingRanges.provider if enabled_features["foldingRanges"]
159
+ semantic_tokens_provider = Requests::SemanticHighlighting.provider if enabled_features["semanticHighlighting"]
160
+ diagnostics_provider = Requests::Diagnostics.provider if enabled_features["diagnostics"]
161
+ on_type_formatting_provider = Requests::OnTypeFormatting.provider if enabled_features["onTypeFormatting"]
162
+ code_action_provider = Requests::CodeActions.provider if enabled_features["codeActions"]
163
+ inlay_hint_provider = Requests::InlayHints.provider if enabled_features["inlayHint"]
164
+ completion_provider = Requests::Completion.provider if enabled_features["completion"]
165
+ signature_help_provider = Requests::SignatureHelp.provider if enabled_features["signatureHelp"]
166
+
167
+ response = {
168
+ capabilities: Interface::ServerCapabilities.new(
169
+ text_document_sync: Interface::TextDocumentSyncOptions.new(
170
+ change: Constant::TextDocumentSyncKind::INCREMENTAL,
171
+ open_close: true,
172
+ ),
173
+ position_encoding: @store.encoding,
174
+ selection_range_provider: enabled_features["selectionRanges"],
175
+ hover_provider: hover_provider,
176
+ document_symbol_provider: document_symbol_provider,
177
+ document_link_provider: document_link_provider,
178
+ folding_range_provider: folding_ranges_provider,
179
+ semantic_tokens_provider: semantic_tokens_provider,
180
+ document_formatting_provider: enabled_features["formatting"] && formatter != "none",
181
+ document_highlight_provider: enabled_features["documentHighlights"],
182
+ code_action_provider: code_action_provider,
183
+ document_on_type_formatting_provider: on_type_formatting_provider,
184
+ diagnostic_provider: diagnostics_provider,
185
+ inlay_hint_provider: inlay_hint_provider,
186
+ completion_provider: completion_provider,
187
+ code_lens_provider: code_lens_provider,
188
+ definition_provider: enabled_features["definition"],
189
+ workspace_symbol_provider: enabled_features["workspaceSymbol"],
190
+ signature_help_provider: signature_help_provider,
191
+ ),
192
+ serverInfo: {
193
+ name: "Ruby LSP",
194
+ version: VERSION,
195
+ },
196
+ formatter: @global_state.formatter,
197
+ }
198
+
199
+ send_message(Result.new(id: message[:id], response: response))
200
+
201
+ # Dynamically registered capabilities
202
+ file_watching_caps = options.dig(:capabilities, :workspace, :didChangeWatchedFiles)
203
+
204
+ # Not every client supports dynamic registration or file watching
205
+ if file_watching_caps&.dig(:dynamicRegistration) && file_watching_caps&.dig(:relativePatternSupport)
206
+ send_message(
207
+ Request.new(
208
+ id: @current_request_id,
209
+ method: "client/registerCapability",
210
+ params: Interface::RegistrationParams.new(
211
+ registrations: [
212
+ # Register watching Ruby files
213
+ Interface::Registration.new(
214
+ id: "workspace/didChangeWatchedFiles",
215
+ method: "workspace/didChangeWatchedFiles",
216
+ register_options: Interface::DidChangeWatchedFilesRegistrationOptions.new(
217
+ watchers: [
218
+ Interface::FileSystemWatcher.new(
219
+ glob_pattern: "**/*.rb",
220
+ kind: Constant::WatchKind::CREATE | Constant::WatchKind::CHANGE | Constant::WatchKind::DELETE,
221
+ ),
222
+ ],
223
+ ),
224
+ ),
225
+ ],
226
+ ),
227
+ ),
228
+ )
229
+ end
230
+
231
+ begin_progress("indexing-progress", "Ruby LSP: indexing files")
232
+ end
233
+
234
+ sig { void }
235
+ def run_initialized
236
+ load_addons
237
+ RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)
238
+
239
+ indexing_config = {}
240
+
241
+ # Need to use the workspace URI, otherwise, this will fail for people working on a project that is a symlink.
242
+ index_path = File.join(@global_state.workspace_path, ".index.yml")
243
+
244
+ if File.exist?(index_path)
245
+ begin
246
+ indexing_config = YAML.parse_file(index_path).to_ruby
247
+ rescue Psych::SyntaxError => e
248
+ message = "Syntax error while loading configuration: #{e.message}"
249
+ send_message(
250
+ Notification.new(
251
+ method: "window/showMessage",
252
+ params: Interface::ShowMessageParams.new(
253
+ type: Constant::MessageType::WARNING,
254
+ message: message,
255
+ ),
256
+ ),
257
+ )
139
258
  end
140
259
  end
260
+
261
+ if defined?(Requests::Support::RuboCopFormatter)
262
+ @global_state.register_formatter("rubocop", Requests::Support::RuboCopFormatter.instance)
263
+ end
264
+ if defined?(Requests::Support::SyntaxTreeFormatter)
265
+ @global_state.register_formatter("syntax_tree", Requests::Support::SyntaxTreeFormatter.instance)
266
+ end
267
+
268
+ perform_initial_indexing(indexing_config)
269
+ check_formatter_is_available
270
+ end
271
+
272
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
273
+ def text_document_did_open(message)
274
+ text_document = message.dig(:params, :textDocument)
275
+ @store.set(uri: text_document[:uri], source: text_document[:text], version: text_document[:version])
276
+ rescue Errno::ENOENT
277
+ # If someone re-opens the editor with a file that was deleted or doesn't exist in the current branch, we don't
278
+ # want to crash
141
279
  end
142
280
 
143
- # Finalize a Queue::Result. All IO operations should happen here to avoid any issues with cancelling requests
144
- sig { params(result: Result, request: T::Hash[Symbol, T.untyped]).void }
145
- def finalize_request(result, request)
281
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
282
+ def text_document_did_close(message)
283
+ uri = message.dig(:params, :textDocument, :uri)
284
+ @store.delete(uri)
285
+
286
+ # Clear diagnostics for the closed file, so that they no longer appear in the problems tab
287
+ send_message(
288
+ Notification.new(
289
+ method: "textDocument/publishDiagnostics",
290
+ params: Interface::PublishDiagnosticsParams.new(uri: uri.to_s, diagnostics: []),
291
+ ),
292
+ )
293
+ end
294
+
295
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
296
+ def text_document_did_change(message)
297
+ params = message[:params]
298
+ text_document = params[:textDocument]
299
+
146
300
  @mutex.synchronize do
147
- error = result.error
148
- response = result.response
149
-
150
- if error
151
- @writer.write(
152
- id: request[:id],
153
- error: {
154
- code: Constant::ErrorCodes::INTERNAL_ERROR,
155
- message: error.inspect,
156
- data: {
157
- errorClass: error.class.name,
158
- errorMessage: error.message,
159
- backtrace: error.backtrace&.map { |bt| bt.sub(/^#{Dir.home}/, "~") }&.join("\n"),
160
- },
161
- },
162
- )
163
- elsif response != VOID
164
- @writer.write(id: request[:id], result: response)
301
+ @store.push_edits(uri: text_document[:uri], edits: params[:contentChanges], version: text_document[:version])
302
+ end
303
+ end
304
+
305
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
306
+ def text_document_selection_range(message)
307
+ uri = message.dig(:params, :textDocument, :uri)
308
+ ranges = @store.cache_fetch(uri, "textDocument/selectionRange") do |document|
309
+ Requests::SelectionRanges.new(document).perform
310
+ end
311
+
312
+ # Per the selection range request spec (https://microsoft.github.io/language-server-protocol/specification#textDocument_selectionRange),
313
+ # every position in the positions array should have an element at the same index in the response
314
+ # array. For positions without a valid selection range, the corresponding element in the response
315
+ # array will be nil.
316
+
317
+ response = message.dig(:params, :positions).map do |position|
318
+ ranges.find do |range|
319
+ range.cover?(position)
165
320
  end
166
321
  end
322
+
323
+ send_message(Result.new(id: message[:id], response: response))
324
+ end
325
+
326
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
327
+ def run_combined_requests(message)
328
+ uri = URI(message.dig(:params, :textDocument, :uri))
329
+ document = @store.get(uri)
330
+
331
+ # If the response has already been cached by another request, return it
332
+ cached_response = document.cache_get(message[:method])
333
+ if cached_response
334
+ send_message(Result.new(id: message[:id], response: cached_response))
335
+ return
336
+ end
337
+
338
+ # Run requests for the document
339
+ dispatcher = Prism::Dispatcher.new
340
+ folding_range = Requests::FoldingRanges.new(document.parse_result.comments, dispatcher)
341
+ document_symbol = Requests::DocumentSymbol.new(uri, dispatcher)
342
+ document_link = Requests::DocumentLink.new(uri, document.comments, dispatcher)
343
+ code_lens = Requests::CodeLens.new(@global_state, uri, dispatcher)
344
+
345
+ semantic_highlighting = Requests::SemanticHighlighting.new(dispatcher)
346
+ dispatcher.dispatch(document.tree)
347
+
348
+ # Store all responses retrieve in this round of visits in the cache and then return the response for the request
349
+ # we actually received
350
+ document.cache_set("textDocument/foldingRange", folding_range.perform)
351
+ document.cache_set("textDocument/documentSymbol", document_symbol.perform)
352
+ document.cache_set("textDocument/documentLink", document_link.perform)
353
+ document.cache_set("textDocument/codeLens", code_lens.perform)
354
+ document.cache_set(
355
+ "textDocument/semanticTokens/full",
356
+ semantic_highlighting.perform,
357
+ )
358
+ send_message(Result.new(id: message[:id], response: document.cache_get(message[:method])))
359
+ end
360
+
361
+ alias_method :text_document_document_symbol, :run_combined_requests
362
+ alias_method :text_document_document_link, :run_combined_requests
363
+ alias_method :text_document_code_lens, :run_combined_requests
364
+ alias_method :text_document_semantic_tokens_full, :run_combined_requests
365
+ alias_method :text_document_folding_range, :run_combined_requests
366
+
367
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
368
+ def text_document_semantic_tokens_range(message)
369
+ params = message[:params]
370
+ range = params[:range]
371
+ uri = params.dig(:textDocument, :uri)
372
+ document = @store.get(uri)
373
+ start_line = range.dig(:start, :line)
374
+ end_line = range.dig(:end, :line)
375
+
376
+ dispatcher = Prism::Dispatcher.new
377
+ request = Requests::SemanticHighlighting.new(dispatcher, range: start_line..end_line)
378
+ dispatcher.visit(document.tree)
379
+
380
+ response = request.perform
381
+ send_message(Result.new(id: message[:id], response: response))
382
+ end
383
+
384
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
385
+ def text_document_formatting(message)
386
+ # If formatter is set to `auto` but no supported formatting gem is found, don't attempt to format
387
+ if @global_state.formatter == "none"
388
+ send_empty_response(message[:id])
389
+ return
390
+ end
391
+
392
+ uri = message.dig(:params, :textDocument, :uri)
393
+ # Do not format files outside of the workspace. For example, if someone is looking at a gem's source code, we
394
+ # don't want to format it
395
+ path = uri.to_standardized_path
396
+ unless path.nil? || path.start_with?(@global_state.workspace_path)
397
+ send_empty_response(message[:id])
398
+ return
399
+ end
400
+
401
+ response = Requests::Formatting.new(@global_state, @store.get(uri)).perform
402
+ send_message(Result.new(id: message[:id], response: response))
403
+ rescue Requests::Request::InvalidFormatter => error
404
+ send_message(Notification.window_show_error("Configuration error: #{error.message}"))
405
+ send_empty_response(message[:id])
406
+ rescue StandardError, LoadError => error
407
+ send_message(Notification.window_show_error("Formatting error: #{error.message}"))
408
+ send_empty_response(message[:id])
409
+ end
410
+
411
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
412
+ def text_document_document_highlight(message)
413
+ params = message[:params]
414
+ dispatcher = Prism::Dispatcher.new
415
+ document = @store.get(params.dig(:textDocument, :uri))
416
+ request = Requests::DocumentHighlight.new(document, params[:position], dispatcher)
417
+ dispatcher.dispatch(document.tree)
418
+ send_message(Result.new(id: message[:id], response: request.perform))
419
+ end
420
+
421
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
422
+ def text_document_on_type_formatting(message)
423
+ params = message[:params]
424
+
425
+ send_message(
426
+ Result.new(
427
+ id: message[:id],
428
+ response: Requests::OnTypeFormatting.new(
429
+ @store.get(params.dig(:textDocument, :uri)),
430
+ params[:position],
431
+ params[:ch],
432
+ @store.client_name,
433
+ ).perform,
434
+ ),
435
+ )
436
+ end
437
+
438
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
439
+ def text_document_hover(message)
440
+ params = message[:params]
441
+ dispatcher = Prism::Dispatcher.new
442
+ document = @store.get(params.dig(:textDocument, :uri))
443
+
444
+ send_message(
445
+ Result.new(
446
+ id: message[:id],
447
+ response: Requests::Hover.new(
448
+ document,
449
+ @global_state,
450
+ params[:position],
451
+ dispatcher,
452
+ typechecker_enabled?(document),
453
+ ).perform,
454
+ ),
455
+ )
456
+ end
457
+
458
+ sig { params(document: Document).returns(T::Boolean) }
459
+ def typechecker_enabled?(document)
460
+ @global_state.typechecker && document.sorbet_sigil_is_true_or_higher
461
+ end
462
+
463
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
464
+ def text_document_inlay_hint(message)
465
+ params = message[:params]
466
+ hints_configurations = T.must(@store.features_configuration.dig(:inlayHint))
467
+ dispatcher = Prism::Dispatcher.new
468
+ document = @store.get(params.dig(:textDocument, :uri))
469
+ request = Requests::InlayHints.new(document, params[:range], hints_configurations, dispatcher)
470
+ dispatcher.visit(document.tree)
471
+ send_message(Result.new(id: message[:id], response: request.perform))
472
+ end
473
+
474
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
475
+ def text_document_code_action(message)
476
+ params = message[:params]
477
+ document = @store.get(params.dig(:textDocument, :uri))
478
+
479
+ send_message(
480
+ Result.new(
481
+ id: message[:id],
482
+ response: Requests::CodeActions.new(
483
+ document,
484
+ params[:range],
485
+ params[:context],
486
+ ).perform,
487
+ ),
488
+ )
489
+ end
490
+
491
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
492
+ def code_action_resolve(message)
493
+ params = message[:params]
494
+ uri = URI(params.dig(:data, :uri))
495
+ document = @store.get(uri)
496
+ result = Requests::CodeActionResolve.new(document, params).perform
497
+
498
+ case result
499
+ when Requests::CodeActionResolve::Error::EmptySelection
500
+ send_message(Notification.window_show_error("Invalid selection for Extract Variable refactor"))
501
+ raise Requests::CodeActionResolve::CodeActionError
502
+ when Requests::CodeActionResolve::Error::InvalidTargetRange
503
+ send_message(
504
+ Notification.window_show_error(
505
+ "Couldn't find an appropriate location to place extracted refactor",
506
+ ),
507
+ )
508
+ raise Requests::CodeActionResolve::CodeActionError
509
+ else
510
+ send_message(Result.new(id: message[:id], response: result))
511
+ end
512
+ end
513
+
514
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
515
+ def text_document_diagnostic(message)
516
+ # Do not compute diagnostics for files outside of the workspace. For example, if someone is looking at a gem's
517
+ # source code, we don't want to show diagnostics for it
518
+ uri = message.dig(:params, :textDocument, :uri)
519
+ path = uri.to_standardized_path
520
+ unless path.nil? || path.start_with?(@global_state.workspace_path)
521
+ send_empty_response(message[:id])
522
+ return
523
+ end
524
+
525
+ response = @store.cache_fetch(uri, "textDocument/diagnostic") do |document|
526
+ Requests::Diagnostics.new(@global_state, document).perform
527
+ end
528
+
529
+ send_message(
530
+ Result.new(
531
+ id: message[:id],
532
+ response: response && Interface::FullDocumentDiagnosticReport.new(kind: "full", items: response),
533
+ ),
534
+ )
535
+ rescue Requests::Request::InvalidFormatter => error
536
+ send_message(Notification.window_show_error("Configuration error: #{error.message}"))
537
+ send_empty_response(message[:id])
538
+ rescue StandardError, LoadError => error
539
+ send_message(Notification.window_show_error("Error running diagnostics: #{error.message}"))
540
+ send_empty_response(message[:id])
541
+ end
542
+
543
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
544
+ def text_document_completion(message)
545
+ params = message[:params]
546
+ dispatcher = Prism::Dispatcher.new
547
+ document = @store.get(params.dig(:textDocument, :uri))
548
+
549
+ send_message(
550
+ Result.new(
551
+ id: message[:id],
552
+ response: Requests::Completion.new(
553
+ document,
554
+ @global_state,
555
+ params[:position],
556
+ typechecker_enabled?(document),
557
+ dispatcher,
558
+ ).perform,
559
+ ),
560
+ )
561
+ end
562
+
563
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
564
+ def text_document_completion_item_resolve(message)
565
+ send_message(Result.new(
566
+ id: message[:id],
567
+ response: Requests::CompletionResolve.new(@global_state, message[:params]).perform,
568
+ ))
569
+ end
570
+
571
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
572
+ def text_document_signature_help(message)
573
+ params = message[:params]
574
+ dispatcher = Prism::Dispatcher.new
575
+ document = @store.get(params.dig(:textDocument, :uri))
576
+
577
+ send_message(
578
+ Result.new(
579
+ id: message[:id],
580
+ response: Requests::SignatureHelp.new(
581
+ document,
582
+ @global_state,
583
+ params[:position],
584
+ params[:context],
585
+ dispatcher,
586
+ typechecker_enabled?(document),
587
+ ).perform,
588
+ ),
589
+ )
590
+ end
591
+
592
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
593
+ def text_document_definition(message)
594
+ params = message[:params]
595
+ dispatcher = Prism::Dispatcher.new
596
+ document = @store.get(params.dig(:textDocument, :uri))
597
+
598
+ send_message(
599
+ Result.new(
600
+ id: message[:id],
601
+ response: Requests::Definition.new(
602
+ document,
603
+ @global_state,
604
+ params[:position],
605
+ dispatcher,
606
+ typechecker_enabled?(document),
607
+ ).perform,
608
+ ),
609
+ )
610
+ end
611
+
612
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
613
+ def workspace_did_change_watched_files(message)
614
+ changes = message.dig(:params, :changes)
615
+ index = @global_state.index
616
+ changes.each do |change|
617
+ # File change events include folders, but we're only interested in files
618
+ uri = URI(change[:uri])
619
+ file_path = uri.to_standardized_path
620
+ next if file_path.nil? || File.directory?(file_path)
621
+
622
+ load_path_entry = $LOAD_PATH.find { |load_path| file_path.start_with?(load_path) }
623
+ indexable = RubyIndexer::IndexablePath.new(load_path_entry, file_path)
624
+
625
+ case change[:type]
626
+ when Constant::FileChangeType::CREATED
627
+ index.index_single(indexable)
628
+ when Constant::FileChangeType::CHANGED
629
+ index.delete(indexable)
630
+ index.index_single(indexable)
631
+ when Constant::FileChangeType::DELETED
632
+ index.delete(indexable)
633
+ end
634
+ end
635
+
636
+ Addon.file_watcher_addons.each { |addon| T.unsafe(addon).workspace_did_change_watched_files(changes) }
637
+ end
638
+
639
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
640
+ def workspace_symbol(message)
641
+ send_message(
642
+ Result.new(
643
+ id: message[:id],
644
+ response: Requests::WorkspaceSymbol.new(
645
+ @global_state,
646
+ message.dig(:params, :query),
647
+ ).perform,
648
+ ),
649
+ )
650
+ end
651
+
652
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
653
+ def text_document_show_syntax_tree(message)
654
+ params = message[:params]
655
+ response = {
656
+ ast: Requests::ShowSyntaxTree.new(
657
+ @store.get(params.dig(:textDocument, :uri)),
658
+ params[:range],
659
+ ).perform,
660
+ }
661
+ send_message(Result.new(id: message[:id], response: response))
662
+ end
663
+
664
+ sig { params(message: T::Hash[Symbol, T.untyped]).void }
665
+ def workspace_dependencies(message)
666
+ response = begin
667
+ Bundler.with_original_env do
668
+ definition = Bundler.definition
669
+ dep_keys = definition.locked_deps.keys.to_set
670
+
671
+ definition.specs.map do |spec|
672
+ {
673
+ name: spec.name,
674
+ version: spec.version,
675
+ path: spec.full_gem_path,
676
+ dependency: dep_keys.include?(spec.name),
677
+ }
678
+ end
679
+ end
680
+ rescue Bundler::GemfileNotFound
681
+ []
682
+ end
683
+
684
+ send_message(Result.new(id: message[:id], response: response))
685
+ end
686
+
687
+ sig { override.void }
688
+ def shutdown
689
+ Addon.addons.each(&:deactivate)
690
+ end
691
+
692
+ sig { params(config_hash: T::Hash[String, T.untyped]).void }
693
+ def perform_initial_indexing(config_hash)
694
+ # The begin progress invocation happens during `initialize`, so that the notification is sent before we are
695
+ # stuck indexing files
696
+ RubyIndexer.configuration.apply_config(config_hash)
697
+
698
+ Thread.new do
699
+ begin
700
+ @global_state.index.index_all do |percentage|
701
+ progress("indexing-progress", percentage)
702
+ true
703
+ rescue ClosedQueueError
704
+ # Since we run indexing on a separate thread, it's possible to kill the server before indexing is complete.
705
+ # In those cases, the message queue will be closed and raise a ClosedQueueError. By returning `false`, we
706
+ # tell the index to stop working immediately
707
+ false
708
+ end
709
+ rescue StandardError => error
710
+ send_message(Notification.window_show_error("Error while indexing: #{error.message}"))
711
+ end
712
+
713
+ # Always end the progress notification even if indexing failed or else it never goes away and the user has no
714
+ # way of dismissing it
715
+ end_progress("indexing-progress")
716
+ end
717
+ end
718
+
719
+ sig { params(id: String, title: String, percentage: Integer).void }
720
+ def begin_progress(id, title, percentage: 0)
721
+ return unless @store.supports_progress
722
+
723
+ send_message(Request.new(
724
+ id: @current_request_id,
725
+ method: "window/workDoneProgress/create",
726
+ params: Interface::WorkDoneProgressCreateParams.new(token: id),
727
+ ))
728
+
729
+ send_message(Notification.new(
730
+ method: "$/progress",
731
+ params: Interface::ProgressParams.new(
732
+ token: id,
733
+ value: Interface::WorkDoneProgressBegin.new(
734
+ kind: "begin",
735
+ title: title,
736
+ percentage: percentage,
737
+ message: "#{percentage}% completed",
738
+ ),
739
+ ),
740
+ ))
741
+ end
742
+
743
+ sig { params(id: String, percentage: Integer).void }
744
+ def progress(id, percentage)
745
+ return unless @store.supports_progress
746
+
747
+ send_message(
748
+ Notification.new(
749
+ method: "$/progress",
750
+ params: Interface::ProgressParams.new(
751
+ token: id,
752
+ value: Interface::WorkDoneProgressReport.new(
753
+ kind: "report",
754
+ percentage: percentage,
755
+ message: "#{percentage}% completed",
756
+ ),
757
+ ),
758
+ ),
759
+ )
760
+ end
761
+
762
+ sig { params(id: String).void }
763
+ def end_progress(id)
764
+ return unless @store.supports_progress
765
+
766
+ send_message(
767
+ Notification.new(
768
+ method: "$/progress",
769
+ params: Interface::ProgressParams.new(
770
+ token: id,
771
+ value: Interface::WorkDoneProgressEnd.new(kind: "end"),
772
+ ),
773
+ ),
774
+ )
775
+ rescue ClosedQueueError
776
+ # If the server was killed and the message queue is already closed, there's no way to end the progress
777
+ # notification
778
+ end
779
+
780
+ sig { void }
781
+ def check_formatter_is_available
782
+ # Warn of an unavailable `formatter` setting, e.g. `rubocop` on a project which doesn't have RuboCop.
783
+ # Syntax Tree will always be available via Ruby LSP so we don't need to check for it.
784
+ return unless @global_state.formatter == "rubocop"
785
+
786
+ unless defined?(RubyLsp::Requests::Support::RuboCopRunner)
787
+ @global_state.formatter = "none"
788
+
789
+ send_message(
790
+ Notification.window_show_error(
791
+ "Ruby LSP formatter is set to `rubocop` but RuboCop was not found in the Gemfile or gemspec.",
792
+ ),
793
+ )
794
+ end
167
795
  end
168
796
  end
169
797
  end