Skip to content

Commit 6a97d0c

Browse files
committed
Add Definition support for routes
1 parent 64a56af commit 6a97d0c

File tree

10 files changed

+161
-5
lines changed

10 files changed

+161
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Ruby LSP Rails is a [Ruby LSP](https://github.com/Shopify/ruby-lsp) addon for ex
66
* Run or debug a test by clicking on the code lens which appears above the test class, or an individual test.
77
* Navigate to associations, validations, callbacks and test cases using your editor's "Go to Symbol" feature, or outline view.
88
* Jump to the definition of callbacks using your editor's "Go to Definition" feature.
9+
* Jump to the definition of a route.
910

1011
## Installation
1112

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def activate(global_state, message_queue)
3131
$stderr.puts("Activating Ruby LSP Rails addon v#{VERSION}")
3232
# Start booting the real client in a background thread. Until this completes, the client will be a NullClient
3333
Thread.new { @client = RunnerClient.create_client }
34+
@client = RunnerClient.create_client
3435
end
3536

3637
sig { override.void }
@@ -83,7 +84,7 @@ def create_document_symbol_listener(response_builder, dispatcher)
8384
end
8485
def create_definition_listener(response_builder, uri, nesting, dispatcher)
8586
index = T.must(@global_state).index
86-
Definition.new(response_builder, nesting, index, dispatcher)
87+
Definition.new(@client, response_builder, nesting, index, dispatcher)
8788
end
8889

8990
sig { params(changes: T::Array[{ uri: String, type: Integer }]).void }

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,38 @@ module Rails
99
# request](https://microsoft.github.io/language-server-protocol/specification#textDocument_definition) jumps to the
1010
# definition of the symbol under the cursor.
1111
#
12+
# It is available only Rails 7.1 or newer.
13+
#
1214
# Currently supported targets:
1315
# - Callbacks
16+
# - Named routes (e.g. `users_path`)
1417
#
1518
# # Example
1619
#
1720
# ```ruby
1821
# before_action :foo # <- Go to definition on this symbol will jump to the method if it is defined in the same class
1922
# ```
23+
#
24+
# Notes for named routes:
25+
# - It works even if the routes are defined in multiple files, e.g. using `draw`.
26+
# - It won't work if a route is not defined for the Rails development. environment.
27+
# - If using `constraints`, the route will be found if the constraints are met.
28+
# - Changes to routes won't be picked up until the server is restarted.
2029
class Definition
2130
extend T::Sig
2231
include Requests::Support::Common
2332

2433
sig do
2534
params(
35+
client: RunnerClient,
2636
response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::Location],
2737
nesting: T::Array[String],
2838
index: RubyIndexer::Index,
2939
dispatcher: Prism::Dispatcher,
3040
).void
3141
end
32-
def initialize(response_builder, nesting, index, dispatcher)
42+
def initialize(client, response_builder, nesting, index, dispatcher)
43+
@client = client
3344
@response_builder = response_builder
3445
@nesting = nesting
3546
@index = index
@@ -43,8 +54,19 @@ def on_call_node_enter(node)
4354

4455
message = node.message
4556

46-
return unless message && Support::Callbacks::ALL.include?(message)
57+
return unless message
58+
59+
if Support::Callbacks::ALL.include?(message)
60+
handle_callback(node)
61+
elsif message.match?(/^([a-zA-Z0-9_]+)(_path|_url)$/)
62+
handle_route(node)
63+
end
64+
end
65+
66+
private
4767

68+
sig { params(node: Prism::CallNode).void }
69+
def handle_callback(node)
4870
arguments = node.arguments&.arguments
4971
return unless arguments&.any?
5072

@@ -62,7 +84,23 @@ def on_call_node_enter(node)
6284
end
6385
end
6486

65-
private
87+
sig { params(node: T.untyped).void }
88+
def handle_route(node)
89+
result = @client.route_location(node.message)
90+
91+
location = T.must(result).fetch(:location)
92+
return unless location
93+
94+
file_path, line = location.split(":")
95+
96+
@response_builder << Interface::Location.new(
97+
uri: URI::Generic.from_path(path: file_path).to_s,
98+
range: Interface::Range.new(
99+
start: Interface::Position.new(line: Integer(line) - 1, character: 0),
100+
end: Interface::Position.new(line: Integer(line) - 1, character: 0),
101+
),
102+
)
103+
end
66104

67105
sig { params(name: String).void }
68106
def collect_definitions(name)

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ def model(name)
9595
nil
9696
end
9797

