ruby-lsp 0.14.5 → 0.15.0

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