Skip to content

Commit

Permalink
Allow declaration of project addons (#2601)
Browse files Browse the repository at this point in the history
* Allow declaration of project addons

* Test
  • Loading branch information
vinistock authored Oct 2, 2024
1 parent 6ab1972 commit af0b46d
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 30 deletions.
4 changes: 4 additions & 0 deletions jekyll/add-ons.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/ruby_indexer/lib/ruby_indexer/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ def initial_excluded_gems

excluded.uniq!
excluded.map(&:name)
rescue Bundler::GemfileNotFound
[]
end
end
end
29 changes: 24 additions & 5 deletions lib/ruby_lsp/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_lsp/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 54 additions & 21 deletions test/addon_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -98,60 +98,93 @@ 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 },
},
},
})

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

0 comments on commit af0b46d

Please sign in to comment.