diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index c346d6ea6..230b87a65 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -56,26 +56,9 @@ def load_addons(global_state, outgoing_queue, include_project_addons: true) addon_files = Gem.find_files("ruby_lsp/**/addon.rb") if include_project_addons - project_addons = Dir.glob("#{global_state.workspace_path}/**/ruby_lsp/**/addon.rb") bundle_path = Bundler.bundle_path.to_s - 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 + project_addons = Dir.glob("#{global_state.workspace_path}/**/ruby_lsp/**/addon.rb") + project_addons.reject! { |path| path.start_with?(bundle_path) || gem_installation_path?(path) } addon_files.concat(project_addons) end @@ -162,6 +145,23 @@ def depend_on_ruby_lsp!(*version_constraints) "Add-on is not compatible with this version of the Ruby LSP. Skipping its activation" end end + + private + + # Checks if a path appears to be inside a versioned gem installation directory (e.g., `rubocop-1.73.0/lib/...`) by + # looking for a directory segment matching `name-version` before the `lib` component + # + #: (String path) -> bool + def gem_installation_path?(path) + parts = path.split(%r{[/\\]}) + lib_index = parts.rindex("lib") + return false unless lib_index + + prefix = parts[0...lib_index] #: Array[String]? + return false unless prefix + + prefix.any? { |part| part.match?(/-\d+(\.\d+)+$/) } + end end #: -> void diff --git a/test/addon_test.rb b/test/addon_test.rb index ffadeed2b..75ec3687e 100644 --- a/test/addon_test.rb +++ b/test/addon_test.rb @@ -255,9 +255,103 @@ def version Addon.load_addons(@global_state, @outgoing_queue) assert_raises(Addon::AddonNotFoundError) do - Addon.get("Project Addon", "0.1.0") + Addon.get("Old RuboCop Addon", "0.1.0") end end end + + def test_loading_project_addons_ignores_old_gem_version_even_when_gem_is_not_in_bundle + # If there's an old installation of an add-on gem that has since been removed from the Gemfile, but not cleaned + # up, we must not require it + + Dir.mktmpdir do |dir| + addon_dir = File.join(dir, "ruby-lsp-rails-0.3.6", "lib", "ruby_lsp", "rails") + FileUtils.mkdir_p(addon_dir) + + File.write(File.join(addon_dir, "addon.rb"), <<~RUBY) + class OldRailsAddon < RubyLsp::Addon + def activate(global_state, outgoing_queue) + end + + def name + "Old Rails Addon" + end + + def version + "0.3.6" + 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("Old Rails Addon", "0.3.6") + end + end + end + + def test_loading_project_addons_ignores_double_addon_installation + # If there are two installations of the same gem inside of the project workspace, we should not load the incorrect + # one. We must always load the one coming from the bundle and ignore any others + + 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) + $loaded_expected = true + + class RuboCopAddon < RubyLsp::Addon + def activate(global_state, outgoing_queue) + end + + def name + "RuboCop Addon" + end + + def version + "0.1.0" + end + end + RUBY + + addon_dir = File.join(dir, "custom_gems", "rubocop-1.68.0", "lib", "ruby_lsp", "rubocop") + FileUtils.mkdir_p(addon_dir) + + File.write(File.join(addon_dir, "addon.rb"), <<~RUBY) + $loaded_incorrect = true + + class RuboCopAddon < RubyLsp::Addon + def activate(global_state, outgoing_queue) + end + + def name + "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 }], + }) + + expected_addon_path = File.join(dir, "vendor", "bundle", "rubocop-1.73.0", "lib", "ruby_lsp", "rubocop", "addon.rb") + Gem.stubs(:find_files).with("ruby_lsp/**/addon.rb").returns([expected_addon_path]) + Bundler.stubs(:bundle_path).returns(Pathname.new(File.join(dir, "vendor", "bundle"))) + Addon.load_addons(@global_state, @outgoing_queue) + + refute($loaded_incorrect) # rubocop:disable Style/GlobalVars + assert($loaded_expected) # rubocop:disable Style/GlobalVars + end + end end end