diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index 0c1afe996..c346d6ea6 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -57,11 +57,26 @@ def load_addons(global_state, outgoing_queue, include_project_addons: true) if include_project_addons project_addons = Dir.glob("#{global_state.workspace_path}/**/ruby_lsp/**/addon.rb") - - # Ignore add-ons from dependencies if the bundle is stored inside the project. We already found those with - # `Gem.find_files` bundle_path = Bundler.bundle_path.to_s - project_addons.reject! { |path| path.start_with?(bundle_path) } + gems_dir = Bundler.bundle_path.join("gems") + + # Create an array of rejection glob patterns to ignore add-ons already discovered through Gem.find_files if + # they are also copied inside the workspace for whatever reason. We received reports of projects having gems + # installed in vendor/bundle despite BUNDLE_PATH pointing elsewhere. Without this mechanism, we will + # double-require the same add-on, potentially for different versions of the same gem, which leads to incorrect + # behavior + reject_glob_patterns = addon_files.map do |path| + relative_gem_path = Pathname.new(path).relative_path_from(gems_dir) + first_part, *parts = relative_gem_path.to_s.split(File::SEPARATOR) + first_part&.gsub!(/-([0-9.]+)$/, "*") + "**/#{first_part}/#{parts.join("/")}" + end + + project_addons.reject! do |path| + path.start_with?(bundle_path) || + reject_glob_patterns.any? { |pattern| File.fnmatch?(pattern, path, File::Constants::FNM_PATHNAME) } + end + addon_files.concat(project_addons) end diff --git a/test/addon_test.rb b/test/addon_test.rb index e2efabc0a..ffadeed2b 100644 --- a/test/addon_test.rb +++ b/test/addon_test.rb @@ -225,5 +225,39 @@ def version end end end + + def test_loading_project_addons_ignores_vendor_bundle + # Some users have gems installed under `vendor/bundle` despite not having their BUNDLE_PATH configured to be so. + # That leads to loading the same add-on multiple times if they have the same gem installed both in their + # BUNDLE_PATH and in `vendor/bundle` + Dir.mktmpdir do |dir| + addon_dir = File.join(dir, "vendor", "bundle", "rubocop-1.73.0", "lib", "ruby_lsp", "rubocop") + FileUtils.mkdir_p(addon_dir) + File.write(File.join(addon_dir, "addon.rb"), <<~RUBY) + class OldRuboCopAddon < RubyLsp::Addon + def activate(global_state, outgoing_queue) + end + + def name + "Old RuboCop Addon" + end + + def version + "0.1.0" + end + end + RUBY + + @global_state.apply_options({ + workspaceFolders: [{ uri: URI::Generic.from_path(path: dir).to_s }], + }) + + Addon.load_addons(@global_state, @outgoing_queue) + + assert_raises(Addon::AddonNotFoundError) do + Addon.get("Project Addon", "0.1.0") + end + end + end end end