ruby-lsp 0.23.15 → 0.26.9

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/VERSION +1 -1
  3. data/exe/ruby-lsp +17 -14
  4. data/exe/ruby-lsp-check +0 -4
  5. data/exe/ruby-lsp-launcher +41 -14
  6. data/exe/ruby-lsp-test-exec +6 -0
  7. data/lib/rubocop/cop/ruby_lsp/use_language_server_aliases.rb +0 -1
  8. data/lib/rubocop/cop/ruby_lsp/use_register_with_handler_method.rb +0 -1
  9. data/lib/ruby_indexer/lib/ruby_indexer/configuration.rb +4 -3
  10. data/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb +42 -20
  11. data/lib/ruby_indexer/lib/ruby_indexer/enhancement.rb +1 -7
  12. data/lib/ruby_indexer/lib/ruby_indexer/entry.rb +49 -62
  13. data/lib/ruby_indexer/lib/ruby_indexer/index.rb +84 -74
  14. data/lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb +6 -9
  15. data/lib/ruby_indexer/lib/ruby_indexer/rbs_indexer.rb +9 -14
  16. data/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb +12 -8
  17. data/lib/ruby_indexer/lib/ruby_indexer/visibility_scope.rb +4 -4
  18. data/lib/ruby_lsp/addon.rb +44 -15
  19. data/lib/ruby_lsp/base_server.rb +56 -37
  20. data/lib/ruby_lsp/client_capabilities.rb +6 -1
  21. data/lib/ruby_lsp/document.rb +174 -62
  22. data/lib/ruby_lsp/erb_document.rb +10 -8
  23. data/lib/ruby_lsp/global_state.rb +86 -33
  24. data/lib/ruby_lsp/internal.rb +6 -3
  25. data/lib/ruby_lsp/listeners/completion.rb +22 -11
  26. data/lib/ruby_lsp/listeners/definition.rb +41 -21
  27. data/lib/ruby_lsp/listeners/document_highlight.rb +26 -1
  28. data/lib/ruby_lsp/listeners/document_link.rb +64 -28
  29. data/lib/ruby_lsp/listeners/hover.rb +27 -16
  30. data/lib/ruby_lsp/listeners/inlay_hints.rb +5 -3
  31. data/lib/ruby_lsp/listeners/semantic_highlighting.rb +2 -2
  32. data/lib/ruby_lsp/listeners/signature_help.rb +2 -2
  33. data/lib/ruby_lsp/listeners/spec_style.rb +155 -79
  34. data/lib/ruby_lsp/listeners/test_discovery.rb +39 -21
  35. data/lib/ruby_lsp/listeners/test_style.rb +75 -35
  36. data/lib/ruby_lsp/rbs_document.rb +3 -6
  37. data/lib/ruby_lsp/requests/code_action_resolve.rb +83 -58
  38. data/lib/ruby_lsp/requests/code_actions.rb +20 -5
  39. data/lib/ruby_lsp/requests/code_lens.rb +27 -6
  40. data/lib/ruby_lsp/requests/completion.rb +3 -3
  41. data/lib/ruby_lsp/requests/completion_resolve.rb +8 -6
  42. data/lib/ruby_lsp/requests/definition.rb +4 -7
  43. data/lib/ruby_lsp/requests/discover_tests.rb +2 -2
  44. data/lib/ruby_lsp/requests/document_highlight.rb +2 -2
  45. data/lib/ruby_lsp/requests/document_link.rb +1 -1
  46. data/lib/ruby_lsp/requests/folding_ranges.rb +1 -1
  47. data/lib/ruby_lsp/requests/go_to_relevant_file.rb +64 -12
  48. data/lib/ruby_lsp/requests/hover.rb +3 -6
  49. data/lib/ruby_lsp/requests/inlay_hints.rb +4 -4
  50. data/lib/ruby_lsp/requests/on_type_formatting.rb +1 -1
  51. data/lib/ruby_lsp/requests/prepare_rename.rb +1 -1
  52. data/lib/ruby_lsp/requests/references.rb +10 -21
  53. data/lib/ruby_lsp/requests/rename.rb +9 -10
  54. data/lib/ruby_lsp/requests/request.rb +8 -8
  55. data/lib/ruby_lsp/requests/selection_ranges.rb +2 -2
  56. data/lib/ruby_lsp/requests/semantic_highlighting.rb +1 -1
  57. data/lib/ruby_lsp/requests/show_syntax_tree.rb +2 -2
  58. data/lib/ruby_lsp/requests/signature_help.rb +2 -2
  59. data/lib/ruby_lsp/requests/support/annotation.rb +1 -1
  60. data/lib/ruby_lsp/requests/support/common.rb +9 -12
  61. data/lib/ruby_lsp/requests/support/formatter.rb +16 -15
  62. data/lib/ruby_lsp/requests/support/package_url.rb +414 -0
  63. data/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb +7 -1
  64. data/lib/ruby_lsp/requests/support/rubocop_formatter.rb +2 -2
  65. data/lib/ruby_lsp/requests/support/rubocop_runner.rb +13 -3
  66. data/lib/ruby_lsp/requests/support/source_uri.rb +7 -4
  67. data/lib/ruby_lsp/requests/support/test_item.rb +7 -1
  68. data/lib/ruby_lsp/requests/workspace_symbol.rb +20 -12
  69. data/lib/ruby_lsp/response_builders/collection_response_builder.rb +1 -4
  70. data/lib/ruby_lsp/response_builders/document_symbol.rb +2 -3
  71. data/lib/ruby_lsp/response_builders/hover.rb +1 -4
  72. data/lib/ruby_lsp/response_builders/response_builder.rb +6 -7
  73. data/lib/ruby_lsp/response_builders/semantic_highlighting.rb +4 -5
  74. data/lib/ruby_lsp/response_builders/signature_help.rb +1 -2
  75. data/lib/ruby_lsp/response_builders/test_collection.rb +29 -3
  76. data/lib/ruby_lsp/ruby_document.rb +14 -42
  77. data/lib/ruby_lsp/scripts/compose_bundle.rb +3 -3
  78. data/lib/ruby_lsp/scripts/compose_bundle_windows.rb +3 -1
  79. data/lib/ruby_lsp/server.rb +173 -130
  80. data/lib/ruby_lsp/setup_bundler.rb +114 -47
  81. data/lib/ruby_lsp/static_docs.rb +1 -0
  82. data/lib/ruby_lsp/store.rb +6 -16
  83. data/lib/ruby_lsp/test_helper.rb +1 -4
  84. data/lib/ruby_lsp/test_reporters/lsp_reporter.rb +121 -17
  85. data/lib/ruby_lsp/test_reporters/minitest_reporter.rb +65 -25
  86. data/lib/ruby_lsp/test_reporters/test_unit_reporter.rb +16 -18
  87. data/lib/ruby_lsp/utils.rb +102 -13
  88. data/static_docs/break.md +103 -0
  89. metadata +8 -33
  90. data/lib/ruby_indexer/test/class_variables_test.rb +0 -140
  91. data/lib/ruby_indexer/test/classes_and_modules_test.rb +0 -770
  92. data/lib/ruby_indexer/test/configuration_test.rb +0 -280
  93. data/lib/ruby_indexer/test/constant_test.rb +0 -402
  94. data/lib/ruby_indexer/test/enhancements_test.rb +0 -325
  95. data/lib/ruby_indexer/test/global_variable_test.rb +0 -49
  96. data/lib/ruby_indexer/test/index_test.rb +0 -2190
  97. data/lib/ruby_indexer/test/instance_variables_test.rb +0 -240
  98. data/lib/ruby_indexer/test/method_test.rb +0 -973
  99. data/lib/ruby_indexer/test/prefix_tree_test.rb +0 -150
  100. data/lib/ruby_indexer/test/rbs_indexer_test.rb +0 -380
  101. data/lib/ruby_indexer/test/reference_finder_test.rb +0 -330
  102. data/lib/ruby_indexer/test/test_case.rb +0 -51
  103. data/lib/ruby_indexer/test/uri_test.rb +0 -85
  104. data/lib/ruby_lsp/load_sorbet.rb +0 -62
@@ -1,7 +1,6 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require "sorbet-runtime"
5
4
  require "bundler"
6
5
  require "bundler/cli"
7
6
  require "bundler/cli/install"
@@ -12,27 +11,37 @@ require "digest"
12
11
  require "time"
13
12
  require "uri"
14
13
 
15
- # This file is a script that will configure a composed bundle for the Ruby LSP. The composed bundle allows developers to use
16
- # the Ruby LSP without including the gem in their application's Gemfile while at the same time giving us access to the
17
- # exact locked versions of dependencies.
14
+ # This file is a script that will configure a composed bundle for the Ruby LSP. The composed bundle allows developers to
15
+ # use the Ruby LSP without including the gem in their application's Gemfile while at the same time giving us access to
16
+ # the exact locked versions of dependencies.
18
17
 
19
18
  Bundler.ui.level = :silent
20
19
 
21
20
  module RubyLsp
22
21
  class SetupBundler
23
- extend T::Sig
24
-
25
22
  class BundleNotLocked < StandardError; end
26
23
  class BundleInstallFailure < StandardError; end
27
24
 
25
+ module ThorPatch
26
+ #: -> IO
27
+ def stdout
28
+ $stderr
29
+ end
30
+ end
31
+
28
32
  FOUR_HOURS = 4 * 60 * 60 #: Integer
29
33
 
34
+ # Gems that should be kept up to date in the composed bundle. When updating, any of these gems that are not
35
+ # already in the user's Gemfile will be updated together.
36
+ GEMS_TO_UPDATE = ["ruby-lsp", "debug", "prism", "rbs"].freeze #: Array[String]
37
+ RUBY_LSP_MIN_VERSION = "0.18.0" #: String
38
+
30
39
  #: (String project_path, **untyped options) -> void
31
40
  def initialize(project_path, **options)
