Skip to content

Commit d2c3ac3

Browse files
committed
Implement schema goto definitions
1 parent d2257c2 commit d2c3ac3

File tree

9 files changed

+189
-5
lines changed

9 files changed

+189
-5
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_relative "support/associations"
99
require_relative "support/callbacks"
1010
require_relative "support/validations"
11+
require_relative "support/routes"
1112
require_relative "support/location_builder"
1213
require_relative "runner_client"
1314
require_relative "hover"
@@ -129,7 +130,7 @@ def create_document_symbol_listener(response_builder, dispatcher)
129130
def create_definition_listener(response_builder, uri, node_context, dispatcher)
130131
return unless @global_state
131132

132-
Definition.new(@rails_runner_client, response_builder, node_context, @global_state.index, dispatcher)
133+
Definition.new(@rails_runner_client, response_builder, node_context, @global_state.index, uri, dispatcher)
133134
end
134135

135136
# @override

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ class Definition
3131
include Requests::Support::Common
3232

3333
#: (RunnerClient client, RubyLsp::ResponseBuilders::CollectionResponseBuilder[(Interface::Location | Interface::LocationLink)] response_builder, NodeContext node_context, RubyIndexer::Index index, Prism::Dispatcher dispatcher) -> void
34-
def initialize(client, response_builder, node_context, index, dispatcher)
34+
def initialize(client, response_builder, node_context, index, uri, dispatcher)
3535
@client = client
3636
@response_builder = response_builder
3737
@node_context = node_context
3838
@nesting = node_context.nesting #: Array[String]
3939
@index = index
40+
@uri = uri
4041

4142
dispatcher.register(self, :on_call_node_enter, :on_symbol_node_enter, :on_string_node_enter)
4243
end
@@ -87,6 +88,8 @@ def handle_possible_dsl(node)
8788
elsif Support::Validations::ALL.include?(message)
8889
handle_validation(node, call_node, arguments)
8990
handle_if_unless_conditional(node, call_node, arguments)
91+
elsif Support::Routes::ALL.include?(message)
92+
handle_controller_action(node)
9093
end
9194
end
9295

@@ -125,6 +128,27 @@ def handle_validation(node, call_node, arguments)
125128
collect_definitions(name)
126129
end
127130

131+
def handle_controller_action(node)
132+
return unless @uri.path.match?(Support::Routes::ROUTE_FILES_PATTERN)
133+
return unless node.is_a?(Prism::StringNode)
134+
135+
content = node.content
136+
return unless content.include?("#")
137+
138+
controller, action = content.split("#", 2)
139+
140+
results = @client.controller_action_target(
141+
controller: controller,
142+
action: action,
143+
)
144+
145+
return unless results&.any?
146+
147+
results.each do |result|
148+
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
149+
end
150+
end
151+
128152
#: (Prism::CallNode node) -> void
129153
def handle_association(node)
130154
first_argument = node.arguments&.arguments&.first

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ def association_target(model_name:, association_name:)
159159
nil
160160
end
161161

162+
def controller_action_target(controller:, action:)
163+
make_request(
164+
"controller_action_target",
165+
controller: controller,
166+
action: action,
167+
)
168+
rescue MessageError
169+
log_message(
170+
"Ruby LSP Rails failed to get controller action location",
171+
type: RubyLsp::Constant::MessageType::ERROR,
172+
)
173+
nil
174+
end
175+
162176
#: (String name) -> Hash[Symbol, untyped]?
163177
def route_location(name)
164178
make_request("route_location", name: name)

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ def execute(request, params)
311311
with_request_error_handling(request) do
312312
send_result(resolve_association_target(params))
313313
end
314+
when "controller_action_target"
315+
with_request_error_handling(request) do
316+
send_result(resolve_controller_action_target(params))
317+
end
314318
when "pending_migrations_message"
315319
with_request_error_handling(request) do
316320
send_result({ pending_migrations_message: pending_migrations_message })
@@ -437,6 +441,35 @@ def resolve_association_target(params)
437441
nil
438442
end
439443

