From af0b46d32792fda79290feac9e4bf26360780be8 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 2 Oct 2024 16:21:23 -0400 Subject: [PATCH] Allow declaration of project addons (#2601) * Allow declaration of project addons * Test --- jekyll/add-ons.markdown | 4 + .../lib/ruby_indexer/configuration.rb | 2 + lib/ruby_lsp/addon.rb | 29 +++++-- lib/ruby_lsp/server.rb | 6 +- lib/ruby_lsp/test_helper.rb | 2 +- test/addon_test.rb | 75 +++++++++++++------ 6 files changed, 88 insertions(+), 30 deletions(-) diff --git a/jekyll/add-ons.markdown b/jekyll/add-ons.markdown index 2d86a216b..0506b5961 100644 --- a/jekyll/add-ons.markdown +++ b/jekyll/add-ons.markdown @@ -61,6 +61,10 @@ The Ruby LSP discovers add-ons based on the existence of an `addon.rb` file plac example, `my_gem/lib/ruby_lsp/my_gem/addon.rb`. This file must declare the add-on class, which can be used to perform any necessary activation when the server starts. +{: .note } +Projects can also define their own private add-ons for functionality that only applies to a particular application. As +long as a file matching `ruby_lsp/**/addon.rb` exists inside of the workspace (not necessarily at the root), it will be +loaded by the Ruby LSP. ```ruby # frozen_string_literal: true diff --git a/lib/ruby_indexer/lib/ruby_indexer/configuration.rb b/lib/ruby_indexer/lib/ruby_indexer/configuration.rb index eee3fd2e8..e3c4c9b77 100644 --- a/lib/ruby_indexer/lib/ruby_indexer/configuration.rb +++ b/lib/ruby_indexer/lib/ruby_indexer/configuration.rb @@ -239,6 +239,8 @@ def initial_excluded_gems excluded.uniq! excluded.map(&:name) + rescue Bundler::GemfileNotFound + [] end end end diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index 44f6f8496..d9ec2a84b 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -55,13 +55,28 @@ def inherited(child_class) # Discovers and loads all add-ons. Returns a list of errors when trying to require add-ons sig do - params(global_state: GlobalState, outgoing_queue: Thread::Queue).returns(T::Array[StandardError]) + params( + global_state: GlobalState, + outgoing_queue: Thread::Queue, + include_project_addons: T::Boolean, + ).returns(T::Array[StandardError]) end - def load_addons(global_state, outgoing_queue) + def load_addons(global_state, outgoing_queue, include_project_addons: true) # Require all add-ons entry points, which should be placed under - # `some_gem/lib/ruby_lsp/your_gem_name/addon.rb` - errors = Gem.find_files("ruby_lsp/**/addon.rb").filter_map do |addon| - require File.expand_path(addon) + # `some_gem/lib/ruby_lsp/your_gem_name/addon.rb` or in the workspace under + # `your_project/ruby_lsp/project_name/addon.rb` + 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"))) + end + + errors = addon_files.filter_map do |addon_path| + # Avoid requiring this file twice. This may happen if you're working on the Ruby LSP itself and at the same + # time have `ruby-lsp` installed as a vendored gem + next if File.basename(File.dirname(addon_path)) == "ruby_lsp" + + require File.expand_path(addon_path) nil rescue => e e @@ -90,6 +105,10 @@ def load_addons(global_state, outgoing_queue) # the responsibility of the add-ons using this API to handle these errors appropriately. sig { params(addon_name: String, version_constraints: String).returns(Addon) } def get(addon_name, *version_constraints) + if version_constraints.empty? + raise IncompatibleApiError, "Must specify version constraints when accessing other add-ons" + end + addon = addons.find { |addon| addon.name == addon_name } raise AddonNotFoundError, "Could not find add-on '#{addon_name}'" unless addon diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index bf3aedaea..2f08a7693 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -123,9 +123,9 @@ def process_message(message) send_log_message("Error processing #{message[:method]}: #{e.full_message}", type: Constant::MessageType::ERROR) end - sig { void } - def load_addons - errors = Addon.load_addons(@global_state, @outgoing_queue) + sig { params(include_project_addons: T::Boolean).void } + def load_addons(include_project_addons: true) + errors = Addon.load_addons(@global_state, @outgoing_queue, include_project_addons: include_project_addons) if errors.any? send_log_message( diff --git a/lib/ruby_lsp/test_helper.rb b/lib/ruby_lsp/test_helper.rb index d01d2dc3b..fb21805ac 100644 --- a/lib/ruby_lsp/test_helper.rb +++ b/lib/ruby_lsp/test_helper.rb @@ -42,7 +42,7 @@ def with_server(source = nil, uri = Kernel.URI("file:///fake.rb"), stub_no_typec RubyIndexer::IndexablePath.new(nil, T.must(uri.to_standardized_path)), source, ) - server.load_addons if load_addons + server.load_addons(include_project_addons: false) if load_addons block.call(server, uri) ensure if load_addons diff --git a/test/addon_test.rb b/test/addon_test.rb index ddd75236f..1b996f160 100644 --- a/test/addon_test.rb +++ b/test/addon_test.rb @@ -28,18 +28,18 @@ def version end end @global_state = GlobalState.new - @outgoing_queue = Thread::Queue.new - Addon.load_addons(@global_state, @outgoing_queue) end def teardown + RubyLsp::Addon.file_watcher_addons.clear RubyLsp::Addon.addon_classes.clear RubyLsp::Addon.addons.clear @outgoing_queue.close end def test_registering_an_addon_invokes_activate_on_initialized + Addon.load_addons(@global_state, @outgoing_queue) server = RubyLsp::Server.new capture_subprocess_io do @@ -53,10 +53,12 @@ def test_registering_an_addon_invokes_activate_on_initialized end def test_addons_are_automatically_tracked + Addon.load_addons(@global_state, @outgoing_queue) assert_equal(123, T.unsafe(Addon.addons.first).field) end def test_loading_addons_initializes_them + Addon.load_addons(@global_state, @outgoing_queue) assert( Addon.addons.any? { |addon| addon.is_a?(@addon) }, "Expected add-on to be automatically tracked", @@ -78,10 +80,8 @@ def version end end - queue = Thread::Queue.new - Addon.load_addons(GlobalState.new, queue) + Addon.load_addons(@global_state, @outgoing_queue) error_addon = T.must(Addon.addons.find(&:error?)) - queue.close assert_predicate(error_addon, :error?) assert_equal(<<~MESSAGE, error_addon.formatted_errors) @@ -98,37 +98,40 @@ def deactivate; end def workspace_did_change_watched_files(changes); end end - begin - queue = Thread::Queue.new - Addon.load_addons(GlobalState.new, queue) - assert_equal(1, Addon.file_watcher_addons.length) - assert_instance_of(klass, Addon.file_watcher_addons.first) - ensure - T.must(queue).close - Addon.file_watcher_addons.clear - end + Addon.load_addons(@global_state, @outgoing_queue) + assert_equal(1, Addon.file_watcher_addons.length) + assert_instance_of(klass, Addon.file_watcher_addons.first) end def test_get_an_addon_by_name + Addon.load_addons(@global_state, @outgoing_queue) addon = Addon.get("My Add-on", "0.1.0") assert_equal("My Add-on", addon.name) end def test_raises_if_an_addon_cannot_be_found + Addon.load_addons(@global_state, @outgoing_queue) assert_raises(Addon::AddonNotFoundError) do Addon.get("Invalid Addon", "0.1.0") end end def test_raises_if_an_addon_version_does_not_match + Addon.load_addons(@global_state, @outgoing_queue) assert_raises(Addon::IncompatibleApiError) do Addon.get("My Add-on", "> 15.0.0") end end + def test_raises_if_no_version_constraints_are_passed + Addon.load_addons(@global_state, @outgoing_queue) + assert_raises(Addon::IncompatibleApiError) do + Addon.get("My Add-on") + end + end + def test_addons_receive_settings - global_state = GlobalState.new - global_state.apply_options({ + @global_state.apply_options({ initializationOptions: { addonSettings: { "My Add-on" => { something: false }, @@ -136,22 +139,52 @@ def test_addons_receive_settings }, }) - outgoing_queue = Thread::Queue.new - Addon.load_addons(global_state, outgoing_queue) + Addon.load_addons(@global_state, @outgoing_queue) addon = Addon.get("My Add-on", "0.1.0") - assert_equal({ something: false }, T.unsafe(addon).settings) - ensure - T.must(outgoing_queue).close end def test_depend_on_constraints + Addon.load_addons(@global_state, @outgoing_queue) assert_raises(Addon::IncompatibleApiError) do Addon.depend_on_ruby_lsp!(">= 10.0.0") end Addon.depend_on_ruby_lsp!(">= 0.18.0", "< 0.30.0") end + + def test_project_specific_addons + Dir.mktmpdir do |dir| + addon_dir = File.join(dir, "lib", "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 }], + }) + Addon.load_addons(@global_state, @outgoing_queue) + + addon = Addon.get("Project Addon", "0.1.0") + assert_equal("Project Addon", addon.name) + assert_predicate(T.unsafe(addon), :hello) + end + end end end