32
41
  @project_path = project_path
33
- @branch = options[:branch] #: String?
34
42
  @launcher = options[:launcher] #: bool?
35
- patch_thor_to_print_progress_to_stderr! if @launcher
43
+ @beta = options[:beta] #: bool?
44
+ force_output_to_stderr! if @launcher
36
45
 
37
46
  # Regular bundle paths
38
47
  @gemfile = begin
@@ -51,7 +60,7 @@ module RubyLsp
51
60
  @custom_dir = Pathname.new(".ruby-lsp").expand_path(@project_path) #: Pathname
52
61
  @custom_gemfile = @custom_dir + @gemfile_name #: Pathname
53
62
  @custom_lockfile = @custom_dir + (@lockfile&.basename || "Gemfile.lock") #: Pathname
54
- @lockfile_hash_path = @custom_dir + "main_lockfile_hash" #: Pathname
63
+ @freshness_hash_path = @custom_dir + "freshness_hash" #: Pathname
55
64
  @last_updated_path = @custom_dir + "last_updated" #: Pathname
56
65
  @error_path = @custom_dir + "install_error" #: Pathname
57
66
  @already_composed_path = @custom_dir + "bundle_is_composed" #: Pathname
@@ -61,6 +70,7 @@ module RubyLsp
61
70
  @bundler_version = bundler_version #: Gem::Version?
62
71
  @rails_app = rails_app? #: bool
63
72
  @retry = false #: bool
73
+ @needs_update_path = @custom_dir + "needs_update" #: Pathname
64
74
  end
65
75
 
66
76
  # Sets up the composed bundle and returns the `BUNDLE_GEMFILE`, `BUNDLE_PATH` and `BUNDLE_APP_CONFIG` that should be
@@ -109,17 +119,24 @@ module RubyLsp
109
119
  return run_bundle_install(@custom_gemfile)
110
120
  end
111
121
 
112
- if @lockfile_hash && @custom_lockfile.exist? && @lockfile_hash_path.exist? &&
113
- @lockfile_hash_path.read == @lockfile_hash
122
+ # Our freshness hash determines if we need to copy the lockfile from the main app again and run bundle install
123
+ # from scratch. We use a combination of the main app's lockfile and the composed Gemfile. The goal is to
124
+ # automatically account for CLI arguments which can change the Gemfile we compose. If the CLI arguments or the
125
+ # main lockfile change, we need to make sure we're re-composing.
126
+ freshness_digest = Digest::SHA256.hexdigest("#{@lockfile_hash}#{@custom_gemfile.read}")
127
+
128
+ if @lockfile_hash && @custom_lockfile.exist? && @freshness_hash_path.exist? &&
129
+ @freshness_hash_path.read == freshness_digest
114
130
  $stderr.puts(
115
131
  "Ruby LSP> Skipping composed bundle setup since #{@custom_lockfile} already exists and is up to date",
116
132
  )
117
133
  return run_bundle_install(@custom_gemfile)
118
134
  end
119
135
 
136
+ @needs_update_path.delete if @needs_update_path.exist?
120
137
  FileUtils.cp(@lockfile.to_s, @custom_lockfile.to_s)
121
138
  correct_relative_remote_paths
122
- @lockfile_hash_path.write(@lockfile_hash)
139
+ @freshness_hash_path.write(freshness_digest)
123
140
  run_bundle_install(@custom_gemfile)
124
141
  end
125
142
 
@@ -159,9 +176,8 @@ module RubyLsp
159
176
  end
160
177
 
161
178
  unless @dependencies["ruby-lsp"]
162
- ruby_lsp_entry = +'gem "ruby-lsp", require: false, group: :development'
163
- ruby_lsp_entry << ", github: \"Shopify/ruby-lsp\", branch: \"#{@branch}\"" if @branch
164
- parts << ruby_lsp_entry
179
+ version = @beta ? "0.a" : RUBY_LSP_MIN_VERSION
180
+ parts << "gem \"ruby-lsp\", \">= #{version}\", require: false, group: :development"
165
181
  end
166
182
 
167
183
  unless @dependencies["debug"]
@@ -229,6 +245,16 @@ module RubyLsp
229
245
  # If no error occurred, then clear previous errors
230
246
  @error_path.delete if @error_path.exist?
231
247
  $stderr.puts("Ruby LSP> Composed bundle installation complete")
248
+ rescue Errno::EPIPE, Bundler::HTTPError, Bundler::InstallError
249
+ # There are cases where we expect certain errors to happen occasionally, and we don't want to write them to
250
+ # a file, which would report to telemetry on the next launch.
251
+ #
252
+ # - The $stderr pipe might be closed by the client, for example when closing the editor during running bundle
253
+ # install. This situation may happen because, while running bundle install, the server is not yet ready to
254
+ # receive shutdown requests and we may continue doing work until the process is killed.
255
+ # - Bundler might also encounter a network error.
256
+ # - Native extension build failures (InstallError) are user environment issues that Ruby LSP cannot resolve.
257
+ @error_path.delete if @error_path.exist?
232
258
  rescue => e