98+
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
99+
def route_location(name)
100+
make_request("route_location", name: name)
101+
rescue IncompleteMessageError
102+
$stderr.puts("Ruby LSP Rails failed to get route location: #{@stderr.read}")
103+
nil
104+
end
105+
98106
sig { void }
99107
def trigger_reload
100108
$stderr.puts("Reloading Rails application")

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def execute(request, params)
7070
when "reload"
7171
::Rails.application.reloader.reload!
7272
VOID
73+
when "route_location"
74+
route_location(T.must(params).fetch(:name))
7375
else
7476
VOID
7577
end
@@ -79,6 +81,36 @@ def execute(request, params)
7981

8082
private
8183

84+
sig { params(name: String).returns(T::Hash[Symbol, T.untyped]) }
85+
def route_location(name)
86+
key = T.must(name.match(/^([a-zA-Z0-9_]+)(_path|_url)$/))[1]
87+
88+
# A token could match the _path or _url pattern, but not be an actual route.
89+
unless ::Rails.application.routes.named_routes.key?(key)
90+
return {
91+
result: {
92+
location: nil,
93+
},
94+
}
95+
end
96+
97+
route = ::Rails.application.routes.named_routes.get(key)
98+
99+
unless route&.source_location
100+
return {
101+
result: {
102+
location: nil,
103+
},
104+
}
105+
end
106+
107+
{
108+
result: {
109+
location: ::Rails.root.join(route.source_location).to_s,
110+
},
111+
}
112+
end
113+
82114
sig { params(model_name: String).returns(T::Hash[Symbol, T.untyped]) }
83115
def resolve_database_info_from_model(model_name)
84116
const = ActiveSupport::Inflector.safe_constantize(model_name)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
# frozen_string_literal: true
22

33
class ApplicationController < ActionController::Base
4+
def create
5+
user_path(1)
6+
user_url(1)
7+
users_path
8+
archive_users_path
9+
invalid_path
10+
end
411
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
# Route source locations are normally only available in development, so we need to enable this in test mode.
5+
ActionDispatch::Routing::Mapper.route_source_locations = true if ENV["RAILS_ENV"] == "test"

test/dummy/config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# frozen_string_literal: true
22

33
Rails.application.routes.draw do
4+
resources :users do
5+
get :archive, on: :collection
6+
end
47
end

test/ruby_lsp_rails/definition_test.rb

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,59 @@ def baz; end
8080
assert_equal(14, response[1].range.end.character)
8181
end
8282

83+
test "provides the definition of a route" do
84+
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
85+
users_path
86+
RUBY
87+
88+
assert_equal(1, response.size)
89+
dummy_root = File.expand_path("../dummy", __dir__)
90+
assert_equal("file://#{dummy_root}/config/routes.rb", response[0].uri)
91+
assert_equal(3, response[0].range.start.line)
92+
assert_equal(3, response[0].range.end.line)
93+
end
94+
95+
test "provides the definition of a custom route" do
96+
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
97+
archive_users_path
98+
RUBY
99+
100+
assert_equal(1, response.size)
101+
dummy_root = File.expand_path("../dummy", __dir__)
102+
assert_equal("file://#{dummy_root}/config/routes.rb", response[0].uri)
103+
assert_equal(4, response[0].range.start.line)
104+
assert_equal(4, response[0].range.end.line)
105+
end
106+
107+
test "ignored non-existing routes" do
108+
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
109+
invalid_path
110+
RUBY
111+
112+
assert_empty(response)
113+
end
114+
115+
test "returns an empty response if `route_source_locations` isn't enabled" do
116+
FileUtils.mv(
117+
"test/dummy/config/initializers/action_dispatch.rb",
118+
"test/dummy/config/initializers/action_dispatch.rb.bak",
119+
)
120+
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
121+
users_path
122+
RUBY
123+
124+
assert_empty(response)
125+
ensure
126+
FileUtils.mv(
127+
"test/dummy/config/initializers/action_dispatch.rb.bak",
128+
"test/dummy/config/initializers/action_dispatch.rb",
129+
)
130+
end
131+
83132
private
84133

85134
def generate_definitions_for_source(source, position)
86-
with_server(source) do |server, uri|
135+
with_server(source, stub_no_typechecker: true) do |server, uri|
87136
server.process_message(
88137
id: 1,
89138
method: "textDocument/definition",

test/ruby_lsp_rails/server_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,16 @@ class ServerTest < ActiveSupport::TestCase
3232
ensure
3333
ActiveRecord::Tasks::DatabaseTasks.send(:alias_method, :schema_dump_path, :old_schema_dump_path)
3434
end
35+
36+
test "route location returns the location for a valid route" do
37+
response = @server.execute("route_location", { name: "user_path" })
38+
location = response[:result][:location]
39+
assert_match %r{test/dummy/config/routes.rb:4$}, location
40+
end
41+
42+
test "route location returns nil for invalid routes" do
43+
response = @server.execute("route_location", { name: "invalid_path" })
44+
location = response[:result][:location]
45+
assert_nil location
46+
end
3547
end

0 commit comments

Comments
 (0)