Skip to content

Commit

Permalink
Add Definition support for routes
Browse files Browse the repository at this point in the history
  • Loading branch information
andyw8 committed Apr 16, 2024
1 parent 64a56af commit 23620ab
Show file tree
Hide file tree
Showing 10 changed files with 161 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Ruby LSP Rails is a [Ruby LSP](https://github.com/Shopify/ruby-lsp) addon for ex
* Run or debug a test by clicking on the code lens which appears above the test class, or an individual test.
* Navigate to associations, validations, callbacks and test cases using your editor's "Go to Symbol" feature, or outline view.
* Jump to the definition of callbacks using your editor's "Go to Definition" feature.
* Jump to the declaration of a route.

## Installation

Expand Down
3 changes: 2 additions & 1 deletion lib/ruby_lsp/ruby_lsp_rails/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def activate(global_state, message_queue)
$stderr.puts("Activating Ruby LSP Rails addon v#{VERSION}")
# Start booting the real client in a background thread. Until this completes, the client will be a NullClient
Thread.new { @client = RunnerClient.create_client }
@client = RunnerClient.create_client
end

sig { override.void }
Expand Down Expand Up @@ -83,7 +84,7 @@ def create_document_symbol_listener(response_builder, dispatcher)
end
def create_definition_listener(response_builder, uri, nesting, dispatcher)
index = T.must(@global_state).index
Definition.new(response_builder, nesting, index, dispatcher)
Definition.new(@client, response_builder, nesting, index, dispatcher)
end

sig { params(changes: T::Array[{ uri: String, type: Integer }]).void }
Expand Down
44 changes: 41 additions & 3 deletions lib/ruby_lsp/ruby_lsp_rails/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,38 @@ module Rails
# request](https://microsoft.github.io/language-server-protocol/specification#textDocument_definition) jumps to the
# definition of the symbol under the cursor.
#
# It is available only Rails 7.1 or newer.
#
# Currently supported targets:
# - Callbacks
# - Named routes (e.g. `users_path`)
#
# # Example
#
# ```ruby
# before_action :foo # <- Go to definition on this symbol will jump to the method if it is defined in the same class
# ```
#
# Notes for named routes:
# - Route may be defined across multiple files, e.g. using `draw`, rather than in `routes.rb`.
# - Routes won't be found if not defined for the Rails development environment.
# - If using `constraints`, the route can only be found if the constraints are met.
# - Changes to routes won't be picked up until the server is restarted.
class Definition
extend T::Sig
include Requests::Support::Common

sig do
params(
client: RunnerClient,
response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::Location],
nesting: T::Array[String],
index: RubyIndexer::Index,
dispatcher: Prism::Dispatcher,
).void
end
def initialize(response_builder, nesting, index, dispatcher)
def initialize(client, response_builder, nesting, index, dispatcher)
@client = client
@response_builder = response_builder
@nesting = nesting
@index = index
Expand All @@ -43,8 +54,19 @@ def on_call_node_enter(node)

message = node.message

return unless message && Support::Callbacks::ALL.include?(message)
return unless message

if Support::Callbacks::ALL.include?(message)
handle_callback(node)
elsif message.match?(/^([a-zA-Z0-9_]+)(_path|_url)$/)
handle_route(node)
end
end

private

sig { params(node: Prism::CallNode).void }
def handle_callback(node)
arguments = node.arguments&.arguments
return unless arguments&.any?

Expand All @@ -62,7 +84,23 @@ def on_call_node_enter(node)
end
end

private
sig { params(node: T.untyped).void }
def handle_route(node)
result = @client.route_location(node.message)

location = T.must(result).fetch(:location)
return unless location

file_path, line = location.split(":")

@response_builder << Interface::Location.new(
uri: URI::Generic.from_path(path: file_path).to_s,
range: Interface::Range.new(
start: Interface::Position.new(line: Integer(line) - 1, character: 0),
end: Interface::Position.new(line: Integer(line) - 1, character: 0),
),
)
end

sig { params(name: String).void }
def collect_definitions(name)
Expand Down
8 changes: 8 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/runner_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ def model(name)
nil
end

sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def route_location(name)
make_request("route_location", name: name)
rescue IncompleteMessageError
$stderr.puts("Ruby LSP Rails failed to get route location: #{@stderr.read}")
nil
end

