Skip to content

Commit a306d81

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

File tree

3 files changed

+218
-0
lines changed

3 files changed

+218
-0
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
sig { returns(T::Boolean) }
45+
def stopped?
46+
[@stdin, @stdout, @stderr].all?(&:closed?) && !@wait_thread.alive?
47+
end
48+
49+
private
50+
51+
sig { params(request: T.untyped, params: T.untyped).returns(T.untyped) }
52+
def make_request(request, params = nil)
53+
send_message(request, params)
54+
read_response
55+
end
56+
57+
sig { params(request: T.untyped, params: T.untyped).void }
58+
def send_message(request, params = nil)
59+
message = { method: request }
60+
message[:params] = params if params
61+
json = message.to_json
62+
63+
@stdin.write("Content-Length: #{json.length}\r\n\r\n", json)
64+
end
65+
66+
alias_method :send_notification, :send_message
67+
68+
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
69+
def read_response
70+
headers = @stdout.gets("\r\n\r\n")
71+
raw_response = @stdout.read(T.must(headers)[/Content-Length: (\d+)/i, 1].to_i)
72+
73+
response = JSON.parse(T.must(raw_response), symbolize_names: true)
74+
75+
if response[:error]
76+
warn("Ruby LSP Rails error: " + response[:error])
77+
return
78+
end
79+
80+
response.fetch(:result)
81+
end
82+
end
83+
end
84+
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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
assert_predicate @client, :stopped?
17+
end
18+
19+
test "#model returns information for the requested model" do
20+
# These columns are from the schema in the dummy app: test/dummy/db/schema.rb
21+
columns = [
22+
["id", "integer"],
23+
["first_name", "string"],
24+
["last_name", "string"],
25+
["age", "integer"],
26+
["created_at", "datetime"],
27+
["updated_at", "datetime"],
28+
]
29+
response = T.must(@client.model("User"))
30+
assert_equal(columns, response.fetch(:columns))
31+
assert_match(%r{db/schema\.rb$}, response.fetch(:schema_file))
32+
end
33+
34+
test "returns nil if model doesn't exist" do
35+
assert_nil @client.model("Foo")
36+
end
37+
38+
test "returns nil if class is not a model" do
39+
assert_nil @client.model("Time")
40+
end
41+
42+
test "returns nil if class is an abstract model" do
43+
assert_nil @client.model("ApplicationRecord")
44+
end
45+
end
46+
end
47+
end

0 commit comments

Comments
 (0)