Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions lib/ruby_lsp/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
96 changes: 95 additions & 1 deletion test/addon_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading