ruby-lsp 0.14.5 → 0.15.0

Sign up to get free protection for your applications and to get access to all the features.
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