Skip to content

Commit 4e6b904

Browse files
committed
WIP
1 parent e815ef6 commit 4e6b904

File tree

8 files changed

+678
-583
lines changed

8 files changed

+678
-583
lines changed

Gemfile

+2
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ platforms :mingw, :x64_mingw, :mswin, :jruby do
2525
gem "tzinfo"
2626
gem "tzinfo-data"
2727
end
28+
29+
gem "ruby-lsp", github: "Shopify/ruby-lsp", branch: "add-on-client-server-framework"

Gemfile.lock

+12-11
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ GIT
66
rdoc (6.6.3.1)
77
psych (>= 4.0.0)
88

9+
GIT
10+
remote: https://github.com/Shopify/ruby-lsp.git
11+
revision: 6484963320dc6d225d2572adcb0c3aadc7598d40
12+
branch: add-on-client-server-framework
13+
specs:
14+
ruby-lsp (0.17.17)
15+
language_server-protocol (~> 3.17.0)
16+
prism (>= 0.29.0, < 0.31)
17+
rbs (>= 3, < 4)
18+
sorbet-runtime (>= 0.5.10782)
19+
920
PATH
1021
remote: .
1122
specs:
@@ -146,8 +157,6 @@ GEM
146157
nio4r (2.7.3)
147158
nokogiri (1.16.5-arm64-darwin)
148159
racc (~> 1.4)
149-
nokogiri (1.16.5-x64-mingw-ucrt)
150-
racc (~> 1.4)
151160
nokogiri (1.16.5-x86_64-darwin)
152161
racc (~> 1.4)
153162
nokogiri (1.16.5-x86_64-linux)
@@ -234,11 +243,6 @@ GEM
234243
rubocop (~> 1.51)
235244
rubocop-sorbet (0.8.3)
236245
rubocop (>= 0.90.0)
237-
ruby-lsp (0.17.12)
238-
language_server-protocol (~> 3.17.0)
239-
prism (>= 0.29.0, < 0.31)
240-
rbs (>= 3, < 4)
241-
sorbet-runtime (>= 0.5.10782)
242246
ruby-progressbar (1.13.0)
243247
ruby2_keywords (0.0.5)
244248
sorbet (0.5.11406)
@@ -255,7 +259,6 @@ GEM
255259
sorbet-static-and-runtime (>= 0.5.10187)
256260
thor (>= 0.19.2)
257261
sqlite3 (1.7.3-arm64-darwin)
258-
sqlite3 (1.7.3-x64-mingw-ucrt)
259262
sqlite3 (1.7.3-x86_64-darwin)
260263
sqlite3 (1.7.3-x86_64-linux)
261264
stringio (3.1.0)
@@ -273,8 +276,6 @@ GEM
273276
timeout (0.4.1)
274277
tzinfo (2.0.6)
275278
concurrent-ruby (~> 1.0)
276-
tzinfo-data (1.2024.1)
277-
tzinfo (>= 1.0.0)
278279
unicode-display_width (2.5.0)
279280
webmock (3.23.1)
280281
addressable (>= 2.8.0)
@@ -292,7 +293,6 @@ GEM
292293

293294
PLATFORMS
294295
arm64-darwin
295-
x64-mingw-ucrt
296296
x86_64-darwin
297297
x86_64-linux
298298

@@ -307,6 +307,7 @@ DEPENDENCIES
307307
rubocop-rake (~> 0.6.0)
308308
rubocop-shopify (~> 2.15)
309309
rubocop-sorbet (~> 0.8)
310+
ruby-lsp!
310311
ruby-lsp-rails!
311312
sorbet-static-and-runtime
312313
sqlite3 (< 2)

lib/ruby_lsp/ruby_lsp_rails/addon.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ def initialize
3232
sig { override.params(global_state: GlobalState, message_queue: Thread::Queue).void }
3333
def activate(global_state, message_queue)
3434
@global_state = T.let(global_state, T.nilable(RubyLsp::GlobalState))
35-
$stderr.puts("Activating Ruby LSP Rails addon v#{VERSION}")
35+
$stderr.puts("Activating Ruby LSP Rails addon v#{VERSION}") unless ENV["RAILS_ENV"] == "test"
3636
# Start booting the real client in a background thread. Until this completes, the client will be a NullClient
37-
Thread.new { @client = RunnerClient.create_client }
37+
Thread.new { @client = RunnerClient.create_client(self) }
3838
register_additional_file_watchers(global_state: global_state, message_queue: message_queue)
3939

