Skip to content

Commit

Permalink
Merge pull request #250 from Shopify/andyw8/add-runner-client
Browse files Browse the repository at this point in the history
Add Rails Runner client
  • Loading branch information
andyw8 authored Feb 13, 2024
2 parents 61550d5 + a306d81 commit a600e02
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 0 deletions.
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)
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?
[@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)

{
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"
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

0 comments on commit a600e02

Please sign in to comment.