ruby-lsp 0.26.1 → 0.26.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c80f549675508ffbb28d649de04506adabec01eac9aa4c6eee057ec848adf858
4
- data.tar.gz: 71ea1a4d628444b98bc1173748f5aecf0d71bdc8d3dc80f33b2779c9c78d9de0
3
+ metadata.gz: 90c12f93d4750216ef84e046fe970f0ccc8edd7567943b9f4be02dc3828fd151
4
+ data.tar.gz: ef810d7d1599f9539474e87067c5972ef2aa3a6143452494e9d03857d64771e7
5
5
  SHA512:
6
- metadata.gz: 7261bf15c095154ff36152492aa252cbd84b84f6e83eb458623c7bb9790a57957885a855c2ee10683b7035267177e323c013988befc02e96f3fdf0274d2312ca
7
- data.tar.gz: 74bcea4844e876230713400776bf7def1db44492bcf913526195b5d01919efd24ac69b517594ce2499e332184bf7ef4881e17e7694574bf4d35afabeda25b8c4
6
+ metadata.gz: 4a6ff236ecaabfd5e927a6f57f47d97ad6822dc647ce3531c57565adfc05fd592167decbabfa2ac3420d8294f193188103c7aa3a468b5f92dca7b18a92bb1b87
7
+ data.tar.gz: e34f78b58b73536faecf0bafb0f7a620bbbc3122c8fb8d1b526112b000055b78fe6d153ff0bf1bbd2300aaaffb3f5a6327fd7ef4248332b39ebb5a7c13fe94d5
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.26.1
1
+ 0.26.3
@@ -195,12 +195,13 @@ module RubyIndexer
195
195
  end
196
196
 
197
197
  # Fuzzy searches index entries based on Jaro-Winkler similarity. If no query is provided, all entries are returned
198
- #: (String? query) -> Array[Entry]
199
- def fuzzy_search(query)
198
+ #: (String? query) ?{ (Entry) -> bool? } -> Array[Entry]
199
+ def fuzzy_search(query, &condition)
200
200
  unless query
201
201
  entries = @entries.filter_map do |_name, entries|
202
202
  next if entries.first.is_a?(Entry::SingletonClass)
203
203
 
204
+ entries = entries.select(&condition) if condition
204
205
  entries
205
206
  end
206
207
 
@@ -212,6 +213,9 @@ module RubyIndexer
212
213
  results = @entries.filter_map do |name, entries|
213
214
  next if entries.first.is_a?(Entry::SingletonClass)
214
215
 
216
+ entries = entries.select(&condition) if condition
217
+ next if entries.empty?
218
+
215
219
  similarity = DidYouMean::JaroWinkler.distance(name.gsub("::", "").downcase, normalized_query)
216
220
  [entries, -similarity] if similarity > ENTRY_SIMILARITY_THRESHOLD
217
221
  end
@@ -424,7 +428,10 @@ module RubyIndexer
424
428
  (parts.length - 1).downto(0) do |i|
425
429
  current_name = parts[0..i] #: as !nil
426
430
  .join("::")
427
- entry = @entries[current_name]&.first
431
+
432
+ entry = unless seen_names.include?(current_name)
433
+ @entries[current_name]&.first
434
+ end
428
435
 
429
436
  case entry
430
437
  when Entry::ConstantAlias
@@ -904,6 +911,9 @@ module RubyIndexer
904
911
  target = resolve(entry.target, entry.nesting, seen_names)
905
912
  return entry unless target
906
913
 
914
+ # Self referential alias can be unresolved we should bail out from resolving
915
+ return entry if target.first == entry
916
+
907
917
  target_name = target.first #: as !nil
908
918
  .name
909
919
  resolved_alias = Entry::ConstantAlias.new(target_name, entry)
@@ -1023,11 +1033,19 @@ module RubyIndexer
1023
1033
  name_parts.join("::")
1024
1034
  end
1025
1035
 
1036
+ # Tries to return direct entry from index then non seen canonicalized alias or nil
1026
1037
  #: (String full_name, Array[String] seen_names) -> Array[Entry::Constant | Entry::ConstantAlias | Entry::Namespace | Entry::UnresolvedConstantAlias]?
1027
1038
  def direct_or_aliased_constant(full_name, seen_names)
1028
- entries = @entries[full_name] || @entries[follow_aliased_namespace(full_name)]
1039
+ if (entries = @entries[full_name])
1040
+ return entries.map do |e|
1041
+ e.is_a?(Entry::UnresolvedConstantAlias) ? resolve_alias(e, seen_names) : e
1042
+ end #: as Array[Entry::Constant | Entry::ConstantAlias | Entry::Namespace | Entry::UnresolvedConstantAlias])?
1043
+ end
1044
+
1045
+ aliased = follow_aliased_namespace(full_name, seen_names)
1046
+ return if full_name == aliased || seen_names.include?(aliased)
1029
1047
 
1030
- entries&.map do |e|
1048
+ @entries[aliased]&.map do |e|
1031
1049
  e.is_a?(Entry::UnresolvedConstantAlias) ? resolve_alias(e, seen_names) : e
1032
1050
  end #: as Array[Entry::Constant | Entry::ConstantAlias | Entry::Namespace | Entry::UnresolvedConstantAlias])?
1033
1051
  end
@@ -1152,6 +1152,46 @@ module RubyIndexer
1152
1152
  assert_equal(2, foo_entry.location.start_line)
1153
1153
  end
1154
1154
 
1155
+ def test_resolving_self_referential_constant_alias
1156
+ index(<<~RUBY)
1157
+ module A
1158
+ module B
1159
+ class C
1160
+ end
1161
+ end
1162
+ end
1163
+
1164
+ module A
1165
+ module D
1166
+ B = B::C
1167
+ end
1168
+ end
1169
+ RUBY
1170
+
1171
+ entry = @index.resolve("A::D::B", [])&.first #: as Entry::ConstantAlias
1172
+
1173
+ assert_kind_of(RubyIndexer::Entry::ConstantAlias, entry)
1174
+ assert_equal(10, entry.location.start_line)
1175
+ assert_equal("A::B::C", entry.target)
1176
+ end
1177
+
1178
+ def test_resolving_non_existing_self_referential_constant_alias
1179
+ index(<<~RUBY)
1180
+ module Foo
1181
+ SomeClass = ::SomeClass
1182
+ UNRESOLVED = SomeClass::CONSTANT
1183
+ end
1184
+ RUBY
1185
+
1186
+ entry = @index.resolve("Foo::UNRESOLVED", [])&.first #: as Entry::UnresolvedConstantAlias
1187
+ assert_kind_of(Entry::UnresolvedConstantAlias, entry)
1188
+ assert_equal(3, entry.location.start_line)
1189
+ assert_equal("SomeClass::CONSTANT", entry.target)
1190
+
1191
+ entry = @index.resolve("SomeClass::CONSTANT", ["Foo"])
1192
+ refute(entry)
1193
+ end
1194
+
1155
1195
  def test_resolving_qualified_references