233
259
  # Write the error object to a file so that we can read it from the parent process
234
260
  @error_path.write(Marshal.dump(e))
@@ -257,41 +283,86 @@ module RubyLsp
257
283
  def run_bundle_install_directly(env, force_install: false)
258
284
  RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)
259
285
 
260
- # The ENV can only be merged after checking if an update is required because we depend on the original value of
261
- # ENV["BUNDLE_GEMFILE"], which gets overridden after the merge
262
- should_update = should_bundle_update?
263
- T.unsafe(ENV).merge!(env)
286
+ # The should_bundle_update? check needs to run on the original Bundler environment, but everything else (like
287
+ # updating or running install) requires the modified environment. Here we compute the check ahead of time and
288
+ # merge the environment to ensure correct results.
289
+ #
290
+ # The symptoms of having these operations in the wrong order is seeing unwanted modifications in the application's
291
+ # main lockfile because we accidentally run update on the main bundle instead of the composed one.
292
+ needs_update = should_bundle_update?
293
+ ENV.merge!(env)
264
294
 
265
- unless should_update && !force_install
266
- Bundler::CLI::Install.new({ "no-cache" => true }).run
267
- correct_relative_remote_paths if @custom_lockfile.exist?
295
+ return update(env) if @needs_update_path.exist?
296
+
297
+ FileUtils.touch(@needs_update_path) if needs_update
298
+
299
+ $stderr.puts("Ruby LSP> Checking if the composed bundle is satisfied...")
300
+
301
+ begin
302
+ missing_gems = bundle_check
303
+ rescue Errno::EPIPE, Bundler::HTTPError
304
+ # These are errors cases where we cannot recover
305
+ raise
306
+ rescue => e
307
+ # If anything fails with bundle check, try to bundle install
308
+ $stderr.puts("Ruby LSP> Running bundle install because #{e.message}")
309
+ bundle_install
268
310
  return env
269
311
  end
270
312
 
313
+ if missing_gems.empty?
314
+ $stderr.puts("Ruby LSP> Bundle already satisfied")
315
+ else
316
+ $stderr.puts(<<~MESSAGE)
317
+ Ruby LSP> Running bundle install because the following gems are not installed:
318
+ #{missing_gems.map { |g| "#{g.name}: #{g.version}" }.join("\n")}
319
+ MESSAGE
320
+
321
+ bundle_install
322
+ end
323
+
324
+ env
325
+ end
326
+
327
+ # Essentially the same as bundle check, but simplified
328
+ #: -> Array[Gem::Specification]
329
+ def bundle_check
330
+ definition = Bundler.definition
331
+ definition.validate_runtime!
332
+ definition.check!
333
+ definition.missing_specs
334
+ end
335
+
336
+ #: -> void
337
+ def bundle_install
338
+ Bundler::CLI::Install.new({ "no-cache" => true }).run
339
+ correct_relative_remote_paths if @custom_lockfile.exist?
340
+ end
341
+
342
+ #: (Hash[String, String]) -> Hash[String, String]
343
+ def update(env)
271
344
  # Try to auto upgrade the gems we depend on, unless they are in the Gemfile as that would result in undesired
272
345
  # source control changes
273
- gems = ["ruby-lsp", "debug", "prism"].reject { |dep| @dependencies[dep] }
346
+ gems = GEMS_TO_UPDATE.reject { |dep| @dependencies[dep] }
274
347
  gems << "ruby-lsp-rails" if @rails_app && !@dependencies["ruby-lsp-rails"]
275
348
 
276
349
  Bundler::CLI::Update.new({ conservative: true }, gems).run
277
350
  correct_relative_remote_paths if @custom_lockfile.exist?
351
+ @needs_update_path.delete if @needs_update_path.exist?
278
352
  @last_updated_path.write(Time.now.iso8601)
279
353
  env
280
- rescue Bundler::GemNotFound, Bundler::GitError
281
- # If a gem is not installed, skip the upgrade and try to install it with a single retry
282
- @retry ? env : run_bundle_install_directly(env, force_install: true)
283
354
  end
284
355
 
285
356
  #: (Hash[String, String] env) -> Hash[String, String]
286
357
  def run_bundle_install_through_command(env)
287
- # If `ruby-lsp` and `debug` (and potentially `ruby-lsp-rails`) are already in the Gemfile, then we shouldn't try
288
- # to upgrade them or else we'll produce undesired source control changes. If the composed bundle was just created
289
- # and any of `ruby-lsp`, `ruby-lsp-rails` or `debug` weren't a part of the Gemfile, then we need to run `bundle
290
- # install` for the first time to generate the Gemfile.lock with them included or else Bundler will complain that
291
- # they're missing. We can only update if the custom `.ruby-lsp/Gemfile.lock` already exists and includes all gems
358
+ # If the gems in GEMS_TO_UPDATE (and potentially `ruby-lsp-rails`) are already in the Gemfile, then we shouldn't
359
+ # try to upgrade them or else we'll produce undesired source control changes. If the composed bundle was just
360
+ # created and any of those gems weren't a part of the Gemfile, then we need to run `bundle install` for the first
361
+ # time to generate the Gemfile.lock with them included or else Bundler will complain that they're missing. We can
362
+ # only update if the custom `.ruby-lsp/Gemfile.lock` already exists and includes all gems
292
363
 
