Repository: Shopify/ruby-lsp (Ruby)

Issue:
Make add-on loading configurable
### I have checked that this feature is not already implemented

- [x] This feature does not exist

### Use case

The Ruby LSP currently [unconditionally tries to load add-ons](https://github.com/Shopify/ruby-lsp/blob/914a1095cbf734b0ddca17dedcb357819bcd2fc1/lib/ruby_lsp/addon.rb#L60C17-L61C1) from any `ruby_lsp/**/addon.rb` file in any installed gem. If you have a gem installed that has such a file, but you don't want the LSP to load its addon, there doesn't seem to be a way around this.

Currently this is just causing noisy logs for me -- I have the tapioca gem installed, and every time the Ruby LSP starts, it prints a stack trace indicating that [this version constraint](https://github.com/Shopify/tapioca/blob/main/lib/ruby_lsp/tapioca/addon.rb#L4) isn't satisfied -- but in principle I'd like to be able to configure which addons the LSP loads or doesn't load.

### Description

Allow the user to configure specific addons that the Ruby LSP should load or not load, instead of unconditionally trying to load an addon for every installed gem that has a `ruby_lsp/**/addon.rb` file.

### Implementation

To solve my immediate problem, I'd be fine with a configuration option that lets me specify a deny-list of addons that the LSP shouldn't try to load, but I can imagine a use case for only loading installed addons that occur in an explicitly-specified list.


Discussion:
Thanks for the report. I think there's an underlying root cause here that we're missing.

If the Ruby LSP is being initialized correctly, it should compose the bundle under the `.ruby-lsp` directory and invoke `Bundler.setup`, which will limit `Gem.find_files` to the scope of the bundle. Even if the project has no Gemfile, this should still be working and we should not be finding globally installed gem results.

What editor are you using? Did you configure the LSP command yourself? Are you overriding `BUNDLE_GEMFILE` anywhere or do you have Ruby LSP related settings?
I think the feature is useful even if the scope of the gems search is the local repository. For example, in out organization there are over 250 developers, we mantain 2 custom addons, but some users don't want to use all of them. 

Currently the aproach is for the addon implementer to provide a setting for enable/disable the addon based on the `rubyLsp.addonSettings` ([example](https://github.com/domingo2000/ruby-lsp-shoulda-context?tab=readme-ov-file#usage)). But it should be nice if this was a standard of the core LSP to enable different developers on the same project to enable or disable addons based on custom config. Just like vscode "recommended addons" that can be enabled/disabled per user.
@domingo2000 yeah, I'm not opposed to allowing add-ons being disabled. We do want to allow remembering the status of the add-on and then configuring that.

However, what @rolandcrosby-columntax is experiencing is a bug that's not related to the ability to disable/enable add-ons. That simply shouldn't happen.
Not sure if it's exactly the same as the original problem but a problem we're seeing is `include_project_addons` is searching deep in `vendor` and similar directories and is causing multiple different versions of gems to be loaded. It actually causes RuboCop part of Ruby LSP to crash if it loads both pre-1.70 and post-1.70 versions.

A way to either disable `include_project_addons` or I guess ideally configure a list of ignored directories would be ideal (maybe even using `rubyLsp.indexing.excludedPatterns` though that is for indexing).
@Bo98 that's gotta be it! We shouldn't search in `vendor` manually using `Dir.glob` for project add-ons. Add-ons defined in dependencies are discovered via `Gem.find_files`, which automatically handles the version constraints of the current bundle.

The changes in https://github.com/Shopify/ruby-lsp/pull/3620 should fix this.



Patch:
diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb
index 9ba707c9d0..08e1c5ab8d 100644
--- a/lib/ruby_lsp/addon.rb
+++ b/lib/ruby_lsp/addon.rb
@@ -56,7 +56,13 @@ def load_addons(global_state, outgoing_queue, include_project_addons: true)
         addon_files = Gem.find_files("ruby_lsp/**/addon.rb")
 
         if include_project_addons
-          addon_files.concat(Dir.glob(File.join(global_state.workspace_path, "**", "ruby_lsp/**/addon.rb")))
+          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) }
+          addon_files.concat(project_addons)
         end
 
         errors = addon_files.filter_map do |addon_path|


Test patch:
diff --git a/test/addon_test.rb b/test/addon_test.rb
index 1be42e0c5a..e2efabc0a3 100644
--- a/test/addon_test.rb
+++ b/test/addon_test.rb
@@ -190,5 +190,40 @@ def version
         assert_predicate(addon, :hello)
       end
     end
+
+    def test_loading_project_addons_ignores_bundle_path
+      Dir.mktmpdir do |dir|
+        addon_dir = File.join(dir, "vendor", "bundle", "ruby_lsp", "test_addon")
+        FileUtils.mkdir_p(addon_dir)
+        File.write(File.join(addon_dir, "addon.rb"), <<~RUBY)
+          class ProjectAddon < RubyLsp::Addon
+            attr_reader :hello
+
+            def activate(global_state, outgoing_queue)
+              @hello = true
+            end
+
+            def name
+              "Project Addon"
+            end
+
+            def version
+              "0.1.0"
+            end
+          end
+        RUBY
+
+        @global_state.apply_options({
+          workspaceFolders: [{ uri: URI::Generic.from_path(path: dir).to_s }],
+        })
+
+        Bundler.stubs(:bundle_path).returns(Pathname.new(File.join(dir, "vendor", "bundle")))
+        Addon.load_addons(@global_state, @outgoing_queue)
+
+        assert_raises(Addon::AddonNotFoundError) do
+          Addon.get("Project Addon", "0.1.0")
+        end
+      end
+    end
   end
 end