1156
1196
  index(<<~RUBY)
1157
1197
  module Namespace
@@ -83,7 +83,7 @@ module RubyLsp
83
83
  # The following requests need to be executed in the main thread directly to avoid concurrency issues. Everything
84
84
  # else is pushed into the incoming queue
85
85
  case method
86
- when "initialize", "initialized", "rubyLsp/diagnoseState"
86
+ when "initialize", "initialized", "rubyLsp/diagnoseState", "$/cancelRequest"
87
87
  process_message(message)
88
88
  when "shutdown"
89
89
  @global_state.synchronize do
@@ -103,7 +103,7 @@ module RubyLsp
103
103
  # This method is only intended to be used in tests! Pops the latest response that would be sent to the client
104
104
  #: -> untyped
105
105
  def pop_response
106
- @outgoing_queue.pop
106
+ @outgoing_queue.pop(timeout: 20) || raise("No message received from server")
107
107
  end
108
108
 
109
109
  # This method is only intended to be used in tests! Pushes a message to the incoming queue directly
@@ -154,20 +154,29 @@ module RubyLsp
154
154
  def new_worker
155
155
  Thread.new do
156
156
  while (message = @incoming_queue.pop)
157
- id = message[:id]
158
-
159
- # Check if the request was cancelled before trying to process it
160
- @global_state.synchronize do
161
- if id && @cancelled_requests.include?(id)
162
- send_message(Result.new(id: id, response: nil))
163
- @cancelled_requests.delete(id)
164
- next
165
- end
166
- end
157
+ handle_incoming_message(message)
158
+ end
159
+ end
160
+ end
167
161
 
168
- process_message(message)
162
+ #: (Hash[Symbol, untyped]) -> void
163
+ def handle_incoming_message(message)
164
+ id = message[:id]
165
+
166
+ # Check if the request was cancelled before trying to process it
167
+ @global_state.synchronize do
168
+ if id && @cancelled_requests.include?(id)
169
+ send_message(Error.new(
170
+ id: id,
171
+ code: Constant::ErrorCodes::REQUEST_CANCELLED,
172
+ message: "Request #{id} was cancelled",
173
+ ))
174
+ @cancelled_requests.delete(id)
175
+ return
169
176
  end
170
177
  end
178
+
179
+ process_message(message)
171
180
  end
172
181
 
173
182
  #: ((Result | Error | Notification | Request) message) -> void
@@ -92,7 +92,8 @@ module RubyLsp
92
92
  Prism::RequiredParameterNode, Prism::RestParameterNode
93
93
  [target, node_value(target)]
94
94
  when Prism::ModuleNode, Prism::ClassNode, Prism::SingletonClassNode, Prism::DefNode, Prism::CaseNode,
95
- Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::IfNode, Prism::UnlessNode
95
+ Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::IfNode, Prism::UnlessNode, Prism::BlockNode,
96
+ Prism::LambdaNode, Prism::BeginNode
96
97
  [target, nil]
97
98
  end
98
99
 
@@ -157,6 +158,9 @@ module RubyLsp
157
158
  :on_for_node_enter,
158
159
  :on_if_node_enter,
159
160
  :on_unless_node_enter,
161
+ :on_block_node_enter,
162
+ :on_lambda_node_enter,
163
+ :on_begin_node_enter,
160
164
  )
161
165
  end
162
166
  end
@@ -551,6 +555,27 @@ module RubyLsp
551
555
  add_matching_end_highlights(node.keyword_loc, node.end_keyword_loc)
552
556
  end
553
557
 
558
+ #: (Prism::BlockNode node) -> void
559
+ def on_block_node_enter(node)
560
+ return unless @target.is_a?(Prism::BlockNode)
561
+
562
+ add_matching_end_highlights(node.opening_loc, node.closing_loc)
563
+ end
564
+
565
+ #: (Prism::LambdaNode node) -> void
566
+ def on_lambda_node_enter(node)
567
+ return unless @target.is_a?(Prism::LambdaNode)
568
+
569
+ add_matching_end_highlights(node.opening_loc, node.closing_loc)
570
+ end
571
+
572
+ #: (Prism::BeginNode node) -> void
573
+ def on_begin_node_enter(node)
574
+ return unless @target.is_a?(Prism::BeginNode)
575
+
576
+ add_matching_end_highlights(node.begin_keyword_loc, node.end_keyword_loc)
577
+ end
578
+
554
579
  private
555
580
 
556
581
  #: (Prism::Node node, Array[singleton(Prism::Node)] classes) -> bool?
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "ruby_lsp/requests/support/source_uri"
5
+ require "ruby_lsp/requests/support/package_url"
5
6
 
6
7
  module RubyLsp
7
8
  module Listeners
@@ -102,19 +103,58 @@ module RubyLsp
102
103
  comment = @lines_to_comments[node.location.start_line - 1]
103
104
  return unless comment
104
105
 
105
- match = comment.location.slice.match(%r{source://.*#\d+$})
106
+ match = comment.location.slice.match(%r{(source://.*#\d+|pkg:gem/.*#.*)$})
106
107
  return unless match
107
108
 
108
- uri = begin
109
- URI(
110
- match[0], #: as !nil
111
- )
109
+ uri_string = match[0] #: as !nil
110
+
111
+ file_path, line_number = if uri_string.start_with?("pkg:gem/")
112
+ parse_package_url(uri_string)
113
+ else
114
+ parse_source_uri(uri_string)
115
+ end
116
+
117
+ return unless file_path
118
+
119
+ @response_builder << Interface::DocumentLink.new(
120
+ range: range_from_location(comment.location),
121
+ target: "file://#{file_path}##{line_number}",
122
+ tooltip: "Jump to #{file_path}##{line_number}",
123
+ )
124
+ end
125
+
126
+ #: (String uri_string) -> [String, String]?
127
+ def parse_package_url(uri_string)
128
+ purl = PackageURL.parse(uri_string) #: as PackageURL?
129
+ return unless purl
130
+
131
+ gem_version = resolve_version(purl.version, purl.name)
132
+ return if gem_version.nil?
133
+
134
+ path, line_number = purl.subpath.split(":", 2)
135
+ return unless path
136
+
137
+ gem_name = purl.name
138
+ return unless gem_name
139
+
140
+ file_path = self.class.gem_paths.dig(gem_name, gem_version, CGI.unescape(path))
141
+ return if file_path.nil?
142
+
143
+ [file_path, line_number]
144
+ rescue PackageURL::InvalidPackageURL
145
+ nil
146
+ end
147
+
148
+ #: (String uri_string) -> [String, String]?
149
+ def parse_source_uri(uri_string)
150
+ uri = begin
151
+ URI(uri_string)
112
152
  rescue URI::Error
113
153
  nil
114
154
  end #: as URI::Source?
115
155
  return unless uri
116
156
 
117
- gem_version = resolve_version(uri)
157
+ gem_version = resolve_version(uri.gem_version, uri.gem_name)
118
158
  return if gem_version.nil?
119
159
 
120
160
  path = uri.path
@@ -126,28 +166,20 @@ module RubyLsp
126
166
  file_path = self.class.gem_paths.dig(gem_name, gem_version, CGI.unescape(path))
127
167
  return if file_path.nil?
128
168
 
129
- @response_builder << Interface::DocumentLink.new(
130
- range: range_from_location(comment.location),
131
- target: "file://#{file_path}##{uri.line_number}",
132
- tooltip: "Jump to #{file_path}##{uri.line_number}",
133
- )
169
+ [file_path, uri.line_number || "0"]
134
170
  end
135
171
 
136
172
  # Try to figure out the gem version for a source:// link. The order of precedence is:
137
173
  # 1. The version in the URI
138
174
  # 2. The version in the RBI file name
139
175
  # 3. The version from the gemspec
140
- #: (URI::Source uri) -> String?
141
- def resolve_version(uri)
142
- version = uri.gem_version
176
+ #: (String? version, String? gem_name) -> String?
177
+ def resolve_version(version, gem_name)
143
178
  return version unless version.nil? || version.empty?
144
179
 
145
180
  return @gem_version unless @gem_version.nil? || @gem_version.empty?
146
181
 
147
- gem_name = uri.gem_name
148
- return unless gem_name
149
-
150
- GEM_TO_VERSION_MAP[gem_name]
182
+ GEM_TO_VERSION_MAP[gem_name.to_s]
151
183
  end
152
184
  end
153
185
  end
@@ -58,13 +58,11 @@ module RubyLsp
58
58
 
59
59
  #: (Prism::CallNode) -> void
60
60
  def on_call_node_enter(node) # rubocop:disable RubyLsp/UseRegisterWithHandlerMethod
61
- return unless in_spec_context?
62
-
63
61
  case node.name
64
62
  when :describe
65
63
  handle_describe(node)
66
64
  when :it, :specify
67
- handle_example(node)
65
+ handle_example(node) if in_spec_context?
68
66
  end
69
67
  end
70
68
 
@@ -117,10 +115,7 @@ module RubyLsp
117
115
 
118
116
  #: (Prism::CallNode) -> void
119
117
  def handle_example(node)
120
- # Minitest formats the descriptions into test method names by using the count of examples with the description
121
- # We are not guaranteed to discover examples in the exact order using static analysis, so we use the line number
122
- # instead. Note that anonymous examples mixed with meta-programming will not be handled correctly
123
- description = extract_description(node) || "anonymous"
118
+ description = extract_it_description(node)
124
119
  line = node.location.start_line - 1
125
120
  parent = latest_group
126
121
  return unless parent.is_a?(Requests::Support::TestItem)
@@ -141,16 +136,37 @@ module RubyLsp
141
136
 
142
137
  #: (Prism::CallNode) -> String?
143
138
  def extract_description(node)
139
+ arguments = node.arguments&.arguments
140
+ return unless arguments
141
+
142
+ parts = arguments.map { |arg| extract_argument_content(arg) }
143
+ return if parts.empty?
144
+
145
+ parts.join("::")
146
+ end
147
+
148
+ #: (Prism::CallNode) -> String
149
+ def extract_it_description(node)
150
+ # Minitest formats the descriptions into test method names by using the count of examples with the description
151
+ # We are not guaranteed to discover examples in the exact order using static analysis, so we use the line number
152
+ # instead. Note that anonymous examples mixed with meta-programming will not be handled correctly
144
153
  first_argument = node.arguments&.arguments&.first
145
- return unless first_argument
154
+ return "anonymous" unless first_argument
155
+
156
+ extract_argument_content(first_argument) || "anonymous"
157
+ end
146
158
 
147
- case first_argument
159
+ #: (Prism::Node) -> String?
160
+ def extract_argument_content(arg)
161
+ case arg
148
162
  when Prism::StringNode
149
- first_argument.content
163
+ arg.content
164
+ when Prism::SymbolNode
165
+ arg.value
150
166
  when Prism::ConstantReadNode, Prism::ConstantPathNode
151
- constant_name(first_argument)
167
+ constant_name(arg)
152
168
  else
153
- first_argument.slice
169
+ arg.slice
154
170
  end
155
171
  end
156
172
 
@@ -190,12 +206,13 @@ module RubyLsp
190
206
  end
191
207
 
192
208
  # Specs only using describes
193
- first_group = @spec_group_id_stack.find { |i| i.is_a?(DescribeGroup) }
194
- return unless first_group
209
+ first_group_index = @spec_group_id_stack.index { |i| i.is_a?(DescribeGroup) }
210
+ return unless first_group_index
195
211
 
212
+ first_group = @spec_group_id_stack[first_group_index] #: as !nil
196
213
  item = @response_builder[first_group.id] #: as !nil
197
214
 
198
- @spec_group_id_stack[1..] #: as !nil
215
+ @spec_group_id_stack[first_group_index + 1..] #: as !nil
199
216
  .each do |group|
200
217
  next unless group.is_a?(DescribeGroup)
201
218
 
@@ -61,11 +61,26 @@ module RubyLsp
61
61
 
62
62
  #: (Prism::ClassNode node, String fully_qualified_name) -> Array[String]
63
63
  def calc_attached_ancestors(node, fully_qualified_name)
64
- @index.linearized_ancestors_of(fully_qualified_name)
65
- rescue RubyIndexer::Index::NonExistingNamespaceError
66
- # When there are dynamic parts in the constant path, we will not have indexed the namespace. We can still
67
- # provide test functionality if the class inherits directly from Test::Unit::TestCase or Minitest::Test
68
- [node.superclass&.slice].compact
64
+ superclass = node.superclass
65
+
66
+ begin
67
+ ancestors = @index.linearized_ancestors_of(fully_qualified_name)
68
+ # If the project has no bundle and a test class inherits from `Minitest::Test`, the linearized ancestors will
69
+ # not include the parent class because we never indexed it in the first place. Here we add the superclass
70
+ # directly, so that we can support running tests in projects without a bundle
71
+ return ancestors if ancestors.length > 1
72
+
73
+ # If all we found is the class itself, then manually include the parent class
74
+ if ancestors.first == fully_qualified_name && superclass
75
+ return [*ancestors, superclass.slice]
76
+ end
77
+
78
+ ancestors
79
+ rescue RubyIndexer::Index::NonExistingNamespaceError
80
+ # When there are dynamic parts in the constant path, we will not have indexed the namespace. We can still
81
+ # provide test functionality if the class inherits directly from Test::Unit::TestCase or Minitest::Test
82
+ [superclass&.slice].compact
83
+ end
69
84
  end
70
85
 
71
86
  #: (Prism::ConstantPathNode | Prism::ConstantReadNode | Prism::ConstantPathTargetNode | Prism::CallNode | Prism::MissingNode node) -> String
@@ -33,13 +33,15 @@ module RubyLsp
33
33
 
34
34
  if tags.include?("test_dir")
35
35
  if children.empty?
36
- full_files.concat(Dir.glob(
37
- "#{path}/**/{*_test,test_*,*_spec}.rb",
38
- File::Constants::FNM_EXTGLOB | File::Constants::FNM_PATHNAME,
39
- ))
36
+ full_files.concat(
37
+ Dir.glob(
38
+ "#{path}/**/{*_test,test_*,*_spec}.rb",
39
+ File::Constants::FNM_EXTGLOB | File::Constants::FNM_PATHNAME,
40
+ ).map! { |p| Shellwords.escape(p) },
41
+ )
40
42
  end
41
43
  elsif tags.include?("test_file")
42
- full_files << path if children.empty?
44
+ full_files << Shellwords.escape(path) if children.empty?
43
45
  elsif tags.include?("test_group")
44
46
  # If all of the children of the current test group are other groups, then there's no need to add it to the
45
47
  # aggregated examples
@@ -114,7 +116,7 @@ module RubyLsp
114
116
  end
115
117
 
116
118
  load_path = spec?(file_path) ? "-Ispec" : "-Itest"
117
- "#{COMMAND} #{load_path} #{file_path} --name \"/#{regex}/\""
119
+ "#{COMMAND} #{load_path} #{Shellwords.escape(file_path)} --name \"/#{regex}/\""
118
120
  end
119
121
 
120
122
  #: (String, Hash[String, Hash[Symbol, untyped]]) -> Array[String]
@@ -125,7 +127,7 @@ module RubyLsp
125
127
  Shellwords.escape(TestDiscovery::DYNAMIC_REFERENCE_MARKER),
126
128
  ".*",
127
129
  )
128
- command = +"#{COMMAND} -Itest #{file_path} --testcase \"/^#{group_regex}\\$/\""
130
+ command = +"#{COMMAND} -Itest #{Shellwords.escape(file_path)} --testcase \"/^#{group_regex}\\$/\""
129
131
 
130
132
  unless examples.empty?
131
133
  command << if examples.length == 1
@@ -145,7 +147,7 @@ module RubyLsp
145
147
  MINITEST_REPORTER_PATH = File.expand_path("../test_reporters/minitest_reporter.rb", __dir__) #: String
146
148
  TEST_UNIT_REPORTER_PATH = File.expand_path("../test_reporters/test_unit_reporter.rb", __dir__) #: String
147
149
  BASE_COMMAND = begin
148
- Bundler.with_original_env { Bundler.default_lockfile }
150
+ Bundler.with_unbundled_env { Bundler.default_lockfile }
149
151
  "bundle exec ruby"
150
152
  rescue Bundler::GemfileNotFound
151
153
  "ruby"
@@ -35,24 +35,78 @@ module RubyLsp
35
35
 
36
36
  #: -> Array[String]
37
37
  def find_relevant_paths
38
- candidate_paths = Dir.glob(File.join("**", relevant_filename_pattern))
38
+ patterns = relevant_filename_patterns
39
+
40
+ candidate_paths = patterns.flat_map do |pattern|
41
+ Dir.glob(File.join(search_root, "**", pattern))
42
+ end
43
+
39
44
  return [] if candidate_paths.empty?
40
45
 
41
- find_most_similar_with_jaccard(candidate_paths).map { File.join(@workspace_path, _1) }
46
+ find_most_similar_with_jaccard(candidate_paths).map { |path| File.expand_path(path, @workspace_path) }
42
47
  end
43
48
 
49
+ # Determine the search roots based on the closest test directories.
50
+ # This scopes the search to reduce the number of files that need to be checked.
44
51
  #: -> String
45
- def relevant_filename_pattern
46
- input_basename = File.basename(@path, File.extname(@path))
52
+ def search_root
53
+ current_path = File.join(".", @path)
54
+ current_dir = File.dirname(current_path)
55
+ while current_dir != "."
56
+ dir_basename = File.basename(current_dir)
57
+
58
+ # If current directory is a test directory, return its parent as search root
59
+ if TEST_KEYWORDS.include?(dir_basename)
60
+ return File.dirname(current_dir)
61
+ end
47
62
 
48
- relevant_basename_pattern =
49
- if input_basename.match?(TEST_PATTERN)
50
- input_basename.gsub(TEST_PATTERN, "")
51
- else
52
- "{{#{TEST_PREFIX_GLOB}}#{input_basename},#{input_basename}{#{TEST_SUFFIX_GLOB}}}"
63
+ # Search the test directories by walking up the directory tree
64
+ begin
65
+ contains_test_dir = Dir
66
+ .entries(current_dir)
67
+ .filter { |entry| TEST_KEYWORDS.include?(entry) }
68
+ .any? { |entry| File.directory?(File.join(current_dir, entry)) }
69
+
70
+ return current_dir if contains_test_dir
71
+ rescue Errno::EACCES, Errno::ENOENT
72
+ # Skip directories we can't read
53
73
  end
54
74
 
55
- "#{relevant_basename_pattern}#{File.extname(@path)}"
75
+ # Move up one level
76
+ parent_dir = File.dirname(current_dir)
77
+ current_dir = parent_dir
78
+ end
79
+
80
+ "."
81
+ end
82
+
83
+ #: -> Array[String]
84
+ def relevant_filename_patterns
85
+ extension = File.extname(@path)
86
+ input_basename = File.basename(@path, extension)
87
+
88
+ if input_basename.match?(TEST_PATTERN)
89
+ # Test file -> find implementation
90
+ base = input_basename.gsub(TEST_PATTERN, "")
91
+ parent_dir = File.basename(File.dirname(@path))
92
+
93
+ # If test file is in a directory matching the implementation name
94
+ # (e.g., go_to_relevant_file/test_go_to_relevant_file_a.rb)
95
+ # return patterns for both the base file name and the parent directory name
96
+ if base.include?(parent_dir) && base != parent_dir
97
+ ["#{base}#{extension}", "#{parent_dir}#{extension}"]
98
+ else
99
+ ["#{base}#{extension}"]
100
+ end
101
+ else
102
+ # Implementation file -> find tests (including in matching directory)
103
+ [
104
+ "{#{TEST_PREFIX_GLOB}}#{input_basename}#{extension}",
105
+ "#{input_basename}{#{TEST_SUFFIX_GLOB}}#{extension}",
106
+ "#{input_basename}/{#{TEST_PREFIX_GLOB}}*#{extension}",
107
+ "#{input_basename}/*{#{TEST_SUFFIX_GLOB}}#{extension}",
108
+ ]
109
+ end
56
110
  end
57
111
 
58
112
  # Using the Jaccard algorithm to determine the similarity between the
@@ -140,21 +140,25 @@ module RubyLsp
140
140
  end
141
141
  end
142
142
 
143
- #: (RubyIndexer::Entry entry) -> Integer?
143
+ #: (RubyIndexer::Entry entry) -> Integer
144
144
  def kind_for_entry(entry)
145
145
  case entry
146
146
  when RubyIndexer::Entry::Class
147
147
  Constant::SymbolKind::CLASS
148
148
  when RubyIndexer::Entry::Module
149
149
  Constant::SymbolKind::NAMESPACE
150
- when RubyIndexer::Entry::Constant
150
+ when RubyIndexer::Entry::Constant, RubyIndexer::Entry::UnresolvedConstantAlias, RubyIndexer::Entry::ConstantAlias
151
151
  Constant::SymbolKind::CONSTANT
152
- when RubyIndexer::Entry::Method
152
+ when RubyIndexer::Entry::Method, RubyIndexer::Entry::UnresolvedMethodAlias, RubyIndexer::Entry::MethodAlias
153
153
  entry.name == "initialize" ? Constant::SymbolKind::CONSTRUCTOR : Constant::SymbolKind::METHOD
154
154
  when RubyIndexer::Entry::Accessor
155
155
  Constant::SymbolKind::PROPERTY
156
- when RubyIndexer::Entry::InstanceVariable
156
+ when RubyIndexer::Entry::InstanceVariable, RubyIndexer::Entry::ClassVariable
157
157
  Constant::SymbolKind::FIELD
158
+ when RubyIndexer::Entry::GlobalVariable
159
+ Constant::SymbolKind::VARIABLE
160
+ else
161
+ Constant::SymbolKind::NULL
158
162
  end
159
163
  end
160
164
  end
@@ -0,0 +1,414 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ # This is a copy of the implementation from the `package_url` gem with the
5
+ # following license. Original source can be found at:
6
+ # https://github.com/package-url/packageurl-ruby/blob/main/lib/package_url.rb
7
+
8
+ # MIT License
9
+ #
10
+ # Copyright (c) 2021 package-url
11
+ #
12
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ # of this software and associated documentation files (the "Software"), to deal
14
+ # in the Software without restriction, including without limitation the rights
15
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ # copies of the Software, and to permit persons to whom the Software is
17
+ # furnished to do so, subject to the following conditions:
18
+ #
19
+ # The above copyright notice and this permission notice shall be included in all
20
+ # copies or substantial portions of the Software.
21
+ #
22
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ # SOFTWARE.
29
+
30
+ require "uri"
31
+
32
+ # A package URL, or _purl_, is a URL string used to
33
+ # identify and locate a software package in a mostly universal and uniform way
34
+ # across programing languages, package managers, packaging conventions, tools,
35
+ # APIs and databases.
36
+ #
37
+ # A purl is a URL composed of seven components:
38
+ #
39
+ # ```
40
+ # scheme:type/namespace/name@version?qualifiers#subpath
41
+ # ```
42
+ #
43
+ # For example,
44
+ # the package URL for this Ruby package at version 0.1.0 is
45
+ # `pkg:ruby/mattt/packageurl-ruby@0.1.0`.
46
+ module RubyLsp
47
+ class PackageURL
48
+ # Raised when attempting to parse an invalid package URL string.
49
+ # @see #parse
50
+ class InvalidPackageURL < ArgumentError; end
51
+
52
+ # The URL scheme, which has a constant value of `"pkg"`.
53
+ def scheme
54
+ "pkg"
55
+ end
56
+
57
+ # The package type or protocol, such as `"gem"`, `"npm"`, and `"github"`.
58
+ attr_reader :type
59
+
60
+ # A name prefix, specific to the type of package.
61
+ # For example, an npm scope, a Docker image owner, or a GitHub user.
62
+ attr_reader :namespace
63
+
64
+ # The name of the package.
65
+ attr_reader :name
66
+
67
+ # The version of the package.
68
+ attr_reader :version
69
+
70
+ # Extra qualifying data for a package, specific to the type of package.
71
+ # For example, the operating system or architecture.
72
+ attr_reader :qualifiers
73
+
74
+ # An extra subpath within a package, relative to the package root.
75
+ attr_reader :subpath
76
+
77
+ # Constructs a package URL from its components
78
+ # @param type [String] The package type or protocol.
79
+ # @param namespace [String] A name prefix, specific to the type of package.
80
+ # @param name [String] The name of the package.
81
+ # @param version [String] The version of the package.
82
+ # @param qualifiers [Hash] Extra qualifying data for a package, specific to the type of package.
83
+ # @param subpath [String] An extra subpath within a package, relative to the package root.
84
+ def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil)
85
+ raise ArgumentError, "type is required" if type.nil? || type.empty?
86
+ raise ArgumentError, "name is required" if name.nil? || name.empty?
87
+
88
+ @type = type.downcase
89
+ @namespace = namespace
90
+ @name = name
91
+ @version = version
92
+ @qualifiers = qualifiers
93
+ @subpath = subpath
94
+ end
95
+
96
+ # Creates a new PackageURL from a string.
97
+ # @param [String] string The package URL string.
98
+ # @raise [InvalidPackageURL] If the string is not a valid package URL.
99
+ # @return [PackageURL]
100
+ def self.parse(string)
101
+ components = {
102
+ type: nil,
103
+ namespace: nil,
104
+ name: nil,
105
+ version: nil,
106
+ qualifiers: nil,
107
+ subpath: nil,
108
+ }
109
+
110
+ # Split the purl string once from right on '#'
111
+ # - The left side is the remainder
112
+ # - Strip the right side from leading and trailing '/'
113
+ # - Split this on '/'
114
+ # - Discard any empty string segment from that split
115
+ # - Discard any '.' or '..' segment from that split
116
+ # - Percent-decode each segment
117
+ # - UTF-8-decode each segment if needed in your programming language
118
+ # - Join segments back with a '/'
119
+ # - This is the subpath
120
+ case string.rpartition("#")
121
+ in String => remainder, separator, String => subpath unless separator.empty?
122
+ subpath_components = []
123
+ subpath.split("/").each do |segment|
124
+ next if segment.empty? || segment == "." || segment == ".."
125
+
126
+ subpath_components << URI.decode_www_form_component(segment)
127
+ end
128
+
129
+ components[:subpath] = subpath_components.compact.join("/")
130
+
131
+ string = remainder
132
+ else
133
+ components[:subpath] = nil
134
+ end
135
+
136
+ # Split the remainder once from right on '?'
137
+ # - The left side is the remainder
138
+ # - The right side is the qualifiers string
139
+ # - Split the qualifiers on '&'. Each part is a key=value pair
140
+ # - For each pair, split the key=value once from left on '=':
141
+ # - The key is the lowercase left side
142
+ # - The value is the percent-decoded right side
143
+ # - UTF-8-decode the value if needed in your programming language
144
+ # - Discard any key/value pairs where the value is empty
145
+ # - If the key is checksums,
146
+ # split the value on ',' to create a list of checksums
147
+ # - This list of key/value is the qualifiers object
148
+ case string.rpartition("?")
149
+ in String => remainder, separator, String => qualifiers unless separator.empty?
150
+ components[:qualifiers] = {}
151
+
152
+ qualifiers.split("&").each do |pair|
153
+ case pair.partition("=")
154
+ in String => key, separator, String => value unless separator.empty?
155
+ key = key.downcase
156
+ value = URI.decode_www_form_component(value)
157
+ next if value.empty?
158
+
159
+ components[:qualifiers][key] = case key
160
+ when "checksums"
161
+ value.split(",")
162
+ else
163
+ value
164
+ end
165
+ else
166
+ next
167
+ end
168
+ end
169
+
170
+ string = remainder
171
+ else
172
+ components[:qualifiers] = nil
173
+ end
174
+
175
+ # Split the remainder once from left on ':'
176
+ # - The left side lowercased is the scheme
177
+ # - The right side is the remainder
178
+ case string.partition(":")
179
+ in "pkg", separator, String => remainder unless separator.empty?
180
+ string = remainder
181
+ else
182
+ raise InvalidPackageURL, 'invalid or missing "pkg:" URL scheme'
183
+ end
184
+
185
+ # Strip the remainder from leading and trailing '/'
186
+ # Use gsub to remove ALL leading slashes instead of just one
187
+ string = string.gsub(%r{^/+}, "").delete_suffix("/")
188
+ # - Split this once from left on '/'
189
+ # - The left side lowercased is the type
190
+ # - The right side is the remainder
191
+ case string.partition("/")
192
+ in String => type, separator, remainder unless separator.empty?
193
+ components[:type] = type
194
+
195
+ string = remainder
196
+ else
197
+ raise InvalidPackageURL, "invalid or missing package type"
198
+ end
199
+
200
+ # Split the remainder once from right on '@'
201
+ # - The left side is the remainder
202
+ # - Percent-decode the right side. This is the version.
203
+ # - UTF-8-decode the version if needed in your programming language
204
+ # - This is the version
205
+ case string.rpartition("@")
206
+ in String => remainder, separator, String => version unless separator.empty?
207
+ components[:version] = URI.decode_www_form_component(version)
208
+
209
+ string = remainder
210
+ else
211
+ components[:version] = nil
212
+ end
213
+
214
+ # Split the remainder once from right on '/'
215
+ # - The left side is the remainder
216
+ # - Percent-decode the right side. This is the name
217
+ # - UTF-8-decode this name if needed in your programming language
218
+ # - Apply type-specific normalization to the name if needed
219
+ # - This is the name
220
+ case string.rpartition("/")
221
+ in String => remainder, separator, String => name unless separator.empty?
222
+ components[:name] = URI.decode_www_form_component(name)
223
+
224
+ # Split the remainder on '/'
225
+ # - Discard any empty segment from that split
226
+ # - Percent-decode each segment
227
+ # - UTF-8-decode the each segment if needed in your programming language
228
+ # - Apply type-specific normalization to each segment if needed
229
+ # - Join segments back with a '/'
230
+ # - This is the namespace
231
+ components[:namespace] = remainder.split("/").map { |s| URI.decode_www_form_component(s) }.compact.join("/")
232
+ in _, _, String => name
233
+ components[:name] = URI.decode_www_form_component(name)
234
+ components[:namespace] = nil
235
+ end
236
+
237
+ # Ensure type and name are not nil before creating the PackageURL instance
238
+ raise InvalidPackageURL, "missing package type" if components[:type].nil?
239
+ raise InvalidPackageURL, "missing package name" if components[:name].nil?
240
+
241
+ # Create a new PackageURL with validated components
242
+ type = components[:type] || "" # This ensures type is never nil
243
+ name = components[:name] || "" # This ensures name is never nil
244
+
245
+ new(
246
+ type: type,
247
+ name: name,
248
+ namespace: components[:namespace],
249
+ version: components[:version],
250
+ qualifiers: components[:qualifiers],
251
+ subpath: components[:subpath],
252
+ )
253
+ end
254
+
255
+ # Returns a hash containing the
256
+ # scheme, type, namespace, name, version, qualifiers, and subpath components
257
+ # of the package URL.
258
+ def to_h
259
+ {
260
+ scheme: scheme,
261
+ type: @type,
262
+ namespace: @namespace,
263
+ name: @name,
264
+ version: @version,
265
+ qualifiers: @qualifiers,
266
+ subpath: @subpath,
267
+ }
268
+ end
269
+
270
+ # Returns a string representation of the package URL.
271
+ # Package URL representations are created according to the instructions from
272
+ # https://github.com/package-url/purl-spec/blob/0b1559f76b79829e789c4f20e6d832c7314762c5/PURL-SPECIFICATION.rst#how-to-build-purl-string-from-its-components.
273
+ def to_s
274
+ # Start a purl string with the "pkg:" scheme as a lowercase ASCII string
275
+ purl = "pkg:"
276
+
277
+ # Append the type string to the purl as a lowercase ASCII string
278
+ # Append '/' to the purl
279
+
280
+ purl += @type
281
+ purl += "/"
282
+
283
+ # If the namespace is not empty:
284
+ # - Strip the namespace from leading and trailing '/'
285
+ # - Split on '/' as segments
286
+ # - Apply type-specific normalization to each segment if needed
287
+ # - UTF-8-encode each segment if needed in your programming language
288
+ # - Percent-encode each segment
289
+ # - Join the segments with '/'
290
+ # - Append this to the purl
291
+ # - Append '/' to the purl
292
+ # - Strip the name from leading and trailing '/'
293
+ # - Apply type-specific normalization to the name if needed
294
+ # - UTF-8-encode the name if needed in your programming language
295
+ # - Append the percent-encoded name to the purl
296
+ #
297
+ # If the namespace is empty:
298
+ # - Apply type-specific normalization to the name if needed
299
+ # - UTF-8-encode the name if needed in your programming language
300
+ # - Append the percent-encoded name to the purl
301
+ case @namespace
302
+ in String => namespace unless namespace.empty?
303
+ segments = []
304
+ @namespace.delete_prefix("/").delete_suffix("/").split("/").each do |segment|
305
+ next if segment.empty?
306
+
307
+ segments << URI.encode_www_form_component(segment)
308
+ end
309
+ purl += segments.join("/")
310
+
311
+ purl += "/"
312
+ purl += URI.encode_www_form_component(@name.delete_prefix("/").delete_suffix("/"))
313
+ else
314
+ purl += URI.encode_www_form_component(@name)
315
+ end
316
+
317
+ # If the version is not empty:
318
+ # - Append '@' to the purl
319
+ # - UTF-8-encode the version if needed in your programming language
320
+ # - Append the percent-encoded version to the purl
321
+ case @version
322
+ in String => version unless version.empty?
323
+ purl += "@"
324
+ purl += URI.encode_www_form_component(@version)
325
+ else
326
+ nil
327
+ end
328
+
329
+ # If the qualifiers are not empty and not composed only of key/value pairs
330
+ # where the value is empty:
331
+ # - Append '?' to the purl
332
+ # - Build a list from all key/value pair:
333
+ # - discard any pair where the value is empty.
334
+ # - UTF-8-encode each value if needed in your programming language
335
+ # - If the key is checksums and this is a list of checksums
336
+ # join this list with a ',' to create this qualifier value
337
+ # - create a string by joining the lowercased key,
338
+ # the equal '=' sign and the percent-encoded value to create a qualifier
339
+ # - sort this list of qualifier strings lexicographically
340
+ # - join this list of qualifier strings with a '&' ampersand
341
+ # - Append this string to the purl
342
+ case @qualifiers
343
+ in Hash => qualifiers unless qualifiers.empty?
344
+ list = []
345
+ qualifiers.each do |key, value|
346
+ next if value.empty?
347
+
348
+ list << case [key, value]
349
+ in "checksums", Array => checksums
350
+ "#{key.downcase}=#{checksums.join(",")}"
351
+ else
352
+ "#{key.downcase}=#{URI.encode_www_form_component(value)}"
353
+ end
354
+ end
355
+
356
+ unless list.empty?
357
+ purl += "?"
358
+ purl += list.sort.join("&")
359
+ end
360
+ else
361
+ nil
362
+ end
363
+
364
+ # If the subpath is not empty and not composed only of
365
+ # empty, '.' and '..' segments:
366
+ # - Append '#' to the purl
367
+ # - Strip the subpath from leading and trailing '/'
368
+ # - Split this on '/' as segments
369
+ # - Discard empty, '.' and '..' segments
370
+ # - Percent-encode each segment
371
+ # - UTF-8-encode each segment if needed in your programming language
372
+ # - Join the segments with '/'
373
+ # - Append this to the purl
374
+ case @subpath
375
+ in String => subpath unless subpath.empty?
376
+ segments = []
377
+ subpath.delete_prefix("/").delete_suffix("/").split("/").each do |segment|
378
+ next if segment.empty? || segment == "." || segment == ".."
379
+
380
+ # Custom encoding for URL fragment segments:
381
+ # 1. Explicitly encode % as %25 to prevent double-encoding issues
382
+ # 2. Percent-encode special characters according to URL fragment rules
383
+ # 3. This ensures proper round-trip encoding/decoding with the parse method
384
+ segments << segment.gsub(/%|[^A-Za-z0-9\-\._~]/) do |m|
385
+ m == "%" ? "%25" : format("%%%02X", m.ord)
386
+ end
387
+ end
388
+
389
+ unless segments.empty?
390
+ purl += "#"
391
+ purl += segments.join("/")
392
+ end
393
+ else
394
+ nil
395
+ end
396
+
397
+ purl
398
+ end
399
+
400
+ # Returns an array containing the
401
+ # scheme, type, namespace, name, version, qualifiers, and subpath components
402
+ # of the package URL.
403
+ def deconstruct
404
+ [scheme, @type, @namespace, @name, @version, @qualifiers, @subpath]
405
+ end
406
+
407
+ # Returns a hash containing the
408
+ # scheme, type, namespace, name, version, qualifiers, and subpath components
409
+ # of the package URL.
410
+ def deconstruct_keys(_keys)
411
+ to_h
412
+ end
413
+ end
414
+ end
@@ -61,6 +61,14 @@ module RubyLsp
61
61
  "RuboCop::Formatter::BaseFormatter", # Suppress any output by using the base formatter
62
62
  ] #: Array[String]
63
63
 
64
+ # Functionality was introduced in 1.75.0 but had issues with autocorrect
65
+ REUSE_PRISM_RESULT = begin
66
+ gem("rubocop", ">= 1.80.1")
67
+ true
68
+ rescue LoadError
69
+ false
70
+ end #: bool
71
+
64
72
  #: Array[::RuboCop::Cop::Offense]
65
73
  attr_reader :offenses
66
74
 
@@ -81,7 +89,7 @@ module RubyLsp
81
89
  @offenses = [] #: Array[::RuboCop::Cop::Offense]
82
90
  @errors = [] #: Array[String]
83
91
  @warnings = [] #: Array[String]
84
- # @prism_result = nil #: Prism::ParseLexResult?
92
+ @prism_result = nil #: Prism::ParseLexResult?
85
93
 
86
94
  args += DEFAULT_ARGS
87
95
  rubocop_options = ::RuboCop::Options.new.parse(args).first
@@ -101,11 +109,7 @@ module RubyLsp
101
109
  @warnings = []
102
110
  @offenses = []
103
111
  @options[:stdin] = contents
104
-
105
- # Setting the Prism result before running the RuboCop runner makes it reuse the existing AST and avoids
106
- # double-parsing. Unfortunately, this leads to a bunch of cops failing to execute properly under LSP mode.
107
- # Uncomment this once reusing the Prism result is more stable
108
- # @prism_result = prism_result
112
+ @prism_result = prism_result if REUSE_PRISM_RESULT
109
113
 
110
114
  super([path])
111
115
 
@@ -20,17 +20,7 @@ module RubyLsp
20
20
  # @override
21
21
  #: -> Array[Interface::WorkspaceSymbol]
22
22
  def perform
23
- @index.fuzzy_search(@query).filter_map do |entry|
24
- uri = entry.uri
25
- file_path = uri.full_path
26
-
27
- # We only show symbols declared in the workspace
28
- in_dependencies = file_path && !not_in_dependencies?(file_path)
29
- next if in_dependencies
30
-
31
- # We should never show private symbols when searching the entire workspace
32
- next if entry.private?
33
-
23
+ fuzzy_search.filter_map do |entry|
34
24
  kind = kind_for_entry(entry)
35
25
  loc = entry.location
36
26
 
@@ -44,7 +34,7 @@ module RubyLsp
44
34
  container_name: container.join("::"),
45
35
  kind: kind,
46
36
  location: Interface::Location.new(
47
- uri: uri.to_s,
37
+ uri: entry.uri.to_s,
48
38
  range: Interface::Range.new(
49
39
  start: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column),
50
40
  end: Interface::Position.new(line: loc.end_line - 1, character: loc.end_column),
@@ -53,6 +43,24 @@ module RubyLsp
53
43
  )
54
44
  end
55
45
  end
46
+
47
+ private
48
+
49
+ #: -> Array[RubyIndexer::Entry]
50
+ def fuzzy_search
51
+ @index.fuzzy_search(@query) do |entry|
52
+ file_path = entry.uri.full_path
53
+
54
+ # We only show symbols declared in the workspace
55
+ in_dependencies = file_path && !not_in_dependencies?(file_path)
56
+ next if in_dependencies
57
+
58
+ # We should never show private symbols when searching the entire workspace
59
+ next if entry.private?
60
+
61
+ true
62
+ end
63
+ end
56
64
  end
57
65
  end
58
66
  end
@@ -303,7 +303,7 @@ module RubyLsp
303
303
  @current_request_id,
304
304
  Interface::RelativePattern.new(
305
305
  base_uri: @global_state.workspace_uri.to_s,
306
- pattern: "{.rubocop.yml,.rubocop}",
306
+ pattern: "{.rubocop.yml,.rubocop,.rubocop_todo.yml}",
307
307
  ),
308
308
  registration_id: "rubocop-watcher",
309
309
  ))