293
364
  # When not updating, we run `(bundle check || bundle install)`
294
- # When updating, we run `((bundle check && bundle update ruby-lsp debug) || bundle install)`
365
+ # When updating, we run `((bundle check && bundle update <GEMS_TO_UPDATE>) || bundle install)`
295
366
  bundler_path = File.join(Gem.default_bindir, "bundle")
296
367
  base_command = (!Gem.win_platform? && File.exist?(bundler_path) ? "#{Gem.ruby} #{bundler_path}" : "bundle").dup
297
368
 
@@ -302,12 +373,11 @@ module RubyLsp
302
373
  command = +"(#{base_command} check"
303
374
 
304
375
  if should_bundle_update?
305
- # If any of `ruby-lsp`, `ruby-lsp-rails` or `debug` are not in the Gemfile, try to update them to the latest
306
- # version
376
+ # If any of the gems in GEMS_TO_UPDATE (or `ruby-lsp-rails` for Rails apps) are not in the Gemfile, try to
377
+ # update them to the latest version
307
378
  command.prepend("(")
308
379
  command << " && #{base_command} update "
309
- command << "ruby-lsp " unless @dependencies["ruby-lsp"]
310
- command << "debug " unless @dependencies["debug"]
380
+ GEMS_TO_UPDATE.each { |gem| command << "#{gem} " unless @dependencies[gem] }
311
381
  command << "ruby-lsp-rails " if @rails_app && !@dependencies["ruby-lsp-rails"]
312
382
  command.delete_suffix!(" ")
313
383
  command << ")"
@@ -436,19 +506,16 @@ module RubyLsp
436
506
  end
437
507
 
438
508
  #: -> void
439
- def patch_thor_to_print_progress_to_stderr!
440
- return unless defined?(Bundler::Thor::Shell::Basic)
441
-
442
- Bundler::Thor::Shell::Basic.prepend(Module.new do
443
- extend T::Sig
509
+ def force_output_to_stderr!
510
+ # Bundler and RubyGems have different UI objects used for printing. We need to ensure that both are configured to
511
+ # print only to stderr or else they'll break the connection with the editor
512
+ Gem::DefaultUserInteraction.ui = Gem::StreamUI.new($stdin, $stderr, $stderr, false)
444
513
 
445
- sig { returns(IO) }
446
- def stdout
447
- $stderr
448
- end
449
- end)
514
+ ui = Bundler.ui
515
+ ui.output_stream = :stderr if ui.respond_to?(:output_stream=)
516
+ ui.level = :info
450
517
 
451
- Bundler.ui.level = :info
518
+ Bundler::Thor::Shell::Basic.prepend(ThorPatch) if defined?(Bundler::Thor::Shell::Basic)
452
519
  end
453
520
  end
454
521
  end
@@ -14,6 +14,7 @@ module RubyLsp
14
14
 
15
15
  # A map of keyword => short documentation to be displayed on hover or completion
16
16
  KEYWORD_DOCS = {
17
+ "break" => "Terminates the execution of a block or loop",
17
18
  "yield" => "Invokes the passed block with the given arguments",
18
19
  }.freeze #: Hash[String, String]
19
20
  end
@@ -5,9 +5,6 @@ module RubyLsp
5
5
  class Store
6
6
  class NonExistingDocumentError < StandardError; end
7
7
 
8
- #: Hash[Symbol, RequestConfig]
9
- attr_accessor :features_configuration
10
-
11
8
  #: String
12
9
  attr_accessor :client_name
13
10
 
@@ -15,13 +12,6 @@ module RubyLsp
15
12
  def initialize(global_state)
16
13
  @global_state = global_state
17
14
  @state = {} #: Hash[String, Document[untyped]]
18
- @features_configuration = {
19
- inlayHint: RequestConfig.new({
20
- enableAll: false,
21
- implicitRescue: false,
22
- implicitHashValue: false,
23
- }),
24
- } #: Hash[Symbol, RequestConfig]
25
15
  @client_name = "Unknown" #: String
26
16
  end
27
17
 
@@ -38,11 +28,11 @@ module RubyLsp
38
28
  ext = File.extname(path)
39
29
  language_id = case ext
40
30
  when ".erb", ".rhtml"
41
- Document::LanguageId::ERB
31
+ :erb
42
32
  when ".rbs"
43
- Document::LanguageId::RBS
33
+ :rbs
44
34
  else
45
- Document::LanguageId::Ruby
35
+ :ruby
46
36
  end
47
37
 
48
38
  set(uri: uri, source: File.binread(path), version: 0, language_id: language_id)
@@ -51,12 +41,12 @@ module RubyLsp
51
41
  raise NonExistingDocumentError, uri.to_s
