Skip to content

Commit

Permalink
Add code lens for mapping actions to routes
Browse files Browse the repository at this point in the history
Adds code lens to find routes in the routes file based on a given
controller action. You need to grep bin/rails routes for this
information manually. If Rails already knows this info, we should make
it more easily accessible.
  • Loading branch information
gmcgibbon committed Feb 8, 2024
1 parent 89967af commit b5aace7
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 30 deletions.
2 changes: 1 addition & 1 deletion lib/ruby_lsp/ruby_lsp_rails/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def deactivate; end
).returns(T.nilable(Listener[T::Array[Interface::CodeLens]]))
end
def create_code_lens_listener(uri, dispatcher)
CodeLens.new(uri, dispatcher)
CodeLens.new(client, uri, dispatcher)
end

sig do
Expand Down
54 changes: 52 additions & 2 deletions lib/ruby_lsp/ruby_lsp_rails/code_lens.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ class CodeLens < ::RubyLsp::Listener
sig { override.returns(ResponseType) }
attr_reader :_response

sig { params(uri: URI::Generic, dispatcher: Prism::Dispatcher).void }
def initialize(uri, dispatcher)
sig { params(client: RailsClient, uri: URI::Generic, dispatcher: Prism::Dispatcher).void }
def initialize(client, uri, dispatcher)
@_response = T.let([], ResponseType)
@path = T.let(uri.to_standardized_path, T.nilable(String))
@group_id = T.let(1, Integer)
@group_id_stack = T.let([], T::Array[Integer])
@client = client

dispatcher.register(self, :on_call_node_enter, :on_class_node_enter, :on_def_node_enter, :on_class_node_leave)

Expand Down Expand Up @@ -96,11 +97,17 @@ def on_def_node_enter(node)
sig { params(node: Prism::ClassNode).void }
def on_class_node_enter(node)
class_name = node.constant_path.slice
superclass_name = node.superclass&.slice

if class_name.end_with?("Test")
command = "#{BASE_COMMAND} #{@path}"
add_test_code_lens(node, name: class_name, command: command, kind: :group)
end

if class_name.end_with?("Controller") && superclass_name&.end_with?("Controller")
add_route_code_lenses_to_actions(node)
end

@group_id_stack.push(@group_id)
@group_id += 1
end
Expand All @@ -112,6 +119,49 @@ def on_class_node_leave(node)

private

sig { params(node: Prism::ClassNode).void }
def add_route_code_lenses_to_actions(node)
public_method_nodes = T.must(node.body).child_nodes.take_while do |node|
!node.is_a?(Prism::CallNode) || ![:protected, :private].include?(node.name)
end
public_method_nodes.each do |public_method_node|
public_method_node = T.cast(public_method_node, Prism::DefNode)
add_route_code_lense_to_action(public_method_node, class_node: node)
end
end

sig { params(node: Prism::DefNode, class_node: Prism::ClassNode).void }
def add_route_code_lense_to_action(node, class_node:)
route = @client.route(
controller: class_node.constant_path.slice,
action: node.name.to_s,
)

if route
path = route.dig(:path)
verb = route.dig(:verb)
source_location = route.dig(:source_location)

arguments = [
source_location,
{
start_line: node.location.start_line - 1,
start_column: node.location.start_column,
end_line: node.location.end_line - 1,
end_column: node.location.end_column,
},
]

@_response << create_code_lens(
node,
title: [verb, path].join(" "),
command_name: "rubyLsp.openFile",
arguments: arguments,
data: { type: "file" },
)
end
end

sig { params(node: Prism::Node, name: String, command: String, kind: Symbol).void }
def add_test_code_lens(node, name:, command:, kind:)
return unless @path
Expand Down
73 changes: 58 additions & 15 deletions lib/ruby_lsp/ruby_lsp_rails/rails_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,52 @@ class ServerAddressUnknown < StandardError; end

extend T::Sig

