Skip to content

Commit 9f7f5e9

Browse files
committed
Implement "go to definition" for render calls in ERB templates
It's common to want to traverse through several partials while updating HTML that a controller action renders. Rails.vim has a neat `gf` shortcut for this, though it probably doesn't have the precision that Prism would provide. This brings the same functionality to Ruby LSP Rails, by implementing "go to definition" support for render calls inside ERB templates. It supports partial name passed as positional argument, or via `:partial`, `:layout`, and `:spacer_template` keyword arguments. It even handles `:variants`, `:formats`, and `:handlers` options, as well as `:template` for rendering non-partial templates. Relative lookup will also check in view directories of controller ancestors. For the latter, I considered doing a call to the Rails process that will return `ActionController::Base._prefixes`. However, I couldn't think of a good enough interface, and that method ableit public is undocumented, so it seems like we shouldn't rely on it. Given that this ancestry lookup is non-configurable anyway, I chose to implement it in Ruby LSP land based on indexed controller files. To avoid the overhead of booting the Rails process too many times in tests, I updated the test helpers to allow sending multiple `textDocument/definition` requests to the same server.
1 parent 75d7a53 commit 9f7f5e9

File tree

6 files changed

+248
-18
lines changed

6 files changed

+248
-18
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require_relative "support/callbacks"
1010
require_relative "support/validations"
1111
require_relative "support/location_builder"
12+
require_relative "support/inflections"
1213
require_relative "runner_client"
1314
require_relative "hover"
1415
require_relative "code_lens"
@@ -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, uri, node_context, @global_state.index, dispatcher)
133134
end
134135

135136
# @override

lib/ruby_lsp/ruby_lsp_rails/code_lens.rb

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ module Rails
7373
#
7474
class CodeLens
7575
include Requests::Support::Common
76+
include Inflections
7677
include ActiveSupportTestCaseHelper
7778

7879
#: (RunnerClient, GlobalState, ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens], URI::Generic, Prism::Dispatcher) -> void
@@ -191,13 +192,9 @@ def controller?
191192
def add_jump_to_view(node)
192193
class_name = @constant_name_stack.map(&:first).join("::")
193194
action_name = node.name
194-
controller_name = class_name
195-
.delete_suffix("Controller")
196-
.gsub(/([a-z])([A-Z])/, "\\1_\\2")
197-
.gsub("::", "/")
198-
.downcase
195+
controller_name = underscore(class_name.delete_suffix("Controller"))
199196

200-
view_uris = Dir.glob("#{@client.rails_root}/app/views/#{controller_name}/#{action_name}*").filter_map do |path|
197+
view_uris = Dir.glob("#{@client.views_dir}/#{controller_name}/#{action_name}*").filter_map do |path|
201198
# it's possible we could have a directory with the same name as the action, so we need to skip those
202199
next if File.directory?(path)
203200

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# typed: strict
22
# frozen_string_literal: true
33

4+
require "pathname"
5+
46
module RubyLsp
57
module Rails
68
# ![Definition demo](../../definition.gif)
@@ -29,11 +31,13 @@ module Rails
2931
# - Changes to routes won't be picked up until the server is restarted.
3032
class Definition
3133
include Requests::Support::Common
34+
include Inflections
3235

3336
#: (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)
37+
def initialize(client, response_builder, uri, node_context, index, dispatcher)
3538
@client = client
3639
@response_builder = response_builder
40+
@path = uri.to_standardized_path #: String?
3741
@node_context = node_context
3842
@nesting = node_context.nesting #: Array[String]
3943
@index = index
@@ -49,6 +53,7 @@ def on_symbol_node_enter(node)
4953
#: (Prism::StringNode node) -> void
5054
def on_string_node_enter(node)
5155
handle_possible_dsl(node)
56+
handle_possible_render(node)
5257
end
5358

5459
#: (Prism::CallNode node) -> void
@@ -142,6 +147,47 @@ def handle_association(node)
142147
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
143148
end
144149