52
42
  end
53
43
 
54
- #: (uri: URI::Generic, source: String, version: Integer, language_id: Document::LanguageId) -> Document[untyped]
44
+ #: (uri: URI::Generic, source: String, version: Integer, language_id: Symbol) -> Document[untyped]
55
45
  def set(uri:, source:, version:, language_id:)
56
46
  @state[uri.to_s] = case language_id
57
- when Document::LanguageId::ERB
47
+ when :erb
58
48
  ERBDocument.new(source: source, version: version, uri: uri, global_state: @global_state)
59
- when Document::LanguageId::RBS
49
+ when :rbs
60
50
  RBSDocument.new(source: source, version: version, uri: uri, global_state: @global_state)
61
51
  else
62
52
  RubyDocument.new(source: source, version: version, uri: uri, global_state: @global_state)
@@ -4,13 +4,10 @@
4
4
  # NOTE: This module is intended to be used by addons for writing their own tests, so keep that in mind if changing.
5
5
 
6
6
  module RubyLsp
7
+ # @requires_ancestor: Kernel
7
8
  module TestHelper
8
9
  class TestError < StandardError; end
9
10
 
10
- extend T::Helpers
11
-
12
- requires_ancestor { Kernel }
13
-
14
11
  #: [T] (?String? source, ?URI::Generic uri, ?stub_no_typechecker: bool, ?load_addons: bool) { (RubyLsp::Server server, URI::Generic uri) -> T } -> T
15
12
  def with_server(source = nil, uri = Kernel.URI("file:///fake.rb"), stub_no_typechecker: false, load_addons: true,
16
13
  &block)
@@ -1,35 +1,110 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "English"
4
5
  require "json"
5
6
  require "socket"
6
- require "singleton"
7
+ require "tmpdir"
8
+ require_relative "../../ruby_indexer/lib/ruby_indexer/uri"
7
9
 
8
10
  module RubyLsp
9
11
  class LspReporter
10
- include Singleton
12
+ @instance = nil #: LspReporter?
13
+
14
+ class << self
15
+ #: -> LspReporter
16
+ def instance
17
+ @instance ||= new
18
+ end
19
+
20
+ #: -> bool
21
+ def start_coverage?
22
+ ENV["RUBY_LSP_TEST_RUNNER"] == "coverage"
23
+ end
24
+
25
+ #: -> bool
26
+ def executed_under_test_runner?
27
+ !!(ENV["RUBY_LSP_TEST_RUNNER"] && ENV["RUBY_LSP_ENV"] != "test")
28
+ end
29
+
30
+ #: (Method | UnboundMethod) -> [URI::Generic, Integer?]?
31
+ def uri_and_line_for(method_object)
32
+ file_path, line = method_object.source_location
33
+ return unless file_path
34
+ return if file_path.start_with?("(eval at ")
35
+
36
+ uri = URI::Generic.from_path(path: File.expand_path(file_path))
37
+ zero_based_line = line ? line - 1 : nil
38
+ [uri, zero_based_line]
39
+ end
40
+ end
41
+
42
+ # https://code.visualstudio.com/api/references/vscode-api#Position
43
+ #: type position = { line: Integer, character: Integer }
44
+
45
+ # https://code.visualstudio.com/api/references/vscode-api#Range
46
+ #: type range = { start: position, end: position }
47
+
48
+ # https://code.visualstudio.com/api/references/vscode-api#BranchCoverage
49
+ #: type branch_coverage = { executed: Integer, label: String, location: range }
50
+
51
+ # https://code.visualstudio.com/api/references/vscode-api#StatementCoverage
52
+ #: type statement_coverage = { executed: Integer, location: position, branches: Array[branch_coverage] }
11
53
 
12
54
  #: -> void
13
55
  def initialize
56
+ dir_path = File.join(Dir.tmpdir, "ruby-lsp")
57
+ FileUtils.mkdir_p(dir_path)
58
+
59
+ port_db_path = File.join(dir_path, "test_reporter_port_db.json")
14
60
  port = ENV["RUBY_LSP_REPORTER_PORT"]
15
- @io = if port
16
- TCPSocket.new("localhost", port)
17
- else
18
- # For tests that don't spawn the TCP server
61
+
62
+ @io = begin
63
+ # The environment variable is only used for tests. The extension always writes to the temporary file
64
+ if port
65
+ socket(port)
66
+ elsif File.exist?(port_db_path)
67
+ db = JSON.load_file(port_db_path)
68
+ socket(db[Dir.pwd])
69
+ else
70
+ # For tests that don't spawn the TCP server
71
+ require "stringio"
72
+ StringIO.new
73
+ end
74
+ rescue
19
75
  require "stringio"
20
76
  StringIO.new
21
77
  end #: IO | StringIO
78
+
79
+ @invoked_shutdown = false #: bool
80
+ @message_queue = Thread::Queue.new #: Thread::Queue
81
+ @writer = Thread.new { write_loop } #: Thread
22
82
  end
23
83
 