444+
#: (Hash[Symbol | String, untyped]) -> Array[Hash[Symbol | String, untyped]]
445+
def resolve_controller_action_target(params)
446+
controller, action = params.values_at(:controller, :action)
447+
return unless controller && action
448+
449+
results = []
450+
451+
::Rails.application.routes.routes.each do |route|
452+
reqs = route.requirements
453+
next unless reqs[:controller]&.ends_with?(controller) && reqs[:action] == action
454+
455+
controller_class = "#{reqs[:controller].camelize}Controller".safe_constantize # rubocop:disable Sorbet/ConstantsFromStrings
456+
next unless controller_class
457+
458+
method = controller_class.instance_method(reqs[:action])
459+
file, line = method.source_location
460+
next unless file && line
461+
462+
results << {
463+
location: "#{file}:#{line}",
464+
name: "#{controller_class.name}##{reqs[:action]}",
465+
}
466+
rescue NameError
467+
next
468+
end
469+
470+
results
471+
end
472+
440473
#: (Module?) -> bool
441474
def active_record_model?(const)
442475
!!(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Rails
6+
module Support
7+
module Routes
8+
ALL = [
9+
"get",
10+
"post",
11+
"put",
12+
"patch",
13+
"delete",
14+
"match",
15+
].freeze
16+
17+
ROUTE_FILES_PATTERN = %r{(^|/)config/routes(?:/[^/]+)?\.rb$}
18+
end
19+
end
20+
end
21+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module Admin
4+
class UsersController < ApplicationController
5+
def index; end
6+
7+
def archive; end
8+
end
9+
end

test/dummy/app/controllers/users_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ class UsersController < ApplicationController
44
def index; end
55

66
def archive; end
7+
8+
def unarchive; end
79
end

test/dummy/config/routes.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
Rails.application.routes.draw do
44
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
55
resources :users do
6-
get :archive, on: :collection
6+
get :archive, on: :collection, to: "users#archive"
7+
get :unarchive, on: :collection, to: "users#unarchive"
8+
end
9+
10+
scope module: "admin" do
11+
resources :users do
12+
get :archive, on: :collection, to: "users#archive"
13+
end
714
end
815

916
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.

test/ruby_lsp_rails/definition_test.rb

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,10 +467,83 @@ def name; end
467467
assert_equal(15, response.range.end.character)
468468
end
469469

470+
test "returns the controller action definition when only one controller matches" do
471+
source = <<~RUBY
472+
Rails.application.routes.draw do
473+
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
474+
resources :users do
475+
get :archive, on: :collection, to: "users#archive"
476+
get :unarchive, on: :collection, to: "users#unarchive"
477+
end
478+
479+
scope module: "admin" do
480+
resources :users do
481+
get :archive, on: :collection, to: "users#archive"
482+
end
483+
end
484+
end
485+
RUBY
486+
487+
response = generate_definitions_for_source(
488+
source,
489+
{ line: 4, character: 45 },
490+
uri: URI("file:///config/routes.rb"),
491+
)
492+
493+
assert_equal(1, response.size)
494+
495+
location = response.first
496+
497+
expected_path = File.expand_path("test/dummy/app/controllers/users_controller.rb")
498+
assert_equal("file://#{expected_path}", location.uri)
499+
assert_equal(7, location.range.start.line)
500+
assert_equal(7, location.range.end.line)
501+
end
502+
503+
test "returns all matching controller actions when multiple controllers exist in different namespaces" do
504+
source = <<~RUBY
505+
Rails.application.routes.draw do
506+
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
507+
resources :users do
508+
get :archive, on: :collection, to: "users#archive"
509+
get :unarchive, on: :collection, to: "users#unarchive"
510+
end
511+
512+
scope module: "admin" do
513+
resources :users do
514+
get :archive, on: :collection, to: "users#archive"
515+
end
516+
end
517+
end
518+
RUBY
519+
520+
response = generate_definitions_for_source(
521+
source,
522+
{ line: 3, character: 45 },
523+
uri: URI("file:///config/routes.rb"),
524+
)
525+
526+
assert_equal(2, response.size)
527+
528+
location = response.first
529+
530+
expected_path = File.expand_path("test/dummy/app/controllers/users_controller.rb")
531+
assert_equal("file://#{expected_path}", location.uri)
532+
assert_equal(5, location.range.start.line)
533+
assert_equal(5, location.range.end.line)
534+
535+
location = response.second
536+
537+
expected_path = File.expand_path("test/dummy/app/controllers/admin/users_controller.rb")
538+
assert_equal("file://#{expected_path}", location.uri)
539+
assert_equal(6, location.range.start.line)
540+
assert_equal(6, location.range.end.line)
541+
end
542+
470543
private
471544

472-
def generate_definitions_for_source(source, position)
473-
with_server(source) do |server, uri|
545+
def generate_definitions_for_source(source, position, uri: nil)
546+
with_server(source, *[uri].compact) do |server, uri|
474547
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
475548

476549
server.process_message(

0 commit comments

Comments
 (0)