SERVER_NOT_FOUND_ERRORS = T.let(
[
Errno::ECONNREFUSED,
Errno::EADDRNOTAVAIL,
Errno::ECONNRESET,
ServerAddressUnknown,
],
T::Array[T.class_of(Exception)],
)

SERVER_TIMEOUT_ERRORS = T.let(
[
Net::ReadTimeout,
Net::OpenTimeout,
],
T::Array[T.class_of(Exception)],
)

SERVER_UNAVAILABLE_ERRORS = T.let(
[
*SERVER_NOT_FOUND_ERRORS, *SERVER_TIMEOUT_ERRORS,
],
T::Array[T.class_of(Exception)],
)

SERVER_NOT_RUNNING_MESSAGE = "Rails server is not running. " \
"To get Rails features in the editor, boot the Rails server"

sig { returns(Pathname) }
attr_reader :root
class << self
extend T::Sig

sig { returns(Pathname) }
def root
@root ||= T.let(
Bundler.with_unbundled_env { Bundler.default_gemfile }.dirname,
T.nilable(Pathname),
)
end

sig { params(root: Pathname).void }
attr_writer :root
end

sig { void }
def initialize
project_root = T.let(Bundler.with_unbundled_env { Bundler.default_gemfile }.dirname, Pathname)
dummy_path = project_root.join("test", "dummy")

@root = T.let(dummy_path.exist? ? dummy_path : project_root, Pathname)
app_uri_path = @root.join("tmp", "app_uri.txt")
app_uri_path = root.join("tmp", "app_uri.txt")

if app_uri_path.exist?
url = URI(app_uri_path.read.chomp)
Expand All @@ -42,25 +75,35 @@ def model(name)
return unless response.code == "200"

JSON.parse(response.body.chomp, symbolize_names: true)
rescue Errno::ECONNREFUSED,
Errno::EADDRNOTAVAIL,
Errno::ECONNRESET,
Net::ReadTimeout,
Net::OpenTimeout,
ServerAddressUnknown
rescue *SERVER_UNAVAILABLE_ERRORS
nil
end

