diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index ed97c4ac1..44f6f8496 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -32,6 +32,8 @@ class Addon AddonNotFoundError = Class.new(StandardError) + class IncompatibleApiError < StandardError; end + class << self extend T::Sig @@ -80,13 +82,49 @@ def load_addons(global_state, outgoing_queue) errors end - sig { params(addon_name: String).returns(Addon) } - def get(addon_name) + # Get a reference to another add-on object by name and version. If an add-on exports an API that can be used by + # other add-ons, this is the way to get access to that API. + # + # Important: if the add-on is not found, AddonNotFoundError will be raised. If the add-on is found, but its + # current version does not satisfy the given version constraint, then IncompatibleApiError will be raised. It is + # 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) addon = addons.find { |addon| addon.name == addon_name } raise AddonNotFoundError, "Could not find add-on '#{addon_name}'" unless addon + version_object = Gem::Version.new(addon.version) + + unless version_constraints.all? { |constraint| Gem::Requirement.new(constraint).satisfied_by?(version_object) } + raise IncompatibleApiError, + "Constraints #{version_constraints.inspect} is incompatible with #{addon_name} version #{addon.version}" + end + addon end + + # Depend on a specific version of the Ruby LSP. This method should only be used if the add-on is distributed in a + # gem that does not have a runtime dependency on the ruby-lsp gem. This method should be invoked at the top of the + # `addon.rb` file before defining any classes or requiring any files. For example: + # + # ```ruby + # RubyLsp::Addon.depend_on_ruby_lsp!(">= 0.18.0") + # + # module MyGem + # class MyAddon < RubyLsp::Addon + # # ... + # end + # end + # ``` + sig { params(version_constraints: String).void } + def depend_on_ruby_lsp!(*version_constraints) + version_object = Gem::Version.new(RubyLsp::VERSION) + + unless version_constraints.all? { |constraint| Gem::Requirement.new(constraint).satisfied_by?(version_object) } + raise IncompatibleApiError, + "Add-on is not compatible with this version of the Ruby LSP. Skipping its activation" + end + end end sig { void } @@ -132,6 +170,11 @@ def deactivate; end sig { abstract.returns(String) } def name; end + # Add-ons should override the `version` method to return a semantic version string representing the add-on's + # version. This is used for compatibility checks + sig { abstract.returns(String) } + def version; end + # Creates a new CodeLens listener. This method is invoked on every CodeLens request sig do overridable.params( diff --git a/test/addon_test.rb b/test/addon_test.rb index f3040c81d..ddd75236f 100644 --- a/test/addon_test.rb +++ b/test/addon_test.rb @@ -22,6 +22,10 @@ def activate(global_state, outgoing_queue) def name "My Add-on" end + + def version + "0.1.0" + end end @global_state = GlobalState.new @@ -68,6 +72,10 @@ def activate(global_state, outgoing_queue) def name "My Add-on" end + + def version + "0.1.0" + end end queue = Thread::Queue.new @@ -102,13 +110,19 @@ def workspace_did_change_watched_files(changes); end end def test_get_an_addon_by_name - addon = Addon.get("My Add-on") + 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 assert_raises(Addon::AddonNotFoundError) do - Addon.get("Invalid Addon") + Addon.get("Invalid Addon", "0.1.0") + end + end + + def test_raises_if_an_addon_version_does_not_match + assert_raises(Addon::IncompatibleApiError) do + Addon.get("My Add-on", "> 15.0.0") end end @@ -125,11 +139,19 @@ def test_addons_receive_settings outgoing_queue = Thread::Queue.new Addon.load_addons(global_state, outgoing_queue) - addon = Addon.get("My Add-on") + 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 + 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 end end diff --git a/test/requests/code_lens_expectations_test.rb b/test/requests/code_lens_expectations_test.rb index ad4033ece..b5c597d84 100644 --- a/test/requests/code_lens_expectations_test.rb +++ b/test/requests/code_lens_expectations_test.rb @@ -244,6 +244,10 @@ def activate(global_state, outgoing_queue) def deactivate; end def name; end + + def version + "0.1.0" + end end end diff --git a/test/requests/completion_test.rb b/test/requests/completion_test.rb index 813d95d6f..f1087c596 100644 --- a/test/requests/completion_test.rb +++ b/test/requests/completion_test.rb @@ -1520,6 +1520,10 @@ def deactivate; end def name "Foo" end + + def version + "0.1.0" + end end end end diff --git a/test/requests/definition_expectations_test.rb b/test/requests/definition_expectations_test.rb index 4ceea2622..b4ba30f97 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -1027,6 +1027,10 @@ def activate(global_state, outgoing_queue); end def deactivate; end def name; end + + def version + "0.1.0" + end end end end diff --git a/test/requests/document_symbol_expectations_test.rb b/test/requests/document_symbol_expectations_test.rb index b1b714cee..14abd0955 100644 --- a/test/requests/document_symbol_expectations_test.rb +++ b/test/requests/document_symbol_expectations_test.rb @@ -78,6 +78,10 @@ def name def deactivate; end + def version + "0.1.0" + end + def create_document_symbol_listener(response_builder, dispatcher) klass = Class.new do include RubyLsp::Requests::Support::Common diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index 846f3b0e9..a5f10b9df 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -765,6 +765,10 @@ def name def deactivate; end + def version + "0.1.0" + end + def create_hover_listener(response_builder, nesting, dispatcher) klass = Class.new do def initialize(response_builder, dispatcher) diff --git a/test/requests/semantic_highlighting_expectations_test.rb b/test/requests/semantic_highlighting_expectations_test.rb index ea575c021..52808842e 100644 --- a/test/requests/semantic_highlighting_expectations_test.rb +++ b/test/requests/semantic_highlighting_expectations_test.rb @@ -108,6 +108,10 @@ def activate(global_state, outgoing_queue); end def deactivate; end def name; end + + def version + "0.1.0" + end end end diff --git a/test/server_test.rb b/test/server_test.rb index 99e6e0c0d..5779a312d 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -703,6 +703,10 @@ def name end def deactivate; end + + def version + "0.1.0" + end end Class.new(RubyLsp::Addon) do @@ -716,6 +720,10 @@ def name end def deactivate; end + + def version + "0.1.0" + end end end