Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -9,6 +9,7 @@
require_relative "support/callbacks"
require_relative "support/validations"
require_relative "support/location_builder"
require_relative "support/inflections"
require_relative "runner_client"
require_relative "hover"
require_relative "code_lens"
Expand Down Expand Up @@ -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, uri, node_context, @global_state.index, dispatcher)
end

# @override
Expand Down
7 changes: 2 additions & 5 deletions lib/ruby_lsp/ruby_lsp_rails/code_lens.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ module Rails
#
class CodeLens
include Requests::Support::Common
include Inflections
include ActiveSupportTestCaseHelper

#: (RunnerClient, GlobalState, ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens], URI::Generic, Prism::Dispatcher) -> void
Expand Down Expand Up @@ -191,11 +192,7 @@ def controller?
def add_jump_to_view(node)
class_name = @constant_name_stack.map(&:first).join("::")
action_name = node.name
controller_name = class_name
.delete_suffix("Controller")
.gsub(/([a-z])([A-Z])/, "\\1_\\2")
.gsub("::", "/")
.downcase
controller_name = underscore(class_name.delete_suffix("Controller"))

view_uris = Dir.glob("#{@client.rails_root}/app/views/#{controller_name}/#{action_name}*").filter_map do |path|
# it's possible we could have a directory with the same name as the action, so we need to skip those
Expand Down
118 changes: 116 additions & 2 deletions lib/ruby_lsp/ruby_lsp_rails/definition.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# typed: strict
# frozen_string_literal: true

require "pathname"

module RubyLsp
module Rails
# ![Definition demo](../../definition.gif)
Expand Down Expand Up @@ -29,11 +31,13 @@ module Rails
# - Changes to routes won't be picked up until the server is restarted.
class Definition
include Requests::Support::Common
include Inflections

#: (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)
#: (RunnerClient client, RubyLsp::ResponseBuilders::CollectionResponseBuilder[(Interface::Location | Interface::LocationLink)] response_builder, URI::Generic uri, NodeContext node_context, RubyIndexer::Index index, Prism::Dispatcher dispatcher) -> void
def initialize(client, response_builder, uri, node_context, index, dispatcher) # rubocop:disable Metrics/ParameterLists
@client = client
@response_builder = response_builder
@path = uri.to_standardized_path #: String?
@node_context = node_context
@nesting = node_context.nesting #: Array[String]
@index = index
Expand All @@ -49,6 +53,7 @@ def on_symbol_node_enter(node)
#: (Prism::StringNode node) -> void
def on_string_node_enter(node)
handle_possible_dsl(node)
handle_possible_render(node)
end

#: (Prism::CallNode node) -> void
Expand Down Expand Up @@ -142,6 +147,39 @@ def handle_association(node)
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
end

#: (Prism::StringNode node) -> void
def handle_possible_render(node)
return unless @path&.end_with?(".html.erb")

call_node = @node_context.call_node
return unless call_node
return unless self_receiver?(call_node)

return unless call_node.message == "render"

arguments = call_node.arguments&.arguments
return unless arguments

argument = view_template_argument(arguments, node)
return unless argument

controller_name = controller_for_template(@path)
return unless controller_name

template_name = node.content
template_details = view_template_details(arguments)

template = @client.find_template(
controller_name: controller_name,
template_name: template_name,
partial: argument != "template",
details: template_details,
)
return unless template

@response_builder << Support::LocationBuilder.line_location_from_s("#{template[:path]}:1")
end

#: (Prism::CallNode node) -> void
def handle_route(node)
result = @client.route_location(
Expand Down Expand Up @@ -194,6 +232,82 @@ def handle_if_unless_conditional(node, call_node, arguments)

collect_definitions(method_name)
end

#: (Array[Prism::Node] arguments, Prism::StringNode node) -> String?
def view_template_argument(arguments, node)
return "partial" if arguments.first == node

keyword_arguments = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) } #: as Prism::KeywordHashNode?
return unless keyword_arguments

element = keyword_arguments.elements.find do |element|
next unless element.is_a?(Prism::AssocNode)

key = element.key
next unless key.is_a?(Prism::SymbolNode)

next unless element.value == node

["partial", "layout", "spacer_template", "template"].include?(key.value)
end #: as Prism::AssocNode?

return unless element

key = element.key #: as Prism::SymbolNode
key.value
end

#: (Array[Prism::Node] arguments) -> Hash[String, (String | Array[String])]
def view_template_details(arguments)
keyword_arguments = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) } #: as Prism::KeywordHashNode?
return {} unless keyword_arguments

keyword_arguments.elements.each_with_object({}) do |element, options|
next unless element.is_a?(Prism::AssocNode)

key = element.key
next unless key.is_a?(Prism::SymbolNode)

