Skip to content

Commit 1dd9370

Browse files
committed
Make template lookup more robust by calling server
Also add missing type annotations.
1 parent 9f7f5e9 commit 1dd9370

File tree

8 files changed

+266
-100
lines changed

8 files changed

+266
-100
lines changed

lib/ruby_lsp/ruby_lsp_rails/code_lens.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,14 @@ def add_jump_to_view(node)
194194
action_name = node.name
195195
controller_name = underscore(class_name.delete_suffix("Controller"))
196196

197-
view_uris = Dir.glob("#{@client.views_dir}/#{controller_name}/#{action_name}*").filter_map do |path|
197+
controller_info = @client.controller(class_name)
198+
return unless controller_info
199+
200+
view_paths = controller_info[:view_paths].select do |path|
201+
path.start_with?(@client.rails_root)
202+
end
203+
204+
view_uris = Dir.glob("{#{view_paths.join(",")}}/#{controller_name}/#{action_name}*").filter_map do |path|
198205
# it's possible we could have a directory with the same name as the action, so we need to skip those
199206
next if File.directory?(path)
200207

lib/ruby_lsp/ruby_lsp_rails/definition.rb

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

4-
require "pathname"
5-
64
module RubyLsp
75
module Rails
86
# ![Definition demo](../../definition.gif)
@@ -33,8 +31,8 @@ class Definition
3331
include Requests::Support::Common
3432
include Inflections
3533

36-
#: (RunnerClient client, RubyLsp::ResponseBuilders::CollectionResponseBuilder[(Interface::Location | Interface::LocationLink)] response_builder, NodeContext node_context, RubyIndexer::Index index, Prism::Dispatcher dispatcher) -> void
37-
def initialize(client, response_builder, uri, node_context, index, dispatcher)
34+
#: (RunnerClient client, RubyLsp::ResponseBuilders::CollectionResponseBuilder[(Interface::Location | Interface::LocationLink)] response_builder, URI::Generic uri, NodeContext node_context, RubyIndexer::Index index, Prism::Dispatcher dispatcher) -> void
35+
def initialize(client, response_builder, uri, node_context, index, dispatcher) # rubocop:disable Metrics/ParameterLists
3836
@client = client
3937
@response_builder = response_builder
4038
@path = uri.to_standardized_path #: String?
@@ -147,45 +145,37 @@ def handle_association(node)
147145
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
148146
end
149147

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

153152
call_node = @node_context.call_node
154153
return unless call_node
155154
return unless self_receiver?(call_node)
156155

157-
message = call_node.message
158-
return unless message == "render"
156+
return unless call_node.message == "render"
159157

160158
arguments = call_node.arguments&.arguments
161159
return unless arguments
162160

163161
argument = view_template_argument(arguments, node)
164162
return unless argument
165163

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(",")}}" : "*"
164+
controller_name = controller_for_template(@path)
165+
return unless controller_name
172166

173-
extension_pattern = "#{formats_pattern}#{variants_pattern}.#{handlers_pattern}"
167+
template_name = node.content
168+
template_details = view_template_details(arguments)
174169

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
170+
template = @client.find_template(
171+
controller_name: controller_name,
172+
template_name: template_name,
173+
partial: argument != "template",
174+
details: template_details,
175+
)
176+
return unless template
187177

188-
@response_builder << Support::LocationBuilder.line_location_from_s("#{template_path}:1")
178+
@response_builder << Support::LocationBuilder.line_location_from_s("#{template[:path]}:1")
189179
end
190180

191181
#: (Prism::CallNode node) -> void
@@ -241,48 +231,69 @@ def handle_if_unless_conditional(node, call_node, arguments)
241231
collect_definitions(method_name)
242232
end
243233

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

247-
kwargs = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
248-
return unless kwargs
238+
keyword_arguments = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) } #: as Prism::KeywordHashNode?
239+
return unless keyword_arguments
249240

250-
kwarg = kwargs.elements.find do |pair|
251-
["partial", "layout", "spacer_template", "template"].include?(pair.key.value) && pair.value == node
252-
end
241+
element = keyword_arguments.elements.find do |element|
242+
next unless element.is_a?(Prism::AssocNode)
243+
244+
key = element.key
245+
next unless key.is_a?(Prism::SymbolNode)
246+
247+
next unless element.value == node
253248

254-
kwarg&.key&.value
249+
["partial", "layout", "spacer_template", "template"].include?(key.value)
250+
end #: as Prism::AssocNode?
251+
252+
return unless element
253+
254+
key = element.key #: as Prism::SymbolNode
255+
key.value
255256
end
256257

257-
def view_template_options(arguments)
258-
kwargs = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
259-
return {} unless kwargs
258+
#: (Array[Prism::Node] arguments) -> Hash[String, (String | Array[String])]
259+
def view_template_details(arguments)
260+
keyword_arguments = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) } #: as Prism::KeywordHashNode?
261+
return {} unless keyword_arguments
262+
263+
keyword_arguments.elements.each_with_object({}) do |element, options|
264+
next unless element.is_a?(Prism::AssocNode)
265+
266+
key = element.key
267+
next unless key.is_a?(Prism::SymbolNode)
260268

261-
kwargs.elements.each_with_object({}) do |pair, options|
262-
next unless ["formats", "variants", "handlers"].include?(pair.key.value)
269+
key_value = key.value
270+
next unless ["formats", "variants", "handlers"].include?(key_value)
263271

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)
272+
value = element.value
266273

