Skip to content

Commit c3f6141

Browse files
authored
Add Definition support for routes (#331)
* Don't test against Ruby head * Add Definition support for routes ---------
1 parent cb8857a commit c3f6141

File tree

11 files changed

+146
-8
lines changed

11 files changed

+146
-8
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,8 @@ jobs:
1212
gemfile:
1313
- Gemfile
1414
- gemfiles/Gemfile-rails-main
15-
ruby: ["3.0", "3.1", "3.2", "3.3", "head"]
15+
ruby: ["3.0", "3.1", "3.2", "3.3"]
1616
include:
17-
- ruby: "head"
18-
experimental: true
1917
- gemfile: "gemfiles/Gemfile-rails-main"
2018
experimental: true
2119
exclude:

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 declaration of a route.
910

1011
## Installation
1112

lib/ruby_lsp/ruby_lsp_rails/addon.rb

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

3737
sig { override.void }
@@ -84,7 +84,7 @@ def create_document_symbol_listener(response_builder, dispatcher)
8484
end
8585
def create_definition_listener(response_builder, uri, nesting, dispatcher)
8686
index = T.must(@global_state).index
87-
Definition.new(response_builder, nesting, index, dispatcher)
87+
Definition.new(@client, response_builder, nesting, index, dispatcher)
8888
end
8989

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

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,35 @@ module Rails
1111
#
1212
# Currently supported targets:
1313
# - Callbacks
14+
# - Named routes (e.g. `users_path`)
1415
#
1516
# # Example
1617
#
1718
# ```ruby
1819
# before_action :foo # <- Go to definition on this symbol will jump to the method if it is defined in the same class
1920
# ```
21+
#
22+
# Notes for named routes:
23+
# - It is available only in Rails 7.1 or newer.
24+
# - Route may be defined across multiple files, e.g. using `draw`, rather than in `routes.rb`.
25+
# - Routes won't be found if not defined for the Rails development environment.
26+
# - If using `constraints`, the route can only be found if the constraints are met.
27+
# - Changes to routes won't be picked up until the server is restarted.
2028
class Definition
2129
extend T::Sig
2230
include Requests::Support::Common
2331

2432
sig do
2533
params(
34+
client: RunnerClient,
2635
response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::Location],
2736
nesting: T::Array[String],
2837
index: RubyIndexer::Index,
2938
dispatcher: Prism::Dispatcher,
3039
).void
3140
end
32-
def initialize(response_builder, nesting, index, dispatcher)
41+
def initialize(client, response_builder, nesting, index, dispatcher)
42+
@client = client
3343
@response_builder = response_builder
3444
@nesting = nesting
3545
@index = index
@@ -43,8 +53,19 @@ def on_call_node_enter(node)
4353

4454
message = node.message
4555

46-
return unless message && Support::Callbacks::ALL.include?(message)
56+
return unless message
57+
58+
if Support::Callbacks::ALL.include?(message)
59+
handle_callback(node)
60+
elsif message.end_with?("_path") || message.end_with?("_url")
61+
handle_route(node)
62+
end
63+
end
64+
65+
private
4766

67+
sig { params(node: Prism::CallNode).void }
68+
def handle_callback(node)
4869
arguments = node.arguments&.arguments
4970
return unless arguments&.any?
5071

@@ -62,7 +83,21 @@ def on_call_node_enter(node)
6283
end
6384
end
6485

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

67102
sig { params(name: String).void }
68103
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
@@ -93,6 +93,14 @@ def model(name)
9393
nil
9494
end
9595

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

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def execute(request, params)
4444
when "reload"
4545
::Rails.application.reloader.reload!
4646
VOID
47+
when "route_location"
48+
route_location(params.fetch(:name))
4749
else
4850
VOID
4951
end
@@ -53,6 +55,34 @@ def execute(request, params)
5355

5456
private
5557

58+
# Older versions of Rails don't support `route_source_locations`.
59+
# We also check that it's enabled.
60+
if ActionDispatch::Routing::Mapper.respond_to?(:route_source_locations) &&
61+
ActionDispatch::Routing::Mapper.route_source_locations
62+
def route_location(name)
63+
match_data = name.match(/^(.+)(_path|_url)$/)
64+
return { result: nil } unless match_data
65+
66+
key = match_data[1]
67+
68+
# A token could match the _path or _url pattern, but not be an actual route.
69+
route = ::Rails.application.routes.named_routes.get(key)
70+
return { result: nil } unless route&.source_location
71+
72+
{
73+
result: {
74+
location: ::Rails.root.join(route.source_location).to_s,
75+
},
76+
}
77+
rescue => e
78+
{ error: e.full_message(highlight: false) }
79+
end
80+
else
81+
def route_location(name)
82+
{ result: nil }
83+
end
84+
end
85+
5686
def resolve_database_info_from_model(model_name)
5787
const = ActiveSupport::Inflector.safe_constantize(model_name)
5888
unless active_record_model?(const)
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

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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,46 @@ 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 "handles incomplete routes" do
96+
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
97+
_path
98+
RUBY
99+
100+
assert_empty(response)
101+
end
102+
103+
test "provides the definition of a custom route" do
104+
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
105+
archive_users_path
106+
RUBY
107+
108+
assert_equal(1, response.size)
109+
dummy_root = File.expand_path("../dummy", __dir__)
110+
assert_equal("file://#{dummy_root}/config/routes.rb", response[0].uri)
111+
assert_equal(4, response[0].range.start.line)
112+
assert_equal(4, response[0].range.end.line)
113+
end
114+
115+
test "ignored non-existing routes" do
116+
response = generate_definitions_for_source(<<~RUBY, { line: 0, character: 0 })
117+
invalid_path
118+
RUBY
119+
120+
assert_empty(response)
121+
end
122+
83123
private
84124

85125
def generate_definitions_for_source(source, position)

test/ruby_lsp_rails/server_test.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,15 @@ def <(other)
4545
ensure
4646
ActiveRecord::Tasks::DatabaseTasks.send(:alias_method, :schema_dump_path, :old_schema_dump_path)
4747
end
48+
49+
test "route location returns the location for a valid route" do
50+
response = @server.execute("route_location", { name: "user_path" })
51+
location = response[:result][:location]
52+
assert_match %r{test/dummy/config/routes.rb:4$}, location
53+
end
54+
55+
test "route location returns nil for invalid routes" do
56+
response = @server.execute("route_location", { name: "invalid_path" })
57+
assert_nil response[:result]
58+
end
4859
end

0 commit comments

Comments
 (0)