Skip to content

Add Rails Runner client #250

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
84 changes: 84 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/runner_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# typed: strict
# frozen_string_literal: true

require "json"
require "open3"

# NOTE: We should avoid printing to stderr since it causes problems. We never read the standard error pipe
# from the client, so it will become full and eventually hang or crash.
# Instead, return a response with an `error` key.

module RubyLsp
module Rails
class RunnerClient
extend T::Sig

sig { void }
def initialize
stdin, stdout, stderr, wait_thread = Open3.popen3(
"bin/rails",
"runner",
"#{__dir__}/server.rb",
"start",
)
@stdin = T.let(stdin, IO)
@stdout = T.let(stdout, IO)
@stderr = T.let(stderr, IO)
@wait_thread = T.let(wait_thread, Process::Waiter)
@stdin.binmode # for Windows compatibility
@stdout.binmode # for Windows compatibility
end

sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def model(name)
make_request("model", name: name)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The param was called models in the Rack app, which matched up with the RESTful route name, but here I think model is more appropriate.

end

sig { void }
def shutdown
send_notification("shutdown")
Thread.pass while @wait_thread.alive?
[@stdin, @stdout, @stderr].each(&:close)
end

sig { returns(T::Boolean) }
def stopped?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To avoid the test having to poke inside with instance_var_get.

[@stdin, @stdout, @stderr].all?(&:closed?) && !@wait_thread.alive?
end

private

sig { params(request: T.untyped, params: T.untyped).returns(T.untyped) }
def make_request(request, params = nil)
send_message(request, params)
read_response
end

sig { params(request: T.untyped, params: T.untyped).void }
def send_message(request, params = nil)
message = { method: request }
message[:params] = params if params
json = message.to_json

@stdin.write("Content-Length: #{json.length}\r\n\r\n", json)
end

alias_method :send_notification, :send_message

sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def read_response
headers = @stdout.gets("\r\n\r\n")
raw_response = @stdout.read(T.must(headers)[/Content-Length: (\d+)/i, 1].to_i)

response = JSON.parse(T.must(raw_response), symbolize_names: true)

if response[:error]
warn("Ruby LSP Rails error: " + response[:error])
return
end

response.fetch(:result)
end
end
end
end
87 changes: 87 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/server.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
require "json"

begin
T::Configuration.default_checked_level = :never
# Suppresses call validation errors
T::Configuration.call_validation_error_handler = ->(*) {}
# Suppresses errors caused by T.cast, T.let, T.must, etc.
T::Configuration.inline_type_error_handler = ->(*) {}
# Suppresses errors caused by incorrect parameter ordering
T::Configuration.sig_validation_error_handler = ->(*) {}
rescue
# Need this rescue so that if another gem has
# already set the checked level by the time we
# get to it, we don't fail outright.
nil
end

module RubyLsp
module Rails
class Server
VOID = Object.new

extend T::Sig

sig { params(model_name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def resolve_database_info_from_model(model_name)
const = ActiveSupport::Inflector.safe_constantize(model_name)
unless const && const < ActiveRecord::Base && !const.abstract_class?
return {
result: nil,
}
end

schema_file = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(const.connection.pool.db_config)
Copy link

@D-system D-system Feb 19, 2024

Choose a reason for hiding this comment

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

This line break the compatibility with ActiveRecord 6

schema_dump_path is not present in ActiveRecord 6.1: https://github.com/rails/rails/blob/v6.1.7.6/activerecord/lib/active_record/tasks/database_tasks.rb

schema_dump_path is available from 7.0: https://github.com/rails/rails/blob/v7.0.0/activerecord/lib/active_record/tasks/database_tasks.rb#L448

[EDIT: open an issue #260]


{
result: {
columns: const.columns.map { |column| [column.name, column.type] },
schema_file: ::Rails.root + schema_file,
},
}
rescue => e
{
error: e.message,
}
end

sig { void }
def start
$stdin.sync = true
$stdout.sync = true

running = T.let(true, T::Boolean)

while running
headers = $stdin.gets("\r\n\r\n")
request = $stdin.read(headers[/Content-Length: (\d+)/i, 1].to_i)

json = JSON.parse(request, symbolize_names: true)
request_method = json.fetch(:method)
params = json[:params]

response = case request_method
when "shutdown"
running = false
VOID
when "model"
resolve_database_info_from_model(params.fetch(:name))
else
VOID
end

next if response == VOID

json_response = response.to_json
$stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
end
end
end
end
end

RubyLsp::Rails::Server.new.start if ARGV.first == "start"
Copy link
Member

Choose a reason for hiding this comment

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

Do we need this argument? This script is always invoked from the runner, so would there be a case where we wouldn't want to start it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can revisit this, but currently without it the check_docs task will hang because it requires each file.

Copy link
Member

Choose a reason for hiding this comment

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

I see. We can probably ignore this file in check_docs.

47 changes: 47 additions & 0 deletions test/ruby_lsp_rails/runner_client_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# typed: true
# frozen_string_literal: true

require "test_helper"
require "ruby_lsp/ruby_lsp_rails/runner_client"

module RubyLsp
module Rails
class RunnerClientTest < ActiveSupport::TestCase
setup do
@client = T.let(RunnerClient.new, RunnerClient)
end

teardown do
@client.shutdown
assert_predicate @client, :stopped?
end

test "#model returns information for the requested model" do
# These columns are from the schema in the dummy app: test/dummy/db/schema.rb
columns = [
["id", "integer"],
["first_name", "string"],
["last_name", "string"],
["age", "integer"],
["created_at", "datetime"],
["updated_at", "datetime"],
]
response = T.must(@client.model("User"))
assert_equal(columns, response.fetch(:columns))
assert_match(%r{db/schema\.rb$}, response.fetch(:schema_file))
end

test "returns nil if model doesn't exist" do
assert_nil @client.model("Foo")
end

test "returns nil if class is not a model" do
assert_nil @client.model("Time")
end

test "returns nil if class is an abstract model" do
assert_nil @client.model("ApplicationRecord")
end
end
end
end