sig { void }
def trigger_reload
$stderr.puts("Reloading Rails application")
Expand Down
33 changes: 33 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ def execute(request, params)
when "reload"
::Rails.application.reloader.reload!
VOID
when "route_location"
route_location(T.must(params).fetch(:name))
else
VOID
end
Expand All @@ -79,6 +81,37 @@ def execute(request, params)

private

sig { params(name: String).returns(T::Hash[Symbol, T.untyped]) }
def route_location(name)
# Older versions of Rails don't support `route_source_locations`.
# We also check it hasn't been disabled.
unless ActionDispatch::Routing::Mapper.respond_to?(:route_source_locations) &&
ActionDispatch::Routing::Mapper.route_source_locations
return { result: { location: nil } }
end

key = T.must(name.match(/^([a-zA-Z0-9_]+)(_path|_url)$/))[1]

# A token could match the _path or _url pattern, but not be an actual route.
unless ::Rails.application.routes.named_routes.key?(key)
return { result: { location: nil } }
end

route = ::Rails.application.routes.named_routes.get(key)

unless route&.source_location
return { result: { location: nil } }
end

{
result: {
location: ::Rails.root.join(route.source_location).to_s,
},
}
rescue => e
{ error: e.full_message(highlight: false) }
end

sig { params(model_name: String).returns(T::Hash[Symbol, T.untyped]) }
def resolve_database_info_from_model(model_name)
const = ActiveSupport::Inflector.safe_constantize(model_name)
Expand Down
7 changes: 7 additions & 0 deletions test/dummy/app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# frozen_string_literal: true

class ApplicationController < ActionController::Base
def create
user_path(1)
user_url(1)
users_path
archive_users_path
invalid_path
end
end
5 changes: 5 additions & 0 deletions test/dummy/config/initializers/action_dispatch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# typed: true
# frozen_string_literal: true

# Route source locations are normally only available in development, so we need to enable this in test mode.
ActionDispatch::Routing::Mapper.route_source_locations = true if ENV["RAILS_ENV"] == "test"
3 changes: 3 additions & 0 deletions test/dummy/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# frozen_string_literal: true

Rails.application.routes.draw do
resources :users do
get :archive, on: :collection
end
end
49 changes: 49 additions & 0 deletions test/ruby_lsp_rails/definition_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,55 @@ def baz; end
assert_equal(14, response[1].range.end.character)
end

test "provides the definition of a route" do
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
users_path
RUBY

assert_equal(1, response.size)
dummy_root = File.expand_path("../dummy", __dir__)
assert_equal("file://#{dummy_root}/config/routes.rb", response[0].uri)
assert_equal(3, response[0].range.start.line)
assert_equal(3, response[0].range.end.line)
end

test "provides the definition of a custom route" do
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
archive_users_path
RUBY

assert_equal(1, response.size)
dummy_root = File.expand_path("../dummy", __dir__)
assert_equal("file://#{dummy_root}/config/routes.rb", response[0].uri)
assert_equal(4, response[0].range.start.line)
assert_equal(4, response[0].range.end.line)
end

test "ignored non-existing routes" do
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
invalid_path
RUBY

assert_empty(response)
end

test "returns an empty response if `route_source_locations` isn't enabled" do
FileUtils.mv(
"test/dummy/config/initializers/action_dispatch.rb",
"test/dummy/config/initializers/action_dispatch.rb.bak",
)
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
users_path
RUBY

assert_empty(response)
ensure
FileUtils.mv(
"test/dummy/config/initializers/action_dispatch.rb.bak",
"test/dummy/config/initializers/action_dispatch.rb",
)
end

private

def generate_definitions_for_source(source, position)
Expand Down
12 changes: 12 additions & 0 deletions test/ruby_lsp_rails/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,16 @@ class ServerTest < ActiveSupport::TestCase
ensure
ActiveRecord::Tasks::DatabaseTasks.send(:alias_method, :schema_dump_path, :old_schema_dump_path)
end

test "route location returns the location for a valid route" do
response = @server.execute("route_location", { name: "user_path" })
location = response[:result][:location]
assert_match %r{test/dummy/config/routes.rb:4$}, location
end

test "route location returns nil for invalid routes" do
response = @server.execute("route_location", { name: "invalid_path" })
location = response[:result][:location]
assert_nil location
end
end

0 comments on commit 23620ab

Please sign in to comment.