@@ -1045,7 +1045,7 @@ module RubyLsp
1045
1045
 
1046
1046
  file_name = File.basename(file_path)
1047
1047
 
1048
- if file_name == ".rubocop.yml" || file_name == ".rubocop"
1048
+ if file_name == ".rubocop.yml" || file_name == ".rubocop" || file_name == ".rubocop_todo.yml"
1049
1049
  handle_rubocop_config_change(uri)
1050
1050
  end
1051
1051
  end
@@ -234,13 +234,14 @@ module RubyLsp
234
234
  # If no error occurred, then clear previous errors
235
235
  @error_path.delete if @error_path.exist?
236
236
  $stderr.puts("Ruby LSP> Composed bundle installation complete")
237
- rescue Errno::EPIPE
238
- # If the $stderr pipe was closed by the client, for example when closing the editor during running bundle
239
- # install, we don't want to write the error to a file or else we will report to telemetry on the next launch and
240
- # it does not represent an actual error.
237
+ rescue Errno::EPIPE, Bundler::HTTPError
238
+ # There are cases where we expect certain errors to happen occasionally, and we don't want to write them to
239
+ # a file, which would report to telemetry on the next launch.
241
240
  #
242
- # This situation may happen because while running bundle install, the server is not yet ready to receive
243
- # shutdown requests and we may continue doing work until the process is killed.
241
+ # - The $stderr pipe might be closed by the client, for example when closing the editor during running bundle
242
+ # install. This situation may happen because, while running bundle install, the server is not yet ready to
243
+ # receive shutdown requests and we may continue doing work until the process is killed.
244
+ # - Bundler might also encounter a network error.
244
245
  @error_path.delete if @error_path.exist?
