Skip to content
9 changes: 8 additions & 1 deletion lib/ruby_lsp/ruby_lsp_rails/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "ruby_lsp/addon"

require_relative "rails_client"
require_relative "schema_collector"
require_relative "hover"
require_relative "code_lens"

Expand All @@ -20,6 +21,7 @@ def client
sig { override.params(message_queue: Thread::Queue).void }
def activate(message_queue)
client.check_if_server_is_running!
schema_collector.parse_schema
end

sig { override.void }
Expand All @@ -44,13 +46,18 @@ def create_code_lens_listener(uri, dispatcher)
).returns(T.nilable(Listener[T.nilable(Interface::Hover)]))
end
def create_hover_listener(nesting, index, dispatcher)
Hover.new(client, nesting, index, dispatcher)
Hover.new(client, schema_collector, nesting, index, dispatcher)
end

sig { override.returns(String) }
def name
"Ruby LSP Rails"
end

sig { returns(SchemaCollector) }
def schema_collector
@schema_collector ||= T.let(SchemaCollector.new, T.nilable(SchemaCollector))
end
end
end
end
16 changes: 14 additions & 2 deletions lib/ruby_lsp/ruby_lsp_rails/hover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,18 @@ class Hover < ::RubyLsp::Listener
sig do
params(
client: RailsClient,
schema_collector: SchemaCollector,
nesting: T::Array[String],
index: RubyIndexer::Index,
dispatcher: Prism::Dispatcher,
).void
end
def initialize(client, nesting, index, dispatcher)
def initialize(client, schema_collector, nesting, index, dispatcher)
super(dispatcher)

@_response = T.let(nil, ResponseType)
@client = client
@schema_collector = schema_collector
@nesting = nesting
@index = index
dispatcher.register(self, :on_constant_path_node_enter, :on_constant_read_node_enter, :on_call_node_enter)
Expand Down Expand Up @@ -91,8 +93,18 @@ def generate_column_content(name)
return if model.nil?

schema_file = model[:schema_file]
if schema_file
location = @schema_collector.tables[model[:schema_table]]
fragment = "L#{location.start_line},#{location.start_column}-"\
"#{location.end_line},#{location.end_column}" if location
schema_uri = URI::Generic.build(
scheme: "file",
path: schema_file,
fragment: fragment
)
end
content = +""
content << "[Schema](#{URI::Generic.build(scheme: "file", path: schema_file)})\n\n" if schema_file
content << "[Schema](#{schema_uri})\n\n" if schema_uri
content << model[:columns].map { |name, type| "**#{name}**: #{type}\n" }.join("\n")
content
end
Expand Down
53 changes: 53 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/schema_collector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Rails
class SchemaCollector < Prism::Visitor
extend T::Sig
extend T::Generic
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably doesn't need to be generic.


sig { returns(T::Hash[String, Prism::Location]) }
attr_reader :tables

sig { void }
def initialize
@tables = {}

super
end

sig { void }
def parse_schema
parse_result = Prism::parse_file(schema_path)
return unless parse_result.success?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this check if necessary. The schema.rb file is generated, so normally it would always be valid Ruby. But in addition to that, Prism is error tolerant, so even if it cannot parse the entire file, it will still return a partial AST, which we can use to find tables.


parse_result.value.accept(self)
end

sig { params(node: Prism::CallNode).void }
def visit_call_node(node)
if node.message == 'create_table'
first_argument = node.arguments&.arguments&.first

if first_argument&.is_a?(Prism::StringNode)
@tables[first_argument.content] = node.location
end
end

super
end

private

sig { returns(String) }
def schema_path
project_root = T.let(
Bundler.with_unbundled_env { Bundler.default_gemfile }.dirname,
Pathname,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be passing this as an argument from client. Otherwise, we end up with the same information in two separate places, which is likely to get out of sync.

project_root.join('db', 'schema.rb').to_s
end
end
end
end
1 change: 1 addition & 0 deletions lib/ruby_lsp_rails/rack_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def resolve_database_info_from_model(model_name)
body = JSON.dump({
columns: const.columns.map { |column| [column.name, column.type] },
schema_file: schema_file,
schema_table: const.table_name
})

[200, { "Content-Type" => "application/json" }, [body]]
Expand Down
1 change: 1 addition & 0 deletions test/ruby_lsp_rails/rack_app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class RackAppTest < ActionDispatch::IntegrationTest
["created_at", "datetime"],
["updated_at", "datetime"],
],
"schema_table" => "users"
},
JSON.parse(response.body),
)
Expand Down
27 changes: 27 additions & 0 deletions test/ruby_lsp_rails/schema_collector_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# typed: true
# frozen_string_literal: true

require "test_helper"

SCHEMA_FILE = <<~RUBY
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this into SchemaCollectorTest so that it's not defined globally.

ActiveRecord::Schema[7.1].define(version: 2023_12_09_114241) do
create_table "cats", force: :cascade do |t|
end

create_table "dogs", force: :cascade do |t|
end
end
RUBY

module RubyLsp
module Rails
class SchemaCollectorTest < ActiveSupport::TestCase
test "store locations of models by parsing create_table calls" do
collector = RubyLsp::Rails::SchemaCollector.new
Prism.parse(SCHEMA_FILE).value.accept(collector)

assert_equal(['cats', 'dogs'], collector.tables.keys)
end
end
end
end