sig { params(controller: String, action: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def route(controller:, action:)
response = request("route?controller=#{controller}&action=#{action}")
return unless response.code == "200"

JSON.parse(response.body.chomp, symbolize_names: true)
rescue *SERVER_UNAVAILABLE_ERRORS
nil
end

sig { void }
def check_if_server_is_running!
request("activate", 0.2)
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::ECONNRESET, ServerAddressUnknown
rescue *SERVER_NOT_FOUND_ERRORS
warn(SERVER_NOT_RUNNING_MESSAGE)
rescue Net::ReadTimeout, Net::OpenTimeout
rescue *SERVER_TIMEOUT_ERRORS
# If the server is running, but the initial request is taking too long, we don't want to block the
# initialization of the Ruby LSP
end

sig { returns(Pathname) }
def root
self.class.root
end

private

sig { params(path: String, timeout: T.nilable(Float)).returns(Net::HTTPResponse) }
Expand Down
24 changes: 24 additions & 0 deletions lib/ruby_lsp_rails/rack_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,37 @@ def call(env)
[200, { "Content-Type" => "application/json" }, []]
when "models"
resolve_database_info_from_model(argument)
when "route"
resolve_route_info(**request.query_parameters.symbolize_keys)
else
not_found
end
end

private

sig { params(requirements: T::Hash[Symbol, String]).returns(T::Array[T.untyped]) }
def resolve_route_info(requirements)
if requirements[:controller]
requirements[:controller] = T.unsafe(requirements[:controller]).underscore.delete_suffix("_controller")
end
## TODO: Upstream a better way to find route info from params.
route = ::Rails.application.routes.routes.find { |route| route.requirements == requirements }

if route&.source_location
file, _, line = route.source_location.rpartition(":")
body = JSON.dump({
source_location: [File.absolute_path(file), line],
verb: route.verb,
path: route.path.spec.to_s,
})

[200, { "Content-Type" => "application/json" }, [body]]
else
not_found
end
end

sig { params(model_name: String).returns(T::Array[T.untyped]) }
def resolve_database_info_from_model(model_name)
const = ActiveSupport::Inflector.safe_constantize(model_name)
Expand Down
6 changes: 6 additions & 0 deletions test/dummy/app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# typed: true
# frozen_string_literal: true

class UsersController < ApplicationController
def index; end
end
2 changes: 1 addition & 1 deletion test/dummy/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
config.cache_store = :null_store

# Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = false
config.action_dispatch.show_exceptions = :none

# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
Expand Down
4 changes: 4 additions & 0 deletions test/dummy/config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# typed: true
# frozen_string_literal: true

# The mapper doesn't draw routes in test mode without this.
ActionDispatch::Routing::Mapper.route_source_locations = true

Rails.application.routes.draw do
resources :users, only: :index
end
24 changes: 24 additions & 0 deletions test/ruby_lsp_rails/code_lens_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ module RubyLsp
module Rails
class CodeLensTest < ActiveSupport::TestCase
setup do
File.write("#{Dir.pwd}/test/dummy/tmp/app_uri.txt", "http://localhost:3000")
@client = RailsClient.new
@message_queue = Thread::Queue.new
end

Expand Down Expand Up @@ -122,6 +124,28 @@ def test_example
assert_match("Debug", response[5].command.title)
end

test "recognizes controller actions" do
expected_response = {
source_location: ["#{@client.root}/config/routes.rb", 3],
verb: "GET",
path: "/users(.:format)",
}

stub_http_request("200", expected_response.to_json)
@client.stubs(:check_if_server_is_running!)

response = generate_code_lens_for_source(<<~RUBY)
class MyController < ApplicationController
def my_action
end
end
RUBY

assert_equal(1, response.size)
assert_match("GET /users(.:format)", response[0].command.title)
assert_equal(["#{@client.root}/config/routes.rb", 3], response[0].command.arguments[0])
end

test "assigns the correct hierarchy to test structure" do
response = generate_code_lens_for_source(<<~RUBY)
class Test < ActiveSupport::TestCase
Expand Down
27 changes: 23 additions & 4 deletions test/ruby_lsp_rails/rack_app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
module RubyLsp
module Rails
class RackAppTest < ActionDispatch::IntegrationTest
test "GET show returns column information for existing models" do
test "GET model route returns column information for existing models" do
get "/ruby_lsp_rails/models/User"
assert_response(:success)
assert_equal(
{
"schema_file" => "#{RailsClient.new.root}/db/schema.rb",
"schema_file" => "#{RailsClient.root}/db/schema.rb",
"columns" => [
["id", "integer"],
["first_name", "string"],
Expand All @@ -25,12 +25,12 @@ class RackAppTest < ActionDispatch::IntegrationTest
)
end

test "GET show returns not_found if model doesn't exist" do
test "GET model route returns not_found if model doesn't exist" do
get "/ruby_lsp_rails/models/Foo"
assert_response(:not_found)
end

test "GET show returns not_found if class is not a model" do
test "GET model route returns not_found if class is not a model" do
get "/ruby_lsp_rails/models/Time"
assert_response(:not_found)
end
Expand All @@ -40,6 +40,25 @@ class RackAppTest < ActionDispatch::IntegrationTest
assert_response(:not_found)
end

test "GET route route returns info on a given route" do
get "/ruby_lsp_rails/route?controller=UsersController&action=index"
assert_response(:success)

assert_equal(
{
"source_location" => ["#{ROOT}/config/routes.rb", "8"],
"verb" => "GET",
"path" => "/users(.:format)",
},
JSON.parse(response.body),
)
end

test "GET route route returns not found when route cannot be found" do
get "/ruby_lsp_rails/route?controller=UsersController&action=show"
assert_response(:not_found)
end

test "GET activate returns success to display that server is running" do
get "/ruby_lsp_rails/activate"
assert_response(:success)
Expand Down
Loading

0 comments on commit b5aace7

Please sign in to comment.