24
84
  #: -> void
25
85
  def shutdown
86
+ # When running in coverage mode, we don't want to inform the extension that we finished immediately after running
87
+ # tests. We only do it after we finish processing coverage results, by invoking `internal_shutdown`
88
+ return if ENV["RUBY_LSP_TEST_RUNNER"] == "coverage"
89
+
90
+ internal_shutdown
91
+ end
92
+
93
+ # This method is intended to be used by the RubyLsp::LspReporter class itself only. If you're writing a custom test
94
+ # reporter, use `shutdown` instead
95
+ #: -> void
96
+ def internal_shutdown
97
+ @invoked_shutdown = true
98
+
26
99
  send_message("finish")
100
+ @message_queue.close
101
+ @writer.join
27
102
  @io.close
28
103
  end
29
104
 
30
- #: (id: String, uri: URI::Generic) -> void
31
- def start_test(id:, uri:)
32
- send_message("start", id: id, uri: uri.to_s)
105
+ #: (id: String, uri: URI::Generic, ?line: Integer?) -> void
106
+ def start_test(id:, uri:, line: nil)
107
+ send_message("start", id: id, uri: uri.to_s, line: line)
33
108
  end
34
109
 
35
110
  #: (id: String, uri: URI::Generic) -> void
@@ -79,14 +154,13 @@ module RubyLsp
79
154
  # ["Foo", :bar, 6, 21, 6, 65] => 0
80
155
  # }
81
156
  # }
82
- #: -> Hash[String, StatementCoverage]
157
+ #: -> Hash[String, statement_coverage]
83
158
  def gather_coverage_results
84
159
  # Ignore coverage results inside dependencies
85
160
  bundle_path = Bundler.bundle_path.to_s
86
- default_gems_path = File.dirname(RbConfig::CONFIG["rubylibdir"])
87
161
 
88
162
  result = Coverage.result.reject do |file_path, _coverage_info|
89
- file_path.start_with?(bundle_path, default_gems_path, "eval")
163
+ file_path.start_with?(bundle_path) || !file_path.start_with?(Dir.pwd)
90
164
  end
91
165
 
92
166
  result.to_h do |file_path, coverage_info|
@@ -129,24 +203,54 @@ module RubyLsp
129
203
  end
130
204
  end
131
205
 
206
+ #: -> void
207
+ def at_coverage_exit
208
+ coverage_results = gather_coverage_results
209
+ File.write(File.join(".ruby-lsp", "coverage_result.json"), coverage_results.to_json)
210
+ internal_shutdown
211
+ end
212
+
213
+ #: -> void
214
+ def at_exit
215
+ internal_shutdown unless @invoked_shutdown
216
+ end
217
+
132
218
  private
133
219
 
134
- #: (method_name: String?, params: untyped) -> void
220
+ #: (String) -> Socket
221
+ def socket(port)
222
+ socket = Socket.tcp("localhost", port)
223
+ socket.binmode
224
+ socket.sync = true
225
+ socket
226
+ end
227
+
228
+ #: (String?, **untyped) -> void
135
229
  def send_message(method_name, **params)
136
230
  json_message = { method: method_name, params: params }.to_json
137
- @io.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}")
231
+ @message_queue << "Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}"
232
+ end
233
+
234
+ #: -> void
235
+ def write_loop
236
+ while (message = @message_queue.pop)
237
+ @io.write(message)
238
+ end
138
239
  end
139
240
  end
140
241
  end
141
242
 
142
- if ENV["RUBY_LSP_TEST_RUNNER"] == "coverage"
243
+ if RubyLsp::LspReporter.start_coverage?
143
244
  # Auto start coverage when running tests under that profile. This avoids the user from having to configure coverage
144
245
  # manually for their project or adding extra dependencies
145
246
  require "coverage"
146
247
  Coverage.start(:all)
248
+ end
147
249
 
250
+ if RubyLsp::LspReporter.executed_under_test_runner?
148
251
  at_exit do
149
- coverage_results = RubyLsp::LspReporter.instance.gather_coverage_results
150
- File.write(File.join(".ruby-lsp", "coverage_result.json"), coverage_results.to_json)
252
+ # Regular finish events are registered per test reporter. However, if the test crashes during loading the files
253
+ # (e.g.: a bad require), we need to ensure that the execution is finalized so that the extension is not left hanging
254
+ RubyLsp::LspReporter.instance.at_exit if $ERROR_INFO
151
255
  end
152
256
  end
@@ -8,7 +8,6 @@ rescue LoadError
8
8
  end
9
9
 
10
10
  require_relative "lsp_reporter"
11
- require "ruby_indexer/lib/ruby_indexer/uri"
12
11
 
13
12
  module RubyLsp
14
13
  # An override of the default progress reporter in Minitest to add color to the output
@@ -31,6 +30,30 @@ module RubyLsp
31
30
  end
32
31
  end
33
32
 
