Skip to content

Commit afc4f28

Browse files
committed
Add Rails runner client
1 parent 088312d commit afc4f28

File tree

3 files changed

+217
-0
lines changed

3 files changed

+217
-0
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "json"
5+
require "open3"
6+
7+
# NOTE: We should avoid printing to stderr since it causes problems. We never read the standard error pipe
8+
# from the client, so it will become full and eventually hang or crash.
9+
# Instead, return a response with an `error` key.
10+
11+
module RubyLsp
12+
module Rails
13+
class RunnerClient
14+
extend T::Sig
15+
16+
sig { void }
17+
def initialize
18+
stdin, stdout, stderr, wait_thread = Open3.popen3(
19+
"bin/rails",
20+
"runner",
21+
"#{__dir__}/server.rb",
22+
"start",
23+
)
24+
@stdin = T.let(stdin, IO)
25+
@stdout = T.let(stdout, IO)
26+
@stderr = T.let(stderr, IO)
27+
@wait_thread = T.let(wait_thread, Process::Waiter)
28+
@stdin.binmode # for Windows compatibility
29+
@stdout.binmode # for Windows compatibility
30+
end
31+
32+
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
33+
def model(name)
34+
make_request("model", name: name)
35+
end
36+
37+
sig { void }
38+
def shutdown
39+
send_notification("shutdown")
40+
Thread.pass while @wait_thread.alive?
41+
[@stdin, @stdout, @stderr].each(&:close)
42+
end
43+
44+
private
45+
46+
sig { params(request: T.untyped, params: T.untyped).returns(T.untyped) }
47+
def make_request(request, params = nil)
48+
send_message(request, params)
49+
read_response
50+
end
51+
52+
sig { params(request: T.untyped, params: T.untyped).void }
53+
def send_message(request, params = nil)
54+
message = { method: request }
55+
message[:params] = params if params
56+
json = message.to_json
57+
58+
@stdin.write("Content-Length: #{json.length}\r\n\r\n", json)
59+
end
60+
61+
alias_method :send_notification, :send_message
62+
63+
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
64+
def read_response
65+
headers = @stdout.gets("\r\n\r\n")
66+
raw_response = @stdout.read(T.must(headers)[/Content-Length: (\d+)/i, 1].to_i)
67+
68+
response = JSON.parse(T.must(raw_response), symbolize_names: true)
69+
70+
if response[:error]
71+
warn("Ruby LSP Rails error: " + response[:error])
72+
return
73+
end
74+
75+
response.fetch(:result)
76+
end
77+
end
78+
end
79+
end

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
require "json"
6+
7+
begin
8+
T::Configuration.default_checked_level = :never
9+
# Suppresses call validation errors
10+
T::Configuration.call_validation_error_handler = ->(*) {}
11+
# Suppresses errors caused by T.cast, T.let, T.must, etc.
12+
T::Configuration.inline_type_error_handler = ->(*) {}
13+
# Suppresses errors caused by incorrect parameter ordering
14+
T::Configuration.sig_validation_error_handler = ->(*) {}
15+
rescue
16+
# Need this rescue so that if another gem has
17+
# already set the checked level by the time we
18+
# get to it, we don't fail outright.
19+
nil
20+
end
21+
22+
module RubyLsp
23+
module Rails
24+
class Server
25+
VOID = Object.new
26+
27+
extend T::Sig
28+
29+
sig { params(model_name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
30+
def resolve_database_info_from_model(model_name)
31+
const = ActiveSupport::Inflector.safe_constantize(model_name)
32+
unless const && const < ActiveRecord::Base && !const.abstract_class?
33+
return {
34+
result: nil,
35+
}
36+
end
37+
38+
schema_file = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(const.connection.pool.db_config)
39+
40+
{
41+
result: {
42+
columns: const.columns.map { |column| [column.name, column.type] },
43+
schema_file: ::Rails.root + schema_file,
44+
},
45+
}
46+
rescue => e
47+
{
48+
error: e.message,
49+
}
50+
end
51+
52+
sig { void }
53+
def start
54+
$stdin.sync = true
55+
$stdout.sync = true
56+
57+
running = T.let(true, T::Boolean)
58+
59+
while running
60+
headers = $stdin.gets("\r\n\r\n")
61+
request = $stdin.read(headers[/Content-Length: (\d+)/i, 1].to_i)
62+
63+
json = JSON.parse(request, symbolize_names: true)
64+
request_method = json.fetch(:method)
65+
params = json[:params]
66+
67+
response = case request_method
68+
when "shutdown"
69+
running = false
70+
VOID
71+
when "model"
72+
resolve_database_info_from_model(params.fetch(:name))
73+
else
74+
VOID
75+
end
76+
77+
next if response == VOID
78+
79+
json_response = response.to_json
80+
$stdout.write("Content-Length: #{json_response.length}\r\n\r\n#{json_response}")
81+
end
82+
end
83+
end
84+
end
85+
end
86+
87+
RubyLsp::Rails::Server.new.start if ARGV.first == "start"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "test_helper"
5+
require "ruby_lsp/ruby_lsp_rails/runner_client"
6+
7+
module RubyLsp
8+
module Rails
9+
class RunnerClientTest < ActiveSupport::TestCase
10+
setup do
11+
@client = T.let(RunnerClient.new, RunnerClient)
12+
end
13+
14+
teardown do
15+
@client.shutdown
16+
17+
assert_predicate @client.instance_variable_get(:@stdin), :closed?
18+
assert_predicate @client.instance_variable_get(:@stdout), :closed?
19+
assert_predicate @client.instance_variable_get(:@stderr), :closed?
20+
refute_predicate @client.instance_variable_get(:@wait_thread), :alive?
21+
end
22+
23+
test "#model returns information for the requested model" do
24+
# These columns are from the schema in the dummy app: test/dummy/db/schema.rb
25+
columns = [
26+
["id", "integer"],
27+
["first_name", "string"],
28+
["last_name", "string"],
29+
["age", "integer"],
30+
["created_at", "datetime"],
31+
["updated_at", "datetime"],
32+
]
33+
response = T.must(@client.model("User"))
34+
assert_equal(columns, response.fetch(:columns))
35+
assert_match(%r{db/schema\.rb$}, response.fetch(:schema_file))
36+
end
37+
38+
test "returns nil if model doesn't exist" do
39+
assert_nil @client.model("Foo")
40+
end
41+
42+
test "returns nil if class is not a model" do
43+
assert_nil @client.model("Time")
44+
end
45+
46+
test "returns nil if class is an abstract model" do
47+
assert_nil @client.model("ApplicationRecord")
48+
end
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)