4040
T.must(@global_state).index.register_enhancement(IndexingEnhancement.new)

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

+38-137
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@
33

44
require "json"
55
require "open3"
6+
require "ruby_lsp/addon/process_client"
67

78
module RubyLsp
89
module Rails
9-
class RunnerClient
10+
class RunnerClient < RubyLsp::Addon::ProcessClient
11+
COMMAND = T.let(["bundle", "exec", "rails", "runner", "#{__dir__}/server.rb", "start"].join(" "), String)
12+
1013
class << self
1114
extend T::Sig
1215

13-
sig { returns(RunnerClient) }
14-
def create_client
16+
sig { params(addon: RubyLsp::Addon).returns(RunnerClient) }
17+
def create_client(addon)
1518
if File.exist?("bin/rails")
16-
new
19+
new(addon, COMMAND)
1720
else
1821
$stderr.puts(<<~MSG)
1922
Ruby LSP Rails failed to locate bin/rails in the current directory: #{Dir.pwd}"
@@ -28,76 +31,44 @@ def create_client
2831
end
2932
end
3033

31-
class InitializationError < StandardError; end
32-
class IncompleteMessageError < StandardError; end
33-
class EmptyMessageError < StandardError; end
34-
35-
MAX_RETRIES = 5
36-
3734
extend T::Sig
3835

3936
sig { returns(String) }
40-
attr_reader :rails_root
41-
42-
sig { void }
43-
def initialize
44-
@mutex = T.let(Mutex.new, Mutex)
45-
# Spring needs a Process session ID. It uses this ID to "attach" itself to the parent process, so that when the
46-
# parent ends, the spring process ends as well. If this is not set, Spring will throw an error while trying to
47-
# set its own session ID
48-
begin
49-
Process.setpgrp
50-
Process.setsid
51-
rescue Errno::EPERM
52-
# If we can't set the session ID, continue
53-
rescue NotImplementedError
54-
# setpgrp() may be unimplemented on some platform
55-
# https://github.com/Shopify/ruby-lsp-rails/issues/348
56-
end
57-
58-
stdin, stdout, stderr, wait_thread = Bundler.with_original_env do
59-
Open3.popen3("bundle", "exec", "rails", "runner", "#{__dir__}/server.rb", "start")
60-
end
61-
62-
@stdin = T.let(stdin, IO)
63-
@stdout = T.let(stdout, IO)
64-
@stderr = T.let(stderr, IO)
65-
@wait_thread = T.let(wait_thread, Process::Waiter)
66-
@stdin.binmode # for Windows compatibility
67-
@stdout.binmode # for Windows compatibility
68-
69-
$stderr.puts("Ruby LSP Rails booting server")
70-
count = 0
37+
def rails_root
38+
T.must(@rails_root)
39+
end
7140

72-
begin
73-
count += 1
74-
initialize_response = T.must(read_response)
75-
@rails_root = T.let(initialize_response[:root], String)
76-
rescue EmptyMessageError
77-
$stderr.puts("Ruby LSP Rails is retrying initialize (#{count})")
78-
retry if count < MAX_RETRIES
41+
sig { params(message: String).void }
42+
def log_output(message)
43+
# We don't want to log output in tests
44+
unless ENV["RAILS_ENV"] == "test"
45+
super
7946
end
47+
end
8048

81-
$stderr.puts("Finished booting Ruby LSP Rails server")
49+
sig { override.params(response: T::Hash[Symbol, T.untyped]).void }
50+
def handle_initialize_response(response)
51+
@rails_root = T.let(response[:root], T.nilable(String))
52+
end
8253

54+
sig { override.void }
55+
def register_exit_handler
8356
unless ENV["RAILS_ENV"] == "test"
8457
at_exit do
85-
if @wait_thread.alive?
86-
$stderr.puts("Ruby LSP Rails is force killing the server")
58+
if wait_thread.alive?
59+
log_output("force killing the server")
8760
sleep(0.5) # give the server a bit of time if we already issued a shutdown notification
8861
force_kill
8962
end
9063
end
9164
end
92-
rescue Errno::EPIPE, IncompleteMessageError
93-
raise InitializationError, @stderr.read
9465
end
9566

9667
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
9768
def model(name)
9869
make_request("model", name: name)
9970
rescue IncompleteMessageError
100-
$stderr.puts("Ruby LSP Rails failed to get model information: #{@stderr.read}")
71+
log_output("failed to get model information: #{stderr.read}")
10172
nil
10273
end
10374