245
246
  rescue => e
246
247
  # Write the error object to a file so that we can read it from the parent process
@@ -269,12 +270,21 @@ module RubyLsp
269
270
  #: (Hash[String, String] env, ?force_install: bool) -> Hash[String, String]
270
271
  def run_bundle_install_directly(env, force_install: false)
271
272
  RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable)
273
+
274
+ # The should_bundle_update? check needs to run on the original Bundler environment, but everything else (like
275
+ # updating or running install) requires the modified environment. Here we compute the check ahead of time and
276
+ # merge the environment to ensure correct results.
277
+ #
278
+ # The symptoms of having these operations in the wrong order is seeing unwanted modifications in the application's
279
+ # main lockfile because we accidentally run update on the main bundle instead of the composed one.
280
+ needs_update = should_bundle_update?
281
+ ENV.merge!(env)
282
+
272
283
  return update(env) if @needs_update_path.exist?
273
284
 
274
285
  # The ENV can only be merged after checking if an update is required because we depend on the original value of
275
286
  # ENV["BUNDLE_GEMFILE"], which gets overridden after the merge
276
- FileUtils.touch(@needs_update_path) if should_bundle_update?
277
- ENV.merge!(env)
287
+ FileUtils.touch(@needs_update_path) if needs_update
278
288
 
