diff --git a/lib/ruby_lsp/ruby_lsp_rails/addon.rb b/lib/ruby_lsp/ruby_lsp_rails/addon.rb index a751708e..99702828 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/addon.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/addon.rb @@ -8,6 +8,7 @@ require_relative "support/associations" require_relative "support/callbacks" require_relative "support/validations" +require_relative "support/routes" require_relative "support/location_builder" require_relative "runner_client" require_relative "hover" @@ -129,7 +130,7 @@ def create_document_symbol_listener(response_builder, dispatcher) def create_definition_listener(response_builder, uri, node_context, dispatcher) return unless @global_state - Definition.new(@rails_runner_client, response_builder, node_context, @global_state.index, dispatcher) + Definition.new(@rails_runner_client, response_builder, node_context, @global_state.index, uri, dispatcher) end # @override diff --git a/lib/ruby_lsp/ruby_lsp_rails/definition.rb b/lib/ruby_lsp/ruby_lsp_rails/definition.rb index 133d7760..e8b42420 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/definition.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/definition.rb @@ -31,12 +31,13 @@ class Definition include Requests::Support::Common #: (RunnerClient client, RubyLsp::ResponseBuilders::CollectionResponseBuilder[(Interface::Location | Interface::LocationLink)] response_builder, NodeContext node_context, RubyIndexer::Index index, Prism::Dispatcher dispatcher) -> void - def initialize(client, response_builder, node_context, index, dispatcher) + def initialize(client, response_builder, node_context, index, uri, dispatcher) @client = client @response_builder = response_builder @node_context = node_context @nesting = node_context.nesting #: Array[String] @index = index + @uri = uri dispatcher.register(self, :on_call_node_enter, :on_symbol_node_enter, :on_string_node_enter) end @@ -87,6 +88,8 @@ def handle_possible_dsl(node) elsif Support::Validations::ALL.include?(message) handle_validation(node, call_node, arguments) handle_if_unless_conditional(node, call_node, arguments) + elsif Support::Routes::ALL.include?(message) + handle_controller_action(node) end end @@ -125,6 +128,28 @@ def handle_validation(node, call_node, arguments) collect_definitions(name) end + #: ((Prism::SymbolNode | Prism::StringNode) node) -> void + def handle_controller_action(node) + return unless @uri.path.match?(Support::Routes::ROUTE_FILES_PATTERN) + return unless node.is_a?(Prism::StringNode) + + content = node.content + return unless content.include?("#") + + controller, action = content.split("#", 2) + + results = @client.controller_action_target( + controller: controller, + action: action, + ) + + return unless results&.any? + + results.each do |result| + @response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location)) + end + end + #: (Prism::CallNode node) -> void def handle_association(node) first_argument = node.arguments&.arguments&.first diff --git a/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb b/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb index dc284cb5..96e8a895 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb @@ -159,6 +159,20 @@ def association_target(model_name:, association_name:) nil end + def controller_action_target(controller:, action:) + make_request( + "controller_action_target", + controller: controller, + action: action, + ) + rescue MessageError + log_message( + "Ruby LSP Rails failed to get controller action location", + type: RubyLsp::Constant::MessageType::ERROR, + ) + nil + end + #: (String name) -> Hash[Symbol, untyped]? def route_location(name) make_request("route_location", name: name) diff --git a/lib/ruby_lsp/ruby_lsp_rails/server.rb b/lib/ruby_lsp/ruby_lsp_rails/server.rb index 9daa56a8..6fd369b8 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/server.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/server.rb @@ -311,6 +311,10 @@ def execute(request, params) with_request_error_handling(request) do send_result(resolve_association_target(params)) end + when "controller_action_target" + with_request_error_handling(request) do + send_result(resolve_controller_action_target(params)) + end when "pending_migrations_message" with_request_error_handling(request) do send_result({ pending_migrations_message: pending_migrations_message }) @@ -437,6 +441,35 @@ def resolve_association_target(params) nil end + #: (Hash[Symbol | String, untyped]) -> Array[Hash[Symbol | String, untyped]] + def resolve_controller_action_target(params) + controller, action = params.values_at(:controller, :action) + return unless controller && action + + results = [] + + ::Rails.application.routes.routes.each do |route| + reqs = route.requirements + next unless reqs[:controller]&.ends_with?(controller) && reqs[:action] == action + + controller_class = "#{reqs[:controller].camelize}Controller".safe_constantize # rubocop:disable Sorbet/ConstantsFromStrings + next unless controller_class + + method = controller_class.instance_method(reqs[:action]) + file, line = method.source_location + next unless file && line + + results << { + location: "#{file}:#{line}", + name: "#{controller_class.name}##{reqs[:action]}", + } + rescue NameError + next + end + + results + end + #: (Module?) -> bool def active_record_model?(const) !!( diff --git a/lib/ruby_lsp/ruby_lsp_rails/support/routes.rb b/lib/ruby_lsp/ruby_lsp_rails/support/routes.rb new file mode 100644 index 00000000..d797bf8d --- /dev/null +++ b/lib/ruby_lsp/ruby_lsp_rails/support/routes.rb @@ -0,0 +1,21 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Rails + module Support + module Routes + ALL = [ + "get", + "post", + "put", + "patch", + "delete", + "match", + ].freeze + + ROUTE_FILES_PATTERN = %r{(^|/)config/routes(?:/[^/]+)?\.rb$} + end + end + end +end diff --git a/test/dummy/app/controllers/admin/users_controller.rb b/test/dummy/app/controllers/admin/users_controller.rb new file mode 100644 index 00000000..d032b71f --- /dev/null +++ b/test/dummy/app/controllers/admin/users_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Admin + class UsersController < ApplicationController + def index; end + + def archive; end + end +end diff --git a/test/dummy/app/controllers/users_controller.rb b/test/dummy/app/controllers/users_controller.rb index 38851110..c33f0556 100644 --- a/test/dummy/app/controllers/users_controller.rb +++ b/test/dummy/app/controllers/users_controller.rb @@ -4,4 +4,6 @@ class UsersController < ApplicationController def index; end def archive; end + + def unarchive; end end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 0693d974..d362c017 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -3,7 +3,14 @@ Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html resources :users do - get :archive, on: :collection + get :archive, on: :collection, to: "users#archive" + get :unarchive, on: :collection, to: "users#unarchive" + end + + scope module: "admin" do + resources :users do + get :archive, on: :collection, to: "users#archive" + end end # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/test/ruby_lsp_rails/definition_test.rb b/test/ruby_lsp_rails/definition_test.rb index bc8c03e0..89d30b2a 100644 --- a/test/ruby_lsp_rails/definition_test.rb +++ b/test/ruby_lsp_rails/definition_test.rb @@ -467,10 +467,75 @@ def name; end assert_equal(15, response.range.end.character) end + test "finds the controller action definition when only one controller matches" do + source = <<~RUBY + Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resources :users do + get :archive, on: :collection, to: "users#archive" + get :unarchive, on: :collection, to: "users#unarchive" + end + + scope module: "admin" do + resources :users do + get :archive, on: :collection, to: "users#archive" + end + end + end + RUBY + + response = generate_definitions_for_source(source, { line: 4, character: 45 }, uri: URI("file:///config/routes.rb")) + + assert_equal(1, response.size) + + location = response.first + + expected_path = File.expand_path("test/dummy/app/controllers/users_controller.rb") + assert_equal("file://#{expected_path}", location.uri) + assert_equal(7, location.range.start.line) + assert_equal(7, location.range.end.line) + end + + test "finds all matching controller actions when multiple controllers exist in different namespaces" do + source = <<~RUBY + Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resources :users do + get :archive, on: :collection, to: "users#archive" + get :unarchive, on: :collection, to: "users#unarchive" + end + + scope module: "admin" do + resources :users do + get :archive, on: :collection, to: "users#archive" + end + end + end + RUBY + + response = generate_definitions_for_source(source, { line: 3, character: 45 }, uri: URI("file:///config/routes.rb")) + + assert_equal(2, response.size) + + location = response.first + + expected_path = File.expand_path("test/dummy/app/controllers/users_controller.rb") + assert_equal("file://#{expected_path}", location.uri) + assert_equal(5, location.range.start.line) + assert_equal(5, location.range.end.line) + + location = response.second + + expected_path = File.expand_path("test/dummy/app/controllers/admin/users_controller.rb") + assert_equal("file://#{expected_path}", location.uri) + assert_equal(6, location.range.start.line) + assert_equal(6, location.range.end.line) + end + private - def generate_definitions_for_source(source, position) - with_server(source) do |server, uri| + def generate_definitions_for_source(source, position, uri: nil) + with_server(source, *[uri].compact) do |server, uri| sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient) server.process_message(