@@ -114,117 +85,47 @@ def association_target_location(model_name:, association_name:)
11485
association_name: association_name,
11586
)
11687
rescue => e
117-
$stderr.puts("Ruby LSP Rails failed with #{e.message}: #{@stderr.read}")
88+
log_output("failed with #{e.message}: #{stderr.read}")
89+
nil
11890
end
11991

12092
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
12193
def route_location(name)
12294
make_request("route_location", name: name)
12395
rescue IncompleteMessageError
124-
$stderr.puts("Ruby LSP Rails failed to get route location: #{@stderr.read}")
96+
log_output("failed to get route location: #{stderr.read}")
12597
nil
12698
end
12799

128100
sig { params(controller: String, action: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
129101
def route(controller:, action:)
130102
make_request("route_info", controller: controller, action: action)
131103
rescue IncompleteMessageError
132-
$stderr.puts("Ruby LSP Rails failed to get route information: #{@stderr.read}")
104+
log_output("failed to get route information: #{stderr.read}")
133105
nil
134106
end
135107

136108
sig { void }
137109
def trigger_reload
138-
$stderr.puts("Reloading Rails application")
110+
log_output("triggering reload")
139111
send_notification("reload")
140112
rescue IncompleteMessageError
141-
$stderr.puts("Ruby LSP Rails failed to trigger reload")
142-
nil
143-
end
144-
145-
sig { void }
146-
def shutdown
147-
$stderr.puts("Ruby LSP Rails shutting down server")
148-
send_message("shutdown")
149-
sleep(0.5) # give the server a bit of time to shutdown
150-
[@stdin, @stdout, @stderr].each(&:close)
151-
rescue IOError
152-
# The server connection may have died
153-
force_kill
154-
end
155-
156-
sig { returns(T::Boolean) }
157-
def stopped?
158-
[@stdin, @stdout, @stderr].all?(&:closed?) && !@wait_thread.alive?
159-
end
160-
161-
private
162-
163-
sig do
164-
params(
165-
request: String,
166-
params: T.nilable(T::Hash[Symbol, T.untyped]),
167-
).returns(T.nilable(T::Hash[Symbol, T.untyped]))
168-
end
169-
def make_request(request, params = nil)
170-
send_message(request, params)
171-
read_response
172-
end
173-
174-
sig { overridable.params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
175-
def send_message(request, params = nil)
176-
message = { method: request }
177-
message[:params] = params if params
178-
json = message.to_json
179-
180-
@mutex.synchronize do
181-
@stdin.write("Content-Length: #{json.length}\r\n\r\n", json)
182-
end
183-
rescue Errno::EPIPE
184-
# The server connection died
185-
end
186-
187-
# Notifications are like messages, but one-way, with no response sent back.
188-
sig { params(request: String, params: T.nilable(T::Hash[Symbol, T.untyped])).void }
189-
def send_notification(request, params = nil) = send_message(request, params)
190-
191-
sig { overridable.returns(T.nilable(T::Hash[Symbol, T.untyped])) }
192-
def read_response
193-
raw_response = @mutex.synchronize do
194-
headers = @stdout.gets("\r\n\r\n")
195-
raise IncompleteMessageError unless headers
196-
197-
content_length = headers[/Content-Length: (\d+)/i, 1].to_i
198-
raise EmptyMessageError if content_length.zero?
199-
200-
@stdout.read(content_length)
201-
end
202-
203-
response = JSON.parse(T.must(raw_response), symbolize_names: true)
204-
205-
if response[:error]
206-
$stderr.puts("Ruby LSP Rails error: " + response[:error])
207-
return
208-
end
209-
210-
response.fetch(:result)
211-
rescue Errno::EPIPE
212-
# The server connection died
113+
log_output("failed to trigger reload")
213114
nil
214115
end
215-
216-
sig { void }
217-
def force_kill
218-
# Windows does not support the `TERM` signal, so we're forced to use `KILL` here
219-
Process.kill(T.must(Signal.list["KILL"]), @wait_thread.pid)
220-
end
221116
end
222117

223118
class NullClient < RunnerClient
224119
extend T::Sig
225120

226121
sig { void }
227-
def initialize # rubocop:disable Lint/MissingSuper
122+
def initialize
123+
# no-op
124+
end
125+
126+
sig { override.params(response: T::Hash[Symbol, T.untyped]).void }
127+
def handle_initialize_response(response)
128+
# no-op
228129
end
229130

230131
sig { override.void }

0 commit comments

Comments
 (0)