279
289
  $stderr.puts("Ruby LSP> Checking if the composed bundle is satisfied...")
280
290
  missing_gems = bundle_check
@@ -35,10 +35,10 @@ module RubyLsp
35
35
  @io = begin
36
36
  # The environment variable is only used for tests. The extension always writes to the temporary file
37
37
  if port
38
- TCPSocket.new("localhost", port)
38
+ socket(port)
39
39
  elsif File.exist?(port_db_path)
40
40
  db = JSON.load_file(port_db_path)
41
- TCPSocket.new("localhost", db[Dir.pwd])
41
+ socket(db[Dir.pwd])
42
42
  else
43
43
  # For tests that don't spawn the TCP server
44
44
  require "stringio"
@@ -209,6 +209,14 @@ module RubyLsp
209
209
 
210
210
  private
211
211
 
212
+ #: (String) -> TCPSocket
213
+ def socket(port)
214
+ socket = TCPSocket.new("localhost", port)
215
+ socket.binmode
216
+ socket.sync = true
217
+ socket
218
+ end
219
+
212
220
  #: (String?, **untyped) -> void
213
221
  def send_message(method_name, **params)
214
222
  json_message = { method: method_name, params: params }.to_json
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-lsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.1
4
+ version: 0.26.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
@@ -163,6 +163,7 @@ files:
163
163
  - lib/ruby_lsp/requests/support/annotation.rb
164
164
  - lib/ruby_lsp/requests/support/common.rb
165
165
  - lib/ruby_lsp/requests/support/formatter.rb
166
+ - lib/ruby_lsp/requests/support/package_url.rb
166
167
  - lib/ruby_lsp/requests/support/rubocop_diagnostic.rb
167
168
  - lib/ruby_lsp/requests/support/rubocop_formatter.rb
168
169
  - lib/ruby_lsp/requests/support/rubocop_runner.rb
@@ -216,7 +217,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
217
  - !ruby/object:Gem::Version
217
218
  version: '0'
218
219
  requirements: []
219
- rubygems_version: 3.6.9
220
+ rubygems_version: 3.7.2
220
221
  specification_version: 4
221
222
  summary: An opinionated language server for Ruby
222
223
  test_files: []