Skip to content

Commit

Permalink
Allow server to send asynchronous notifications to client
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Jan 28, 2025
1 parent a1f3cc1 commit af129b9
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 23 deletions.
27 changes: 24 additions & 3 deletions lib/ruby_lsp/ruby_lsp_rails/runner_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,16 @@ def initialize(outgoing_queue)
end
end

@logger_thread = T.let(
# Responsible for transmitting notifications coming from the server to the outgoing queue, so that we can do
# things such as showing progress notifications initiated by the server
@notifier_thread = T.let(
Thread.new do
while (content = @stderr.gets("\n"))
log_message(content, type: RubyLsp::Constant::MessageType::LOG)
until @stderr.closed?
notification = read_notification

unless @outgoing_queue.closed? || !notification
@outgoing_queue << notification
end
end
rescue IOError
# The server was shutdown and stderr is already closed
Expand Down Expand Up @@ -338,6 +344,21 @@ def read_content_length

length.to_i
end

# Read a server notification from stderr. Only intended to be used by notifier thread
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def read_notification
headers = @stderr.gets("\r\n\r\n")
return unless headers

length = headers[/Content-Length: (\d+)/i, 1]
return unless length

raw_content = @stderr.read(length.to_i)
return unless raw_content

JSON.parse(raw_content, symbolize_names: true)
end
end

class NullClient < RunnerClient
Expand Down
37 changes: 23 additions & 14 deletions lib/ruby_lsp/ruby_lsp_rails/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,9 @@
module RubyLsp
module Rails
module Common
# Write a message to the client. Can be used for sending notifications to the editor
def send_message(message)
json_message = message.to_json
@stdout.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}")
end

# Log a message to the editor's output panel
def log_message(message)
$stderr.puts(message)
def log_message(message, type: 4)
send_notification({ method: "window/logMessage", params: { type: type, message: message } })
end

# Sends an error result to a request, if the request failed. DO NOT INVOKE THIS METHOD FOR NOTIFICATIONS! Use
Expand Down Expand Up @@ -54,6 +48,20 @@ def with_notification_error_handling(notification_name, &block)
rescue => e
log_message("Request #{notification_name} failed:\n#{e.full_message(highlight: false)}")
end

private

# Write a response message back to the client
def send_message(message)
json_message = message.to_json
@stdout.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}")
end

# Write a notification to the client to be transmitted to the editor
def send_notification(message)
json_message = message.to_json
@stderr.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}")
end
end

class ServerAddon
Expand All @@ -76,16 +84,17 @@ def delegate(name, request, params)
end

# Instantiate all server addons and store them in a hash for easy access after we have discovered the classes
def finalize_registrations!(stdout)
def finalize_registrations!(stdout, stderr)
until @server_addon_classes.empty?
addon = @server_addon_classes.shift.new(stdout)
addon = @server_addon_classes.shift.new(stdout, stderr)
@server_addons[addon.name] = addon
end
end
end

def initialize(stdout)
def initialize(stdout, stderr)
@stdout = stdout
@stderr = stderr
end

def name
Expand All @@ -100,11 +109,11 @@ def execute(request, params)
class Server
include Common

def initialize(stdout: $stdout, override_default_output_device: true)
def initialize(stdout: $stdout, stderr: $stderr, override_default_output_device: true)
# Grab references to the original pipes so that we can change the default output device further down
@stdin = $stdin
@stdout = stdout
@stderr = $stderr
@stderr = stderr
@stdin.sync = true
@stdout.sync = true
@stderr.sync = true
Expand Down Expand Up @@ -169,7 +178,7 @@ def execute(request, params)
when "server_addon/register"
with_notification_error_handling(request) do
require params[:server_addon_path]
ServerAddon.finalize_registrations!(@stdout)
ServerAddon.finalize_registrations!(@stdout, @stderr)
end
when "server_addon/delegate"
server_addon_name = params[:server_addon_name]
Expand Down
10 changes: 5 additions & 5 deletions test/ruby_lsp_rails/runner_client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def name
def execute(request, params)
log_message("Hello!")
send_message({ request:, params: })
send_result({ request: request, params: params })
end
end
RUBY
Expand All @@ -141,16 +141,16 @@ def execute(request, params)
# Finished booting server
pop_log_notification(@outgoing_queue, RubyLsp::Constant::MessageType::LOG)

log = pop_log_notification(@outgoing_queue, RubyLsp::Constant::MessageType::LOG)
log = @outgoing_queue.pop

# Sometimes we get warnings concerning deprecations and they mess up this expectation
3.times do
unless log.params.message.match?(/Hello!/)
log = pop_log_notification(@outgoing_queue, RubyLsp::Constant::MessageType::LOG)
unless log.dig(:params, :message).match?(/Hello!/)
log = @outgoing_queue.pop
end
end

assert_match("Hello!", log.params.message)
assert_match("Hello!", log.dig(:params, :message))
ensure
FileUtils.rm("server_addon.rb")
end
Expand Down
20 changes: 19 additions & 1 deletion test/ruby_lsp_rails/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
class ServerTest < ActiveSupport::TestCase
setup do
@stdout = StringIO.new
@server = RubyLsp::Rails::Server.new(stdout: @stdout, override_default_output_device: false)
@stderr = StringIO.new
@server = RubyLsp::Rails::Server.new(stdout: @stdout, stderr: @stderr, override_default_output_device: false)
end

test "returns nil if model doesn't exist" do
Expand Down Expand Up @@ -229,6 +230,23 @@ def resolve_route_info(requirements)
assert_equal expected, @stdout.string
end

test "log_message sends notification to client" do
@server.log_message("Hello")
expected_notification = { method: "window/logMessage", params: { type: 4, message: "Hello" } }.to_json
assert_equal "Content-Length: #{expected_notification.bytesize}\r\n\r\n#{expected_notification}", @stderr.string
end

test "log_message allows server to define message type" do
@server.log_message("Hello", type: 1)

expected_notification = {
method: "window/logMessage",
params: { type: 1, message: "Hello" },
}.to_json

assert_equal "Content-Length: #{expected_notification.bytesize}\r\n\r\n#{expected_notification}", @stderr.string
end

private

def response
Expand Down

0 comments on commit af129b9

Please sign in to comment.