Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 3 additions & 6 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,13 +192,9 @@ 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|
view_uris = Dir.glob("#{@client.views_dir}/#{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
next if File.directory?(path)

Expand Down
92 changes: 91 additions & 1 deletion 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)
def initialize(client, response_builder, uri, node_context, index, dispatcher)
@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,47 @@ def handle_association(node)
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
end

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)

message = call_node.message
return unless message == "render"

arguments = call_node.arguments&.arguments
return unless arguments

argument = view_template_argument(arguments, node)
return unless argument

template = node.content
template_options = view_template_options(arguments)

formats_pattern = template_options[:formats] ? "{#{template_options[:formats].join(",")}}" : "html"
variants_pattern = "{#{template_options[:variants].map { |variant| "+#{variant}" }.join(",")},}" if template_options[:variants]
handlers_pattern = template_options[:handlers] ? "{#{template_options[:handlers].join(",")}}" : "*"

extension_pattern = "#{formats_pattern}#{variants_pattern}.#{handlers_pattern}"

template_pattern = if argument == "template"
File.join(@client.views_dir, "#{template}.#{extension_pattern}")
elsif template.include?("/")
*partial_dir, partial_name = template.split("/")

File.join(@client.views_dir, *partial_dir, "_#{partial_name}.#{extension_pattern}")
else
File.join(@client.views_dir, "{#{view_prefixes.join(",")}}", "_#{template}.#{extension_pattern}")
end

template_path = Dir.glob(template_pattern).first
return unless template_path

@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 +240,50 @@ def handle_if_unless_conditional(node, call_node, arguments)

collect_definitions(method_name)
end

def view_template_argument(arguments, node)
return "partial" if arguments.first == node

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

kwarg = kwargs.elements.find do |pair|
["partial", "layout", "spacer_template", "template"].include?(pair.key.value) && pair.value == node
end

kwarg&.key&.value
end

def view_template_options(arguments)
kwargs = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
return {} unless kwargs

kwargs.elements.each_with_object({}) do |pair, options|
next unless ["formats", "variants", "handlers"].include?(pair.key.value)

value = [pair.value.value] if pair.value.is_a?(Prism::SymbolNode)
value = pair.value.elements.map(&:value) if pair.value.is_a?(Prism::ArrayNode)

options[pair.key.value.to_sym] = value
end
end

# Resolve available directories from which the controller can render relative
# partials based on its ancestry chain.
def view_prefixes
controller_dir = Pathname(@path).dirname.relative_path_from(@client.views_dir).to_s
controller_class = "#{camelize(controller_dir)}Controller"
controller_ancestors = [controller_class]

controller_entry = @index.resolve(controller_class, [])&.find(&:parent_class)
while controller_entry
controller_entry = @index.resolve(controller_entry.parent_class, controller_entry.nesting)&.find(&:parent_class)
break unless controller_entry && not_in_dependencies?(controller_entry.file_path)
controller_ancestors << controller_entry.name
end

controller_ancestors.map { |ancestor| underscore(ancestor.delete_suffix("Controller")) }
end
end
end
end
4 changes: 4 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/runner_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ def connected?
true
end

def views_dir
File.join(@rails_root, "app/views")
end

private

#: (String request, **untyped params) -> Hash[Symbol, untyped]?
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(/_([a-z])/) { $1.upcase }
.gsub(/(^|\/)[a-z]/) { $&.upcase }
.gsub("/", "::")
end

#: String -> String
def underscore(string)
string
.gsub(/([a-z])([A-Z])/, "\\1_\\2")
.gsub("::", "/")
.downcase
end
end
end
end
134 changes: 124 additions & 10 deletions test/ruby_lsp_rails/definition_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -467,20 +467,134 @@ def name; end
assert_equal(15, response.range.end.character)
end

test "recognizes render calls" do
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html.erb")