267-
options[pair.key.value.to_sym] = value
274+
if value.is_a?(Prism::SymbolNode)
275+
options[key_value] = value.value
276+
elsif value.is_a?(Prism::ArrayNode) && value.elements.all?(Prism::SymbolNode)
277+
elements = value.elements #: as Array[Prism::SymbolNode]
278+
options[key_value] = elements.map(&:value)
279+
end
268280
end
269281
end
270282

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
283+
#: (String template_path) -> String?
284+
def controller_for_template(template_path)
285+
controller_info = @client.controller("ActionController::Base")
286+
return unless controller_info
287+
288+
view_paths = controller_info[:view_paths]
289+
template_directory = File.dirname(template_path)
290+
291+
view_path = view_paths.find { |path| template_directory.start_with?(path + "/") }
292+
return unless view_path
293+
294+
controller_path = template_directory.delete_prefix(view_path + "/")
284295

285-
controller_ancestors.map { |ancestor| underscore(ancestor.delete_suffix("Controller")) }
296+
camelize(controller_path) + "Controller"
286297
end
287298
end
288299
end

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,34 @@ def association_target(model_name:, association_name:)
159159
nil
160160
end
161161

162+
#: (String name) -> Hash[Symbol, untyped]?
163+
def controller(name)
164+
make_request("controller", name: name)
165+
rescue MessageError
166+
log_message(
167+
"Ruby LSP Rails failed to get controller view paths",
168+
type: RubyLsp::Constant::MessageType::ERROR,
169+
)
170+
nil
171+
end
172+
173+
#: (controller_name: String, template_name: String, partial: bool, details: Hash[String, String | Array[String]]) -> Hash[Symbol, untyped]?
174+
def find_template(controller_name:, template_name:, partial:, details:)
175+
make_request(
176+
"find_template",
177+
controller_name: controller_name,
178+
template_name: template_name,
179+
partial: partial,
180+
details: details,
181+
)
182+
rescue MessageError
183+
log_message(
184+
"Ruby LSP Rails failed to find view template for controller",
185+
type: RubyLsp::Constant::MessageType::ERROR,
186+
)
187+
nil
188+
end
189+
162190
#: (String name) -> Hash[Symbol, untyped]?
163191
def route_location(name)
164192
make_request("route_location", name: name)
@@ -261,10 +289,6 @@ def connected?
261289
true
262290
end
263291

264-
def views_dir
265-
File.join(@rails_root, "app/views")
266-
end
267-
268292
private
269293

270294
#: (String request, **untyped params) -> Hash[Symbol, untyped]?

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,14 @@ def execute(request, params)
310310
with_request_error_handling(request) do
311311
send_result(resolve_association_target(params))
312312
end
313+
when "controller"
314+
with_request_error_handling(request) do
315+
send_result(controller_info(params.fetch(:name)))
316+
end
317+
when "find_template"
318+
with_request_error_handling(request) do
319+
send_result(lookup_view_template(params))
320+
end
313321
when "pending_migrations_message"
314322
with_request_error_handling(request) do
315323
send_result({ pending_migrations_message: pending_migrations_message })
@@ -436,6 +444,36 @@ def resolve_association_target(params)
436444
nil
437445
end
438446

447+
#: (String controller_name) -> Hash[Symbol | String, untyped]?
448+
def controller_info(controller_name)
449+
const = ActiveSupport::Inflector.safe_constantize(controller_name) # rubocop:disable Sorbet/ConstantsFromStrings
450+
return unless controller?(const)
451+
452+
{ view_paths: const.view_paths.map(&:to_s) }
453+
end
454+
455+
#: (Hash[Symbol | String, untyped]) -> Hash[Symbol | String, untyped]?
456+
def lookup_view_template(params)
457+
const = ActiveSupport::Inflector.safe_constantize(params[:controller_name]) # rubocop:disable Sorbet/ConstantsFromStrings
458+
return unless controller?(const)
459+
460+
path = params[:template_name]
461+
partial = params[:partial]
462+
details = params[:details].transform_values { |value| Array(value).map(&:to_sym) }
463+
464+
lookup_context = const.new.lookup_context
465+
prefixes = path.include?("/") || !partial ? [] : lookup_context.prefixes
466+
467+
# Disable the lookup cache to ensure template changes are reflected
468+
template = lookup_context.disable_cache do
469+
lookup_context.find_template(path, prefixes, partial, [], details)
470+
end
471+
472+
{ path: template.identifier }
473+
rescue ActionView::MissingTemplate
474+
nil
475+
end
476+
439477
#: (Module?) -> bool
440478
def active_record_model?(const)
441479
!!(
@@ -448,6 +486,16 @@ def active_record_model?(const)
448486
)
449487
end
450488

489+
#: (Module?) -> bool
490+
def controller?(const)
491+
!!(
492+
const &&
493+
defined?(ActionController) &&
494+
const.is_a?(Class) &&
495+
ActionController::Base >= const
496+
)
497+
end
498+
451499
#: -> String?
452500
def pending_migrations_message
453501
# `check_all_pending!` is only available since Rails 7.1

lib/ruby_lsp/ruby_lsp_rails/support/inflections.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
module RubyLsp
55
module Rails
66
module Inflections
7-
#: String -> String
7+
#: (String) -> String
88
def camelize(string)
99
string
10-
.gsub(/_([a-z])/) { $1.upcase }
11-
.gsub(/(^|\/)[a-z]/) { $&.upcase }
10+
.gsub(%r{(?:^|_|/)[a-z]}, &:upcase)
11+
.tr("_", "")
1212
.gsub("/", "::")
1313
end
1414

15-
#: String -> String
15+
#: (String) -> String
1616
def underscore(string)
1717
string
1818
.gsub(/([a-z])([A-Z])/, "\\1_\\2")

0 commit comments

Comments
 (0)