33
+ # This patch is here to prevent other gems from overriding or adding more Minitest reporters. Otherwise, they may
34
+ # break the integration between the server and extension
35
+ module PreventReporterOverridePatch
36
+ @lsp_reporters = [] #: Array[Minitest::AbstractReporter]
37
+
38
+ class << self
39
+ #: Array[Minitest::AbstractReporter]
40
+ attr_accessor :lsp_reporters
41
+ end
42
+
43
+ # Patch the writer to prevent replacing the entire array
44
+ #: (untyped) -> void
45
+ def reporters=(reporters)
46
+ # Do nothing. We don't want other gems to override our reporter
47
+ end
48
+
49
+ # Patch the reader to prevent appending more reporters. This method always returns a temporary copy of the real
50
+ # reporters so that if any gem mutates it, it continues to return the original reporters
51
+ #: -> Array[untyped]
52
+ def reporters
53
+ PreventReporterOverridePatch.lsp_reporters.dup
54
+ end
55
+ end
56
+
34
57
  class MinitestReporter < Minitest::AbstractReporter
35
58
  class << self
36
59
  #: (Hash[untyped, untyped]) -> void
@@ -46,21 +69,43 @@ module RubyLsp
46
69
 
47
70
  # Add the JSON RPC reporter
48
71
  reporters << MinitestReporter.new
72
+ PreventReporterOverridePatch.lsp_reporters = reporters
73
+ Minitest.reporter.class.prepend(PreventReporterOverridePatch)
49
74
  end
50
75
  end
51
76
 
52
- #: (singleton(Minitest::Test) test_class, String method_name) -> void
53
- def prerecord(test_class, method_name)
54
- uri = uri_from_test_class(test_class, method_name)
77
+ #: (untyped, String) -> void
78
+ def prerecord(test_class_or_wrapper, method_name)
79
+ # In frameworks like Rails, they can control the Minitest execution by wrapping the test class
80
+ # But they conform to responding to `name`, so we can use that as a guarantee
81
+ # We are interested in the test class, not the wrapper
82
+ name = test_class_or_wrapper.name
83
+
84
+ klass = begin
85
+ Object.const_get(name) # rubocop:disable Sorbet/ConstantsFromStrings
86
+ rescue NameError
87
+ # Handle Minitest specs that create classes with invalid constant names like "MySpec::when something is true"
88
+ # If we can't resolve the constant, it means we were given the actual test class object, not the wrapper
89
+ test_class_or_wrapper
90
+ end
91
+
92
+ uri, line = LspReporter.uri_and_line_for(klass.instance_method(method_name))
55
93
  return unless uri
56
94
 
57
- LspReporter.instance.start_test(id: "#{test_class.name}##{method_name}", uri: uri)
95
+ id = "#{name}##{handle_spec_test_id(method_name, line)}"
96
+ LspReporter.instance.start_test(id: id, uri: uri, line: line)
58
97
  end
59
98
 
60
99
  #: (Minitest::Result result) -> void
61
100
  def record(result)
62
- id = "#{result.klass}##{result.name}"
63
- uri = uri_from_result(result)
101
+ file_path, line = result.source_location
102
+ return unless file_path
103
+
104
+ zero_based_line = line ? line - 1 : nil
105
+ name = handle_spec_test_id(result.name, zero_based_line)
106
+ id = "#{result.klass}##{name}"
107
+
108
+ uri = URI::Generic.from_path(path: File.expand_path(file_path))
64
109
 
65
110
  if result.error?
66
111
  message = result.failures.first.message
@@ -80,26 +125,21 @@ module RubyLsp
80
125
  LspReporter.instance.shutdown
81
126
  end
82
127
 
83
- private
84
-
85
- #: (Minitest::Result result) -> URI::Generic
86
- def uri_from_result(result)
87
- file = result.source_location[0]
88
- absolute_path = File.expand_path(file, Dir.pwd)
89
- URI::Generic.from_path(path: absolute_path)
90
- end
91
-
92
- #: (singleton(Minitest::Test) test_class, String method_name) -> URI::Generic?
93
- def uri_from_test_class(test_class, method_name)
94
- file, _line = test_class.instance_method(method_name).source_location
95
- return unless file
96
-
97
- return if file.start_with?("(eval at ") # test is dynamically defined
98
-
99
- absolute_path = File.expand_path(file, Dir.pwd)
100
- URI::Generic.from_path(path: absolute_path)
128
+ #: (String, Integer?) -> String
129
+ def handle_spec_test_id(method_name, line)
130
+ method_name.gsub(/(?<=test_)\d{4}(?=_)/, format("%04d", line.to_s))
101
131
  end
102
132
  end
103
133
  end
104
134
 
105
135
  Minitest.extensions << RubyLsp::MinitestReporter
136
+
137
+ if RubyLsp::LspReporter.start_coverage?
138
+ Minitest.after_run do
139
+ RubyLsp::LspReporter.instance.at_coverage_exit
140
+ end
141
+ elsif RubyLsp::LspReporter.executed_under_test_runner?
142
+ Minitest.after_run do
143
+ RubyLsp::LspReporter.instance.at_exit
144
+ end
145
+ end