uri = Kernel.URI("file://#{dummy_root}/app/views/users/render.html.erb")
source = <<~ERB
<%= render "partial" %>
<%= render "users/partial" %>
<%= render partial: "partial" %>
<%= render layout: "partial" %>
<%= render spacer_template: "partial" %>
<%= render template: "users/index" %>
ERB

with_ready_server(source, uri) do |server|
response = text_document_definition(server, { line: 0, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)

response = text_document_definition(server, { line: 1, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)

response = text_document_definition(server, { line: 2, character: 21 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)

response = text_document_definition(server, { line: 3, character: 20 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)

response = text_document_definition(server, { line: 4, character: 31 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)

response = text_document_definition(server, { line: 5, character: 23 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/index.html.erb", response.first.uri)
end
ensure
FileUtils.rm("#{dummy_root}/app/views/users/_partial.html.erb")
end

test "searches template directories of controller ancestors" do
FileUtils.mkdir_p("#{dummy_root}/app/views/application")
FileUtils.touch("#{dummy_root}/app/views/application/_partial.html.erb")

uri = Kernel.URI("file://#{dummy_root}/app/views/users/render.html.erb")
source = <<~ERB
<%= render "partial" %>
ERB

response = with_ready_server(source, uri) do |server|
server.global_state.index.index_file(URI::Generic.from_path(path: "#{dummy_root}/app/controllers/users_controller.rb"))
server.global_state.index.index_file(URI::Generic.from_path(path: "#{dummy_root}/app/controllers/application_controller.rb"))

text_document_definition(server, { line: 0, character: 12 }, uri)
end

assert_equal("file://#{dummy_root}/app/views/application/_partial.html.erb", response.first.uri)
ensure
FileUtils.rm_r("#{dummy_root}/app/views/application")
end

test "handles template formats, variants and handlers" do
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html.slim")
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html+tablet.slim")
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html+mobile.slim")
FileUtils.touch("#{dummy_root}/app/views/users/_partial.text.erb")

uri = Kernel.URI("file://#{dummy_root}/app/views/users/render.html.erb")
source = <<~ERB
<%= render "partial" %>
<%= render "partial", formats: :html %>
<%= render "partial", formats: [:text] %>
<%= render "partial", handlers: :slim %>
<%= render "partial", handlers: [:erb] %>
<%= render "partial", variants: :mobile %>
<%= render "partial", variants: [:tablet, :mobile] %>
<%= render "partial", variants: :missing %>
ERB

with_ready_server(source, uri) do |server|
response = text_document_definition(server, { line: 0, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)

response = text_document_definition(server, { line: 1, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)

response = text_document_definition(server, { line: 2, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.text.erb", response.first.uri)

response = text_document_definition(server, { line: 3, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)

response = text_document_definition(server, { line: 4, character: 12 }, uri)
assert_equal([], response)

response = text_document_definition(server, { line: 5, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html+mobile.slim", response.first.uri)

response = text_document_definition(server, { line: 6, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html+tablet.slim", response.first.uri)

response = text_document_definition(server, { line: 7, character: 12 }, uri)
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)
end
ensure
FileUtils.rm Dir["#{dummy_root}/app/views/users/_partial.*"]
end

private

def generate_definitions_for_source(source, position)
with_server(source) do |server, uri|
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
def generate_definitions_for_source(source, position, uri = Kernel.URI("file:///fake.rb"))
with_ready_server(source, uri) do |server|
text_document_definition(server, position, uri)
end
end

server.process_message(
id: 1,
method: "textDocument/definition",
params: { textDocument: { uri: uri }, position: position },
)
def text_document_definition(server, position, uri)
server.process_message(
id: 1,
method: "textDocument/definition",
params: { textDocument: { uri: uri }, position: position },
)

result = pop_result(server)
result.response
end

def with_ready_server(source, uri)
with_server(source, uri) do |server, uri|
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)

result = pop_result(server)
result.response
yield server
end
end
end
Expand Down
Loading