150+
def handle_possible_render(node)
151+
return unless @path&.end_with?(".html.erb")
152+
153+
call_node = @node_context.call_node
154+
return unless call_node
155+
return unless self_receiver?(call_node)
156+
157+
message = call_node.message
158+
return unless message == "render"
159+
160+
arguments = call_node.arguments&.arguments
161+
return unless arguments
162+
163+
argument = view_template_argument(arguments, node)
164+
return unless argument
165+
166+
template = node.content
167+
template_options = view_template_options(arguments)
168+
169+
formats_pattern = template_options[:formats] ? "{#{template_options[:formats].join(",")}}" : "html"
170+
variants_pattern = "{#{template_options[:variants].map { |variant| "+#{variant}" }.join(",")},}" if template_options[:variants]
171+
handlers_pattern = template_options[:handlers] ? "{#{template_options[:handlers].join(",")}}" : "*"
172+
173+
extension_pattern = "#{formats_pattern}#{variants_pattern}.#{handlers_pattern}"
174+
175+
template_pattern = if argument == "template"
176+
File.join(@client.views_dir, "#{template}.#{extension_pattern}")
177+
elsif template.include?("/")
178+
*partial_dir, partial_name = template.split("/")
179+
180+
File.join(@client.views_dir, *partial_dir, "_#{partial_name}.#{extension_pattern}")
181+
else
182+
File.join(@client.views_dir, "{#{view_prefixes.join(",")}}", "_#{template}.#{extension_pattern}")
183+
end
184+
185+
template_path = Dir.glob(template_pattern).first
186+
return unless template_path
187+
188+
@response_builder << Support::LocationBuilder.line_location_from_s("#{template_path}:1")
189+
end
190+
145191
#: (Prism::CallNode node) -> void
146192
def handle_route(node)
147193
result = @client.route_location(
@@ -194,6 +240,50 @@ def handle_if_unless_conditional(node, call_node, arguments)
194240

195241
collect_definitions(method_name)
196242
end
243+
244+
def view_template_argument(arguments, node)
245+
return "partial" if arguments.first == node
246+
247+
kwargs = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
248+
return unless kwargs
249+
250+
kwarg = kwargs.elements.find do |pair|
251+
["partial", "layout", "spacer_template", "template"].include?(pair.key.value) && pair.value == node
252+
end
253+
254+
kwarg&.key&.value
255+
end
256+
257+
def view_template_options(arguments)
258+
kwargs = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
259+
return {} unless kwargs
260+
261+
kwargs.elements.each_with_object({}) do |pair, options|
262+
next unless ["formats", "variants", "handlers"].include?(pair.key.value)
263+
264+
value = [pair.value.value] if pair.value.is_a?(Prism::SymbolNode)
265+
value = pair.value.elements.map(&:value) if pair.value.is_a?(Prism::ArrayNode)
266+
267+
options[pair.key.value.to_sym] = value
268+
end
269+
end
270+
271+
# Resolve available directories from which the controller can render relative
272+
# partials based on its ancestry chain.
273+
def view_prefixes
274+
controller_dir = Pathname(@path).dirname.relative_path_from(@client.views_dir).to_s
275+
controller_class = "#{camelize(controller_dir)}Controller"
276+
controller_ancestors = [controller_class]
277+
278+
controller_entry = @index.resolve(controller_class, [])&.find(&:parent_class)
279+
while controller_entry
280+
controller_entry = @index.resolve(controller_entry.parent_class, controller_entry.nesting)&.find(&:parent_class)
281+
break unless controller_entry && not_in_dependencies?(controller_entry.file_path)
282+
controller_ancestors << controller_entry.name
283+
end
284+
285+
controller_ancestors.map { |ancestor| underscore(ancestor.delete_suffix("Controller")) }
286+
end
197287
end
198288
end
199289
end

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ def connected?
261261
true
262262
end
263263

264+
def views_dir
265+
File.join(@rails_root, "app/views")
266+
end
267+
264268
private
265269

266270
#: (String request, **untyped params) -> Hash[Symbol, untyped]?
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Rails
6+
module Inflections
7+
#: String -> String
8+
def camelize(string)
9+
string
10+
.gsub(/_([a-z])/) { $1.upcase }
11+
.gsub(/(^|\/)[a-z]/) { $&.upcase }
12+
.gsub("/", "::")
13+
end
14+
15+
#: String -> String
16+
def underscore(string)
17+
string
18+
.gsub(/([a-z])([A-Z])/, "\\1_\\2")
19+
.gsub("::", "/")
20+
.downcase
21+
end
22+
end
23+
end
24+
end

test/ruby_lsp_rails/definition_test.rb

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

470+
test "recognizes render calls" do
471+
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html.erb")
472+
473+
uri = Kernel.URI("file://#{dummy_root}/app/views/users/render.html.erb")
474+
source = <<~ERB
475+
<%= render "partial" %>
476+
<%= render "users/partial" %>
477+
<%= render partial: "partial" %>
478+
<%= render layout: "partial" %>
479+
<%= render spacer_template: "partial" %>
480+
<%= render template: "users/index" %>
481+
ERB
482+
483+
with_ready_server(source, uri) do |server|
484+
response = text_document_definition(server, { line: 0, character: 12 }, uri)
485+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)
486+
487+
response = text_document_definition(server, { line: 1, character: 12 }, uri)
488+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)
489+
490+
response = text_document_definition(server, { line: 2, character: 21 }, uri)
491+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)
492+
493+
response = text_document_definition(server, { line: 3, character: 20 }, uri)
494+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)
495+
496+
response = text_document_definition(server, { line: 4, character: 31 }, uri)
497+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.erb", response.first.uri)
498+
499+
response = text_document_definition(server, { line: 5, character: 23 }, uri)
500+
assert_equal("file://#{dummy_root}/app/views/users/index.html.erb", response.first.uri)
501+
end
502+
ensure
503+
FileUtils.rm("#{dummy_root}/app/views/users/_partial.html.erb")
504+
end
505+
506+
test "searches template directories of controller ancestors" do
507+
FileUtils.mkdir_p("#{dummy_root}/app/views/application")
508+
FileUtils.touch("#{dummy_root}/app/views/application/_partial.html.erb")
509+
510+
uri = Kernel.URI("file://#{dummy_root}/app/views/users/render.html.erb")
511+
source = <<~ERB
512+
<%= render "partial" %>
513+
ERB
514+
515+
response = with_ready_server(source, uri) do |server|
516+
server.global_state.index.index_file(URI::Generic.from_path(path: "#{dummy_root}/app/controllers/users_controller.rb"))
517+
server.global_state.index.index_file(URI::Generic.from_path(path: "#{dummy_root}/app/controllers/application_controller.rb"))
518+
519+
text_document_definition(server, { line: 0, character: 12 }, uri)
520+
end
521+
522+
assert_equal("file://#{dummy_root}/app/views/application/_partial.html.erb", response.first.uri)
523+
ensure
524+
FileUtils.rm_r("#{dummy_root}/app/views/application")
525+
end
526+
527+
test "handles template formats, variants and handlers" do
528+
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html.slim")
529+
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html+tablet.slim")
530+
FileUtils.touch("#{dummy_root}/app/views/users/_partial.html+mobile.slim")
531+
FileUtils.touch("#{dummy_root}/app/views/users/_partial.text.erb")
532+
533+
uri = Kernel.URI("file://#{dummy_root}/app/views/users/render.html.erb")
534+
source = <<~ERB
535+
<%= render "partial" %>
536+
<%= render "partial", formats: :html %>
537+
<%= render "partial", formats: [:text] %>
538+
<%= render "partial", handlers: :slim %>
539+
<%= render "partial", handlers: [:erb] %>
540+
<%= render "partial", variants: :mobile %>
541+
<%= render "partial", variants: [:tablet, :mobile] %>
542+
<%= render "partial", variants: :missing %>
543+
ERB
544+
545+
with_ready_server(source, uri) do |server|
546+
response = text_document_definition(server, { line: 0, character: 12 }, uri)
547+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)
548+
549+
response = text_document_definition(server, { line: 1, character: 12 }, uri)
550+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)
551+
552+
response = text_document_definition(server, { line: 2, character: 12 }, uri)
553+
assert_equal("file://#{dummy_root}/app/views/users/_partial.text.erb", response.first.uri)
554+
555+
response = text_document_definition(server, { line: 3, character: 12 }, uri)
556+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)
557+
558+
response = text_document_definition(server, { line: 4, character: 12 }, uri)
559+
assert_equal([], response)
560+
561+
response = text_document_definition(server, { line: 5, character: 12 }, uri)
562+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html+mobile.slim", response.first.uri)
563+
564+
response = text_document_definition(server, { line: 6, character: 12 }, uri)
565+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html+tablet.slim", response.first.uri)
566+
567+
response = text_document_definition(server, { line: 7, character: 12 }, uri)
568+
assert_equal("file://#{dummy_root}/app/views/users/_partial.html.slim", response.first.uri)
569+
end
570+
ensure
571+
FileUtils.rm Dir["#{dummy_root}/app/views/users/_partial.*"]
572+
end
573+
470574
private
471575

472-
def generate_definitions_for_source(source, position)
473-
with_server(source) do |server, uri|
474-
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
576+
def generate_definitions_for_source(source, position, uri = Kernel.URI("file:///fake.rb"))
577+
with_ready_server(source, uri) do |server|
578+
text_document_definition(server, position, uri)
579+
end
580+
end
475581

476-
server.process_message(
477-
id: 1,
478-
method: "textDocument/definition",
479-
params: { textDocument: { uri: uri }, position: position },
480-
)
582+
def text_document_definition(server, position, uri)
583+
server.process_message(
584+
id: 1,
585+
method: "textDocument/definition",
586+
params: { textDocument: { uri: uri }, position: position },
587+
)
588+
589+
result = pop_result(server)
590+
result.response
591+
end
592+
593+
def with_ready_server(source, uri)
594+
with_server(source, uri) do |server, uri|
595+
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
481596

482-
result = pop_result(server)
483-
result.response
597+
yield server
484598
end
485599
end
486600
end

0 commit comments

Comments
 (0)