key_value = key.value
next unless ["formats", "variants", "handlers"].include?(key_value)

value = element.value

if value.is_a?(Prism::SymbolNode)
options[key_value] = value.value
elsif value.is_a?(Prism::ArrayNode) && value.elements.all?(Prism::SymbolNode)
elements = value.elements #: as Array[Prism::SymbolNode]
options[key_value] = elements.map(&:value)
end
end
end

# Determine controller name for given template path by matching segments
# of its directory path to controller paths and checking if the controllers'
# view paths complete the rest of the template directory path.
#
#: (String template_path) -> String?
def controller_for_template(template_path)
template_directory = Pathname(template_path).dirname.relative_path_from(@client.rails_root)
directory_segments = template_directory.each_filename.to_a
possible_controller_paths = (1..directory_segments.count).map do |n|
directory_segments.last(n).join("/")
end

controller_path = possible_controller_paths.find do |controller_path|
controller_name = camelize(controller_path) + "Controller"
view_paths = @client.controller(controller_name)&.dig(:view_paths) || []
view_paths.any? do |view_path|
File.join(view_path, controller_path) == File.dirname(template_path)
end
end

if controller_path
camelize(controller_path) + "Controller"
else
"ActionController::Base"
end
end
end
end
end
28 changes: 28 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/runner_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,34 @@ def association_target(model_name:, association_name:)
nil
end

#: (String name) -> Hash[Symbol, untyped]?
def controller(name)
make_request("controller", name: name)
rescue MessageError
log_message(
"Ruby LSP Rails failed to get controller view paths",
type: RubyLsp::Constant::MessageType::ERROR,
)
nil
end

#: (controller_name: String, template_name: String, partial: bool, details: Hash[String, String | Array[String]]) -> Hash[Symbol, untyped]?
def find_template(controller_name:, template_name:, partial:, details:)
make_request(
"find_template",
controller_name: controller_name,
template_name: template_name,
partial: partial,
details: details,
)
rescue MessageError
log_message(
"Ruby LSP Rails failed to find view template for controller",
type: RubyLsp::Constant::MessageType::ERROR,
)
nil
end

#: (String name) -> Hash[Symbol, untyped]?
def route_location(name)
make_request("route_location", name: name)
Expand Down
48 changes: 48 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ def execute(request, params)
with_request_error_handling(request) do
send_result(resolve_association_target(params))
end
when "controller"
with_request_error_handling(request) do
send_result(controller_info(params.fetch(:name)))
end
when "find_template"
with_request_error_handling(request) do
send_result(lookup_view_template(params))
end
when "pending_migrations_message"
with_request_error_handling(request) do
send_result({ pending_migrations_message: pending_migrations_message })
Expand Down Expand Up @@ -436,6 +444,36 @@ def resolve_association_target(params)
nil
end

#: (String controller_name) -> Hash[Symbol | String, untyped]?
def controller_info(controller_name)
const = ActiveSupport::Inflector.safe_constantize(controller_name) # rubocop:disable Sorbet/ConstantsFromStrings
return unless controller?(const)

{ view_paths: const.view_paths.map(&:to_s) }
end

#: (Hash[Symbol | String, untyped]) -> Hash[Symbol | String, untyped]?
def lookup_view_template(params)
const = ActiveSupport::Inflector.safe_constantize(params[:controller_name]) # rubocop:disable Sorbet/ConstantsFromStrings
return unless controller?(const)

path = params[:template_name]
partial = params[:partial]
details = params[:details].transform_values { |value| Array(value).map(&:to_sym) }

lookup_context = const.new.lookup_context
prefixes = path.include?("/") || !partial || const.abstract? ? [] : lookup_context.prefixes

# Disable the lookup cache to ensure template changes are reflected
template = lookup_context.disable_cache do
lookup_context.find_template(path, prefixes, partial, [], details)
end

{ path: template.identifier }
rescue ActionView::MissingTemplate
nil
end

#: (Module?) -> bool
def active_record_model?(const)
!!(
Expand All @@ -448,6 +486,16 @@ def active_record_model?(const)
)
end

#: (Module?) -> bool
def controller?(const)
!!(
const &&
defined?(ActionController) &&
const.is_a?(Class) &&
ActionController::Base >= const
)
end

#: -> String?
def pending_migrations_message
# `check_all_pending!` is only available since Rails 7.1
Expand Down
24 changes: 24 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/support/inflections.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Rails
module Inflections
#: (String) -> String
def camelize(string)
string
.gsub(%r{(?:^|_|/)[a-z]}, &:upcase)
.tr("_", "")
.gsub("/", "::")
end

#: (String) -> String
def underscore(string)
string
.gsub(/([a-z])([A-Z])/, "\\1_\\2")
.gsub("::", "/")
.downcase
end
end
end
end
Loading
Loading