Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
114 changes: 112 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,78 @@ 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

camelize(controller_path) + "Controller" if controller_path
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 ? [] : 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