diff --git a/exe/ruby-lsp b/exe/ruby-lsp index 1c2833c2d..d3d6ca5f6 100755 --- a/exe/ruby-lsp +++ b/exe/ruby-lsp @@ -88,6 +88,13 @@ if ENV["BUNDLE_GEMFILE"].nil? exit exec(env, "#{base_command} exec ruby-lsp #{original_args.join(" ")}".strip) end +$stdin.sync = true +$stdout.sync = true +$stderr.sync = true +$stdin.binmode +$stdout.binmode +$stderr.binmode + $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "ruby_lsp/load_sorbet" @@ -147,8 +154,10 @@ if options[:doctor] return end +server = RubyLsp::Server.new + # Ensure all output goes out stderr by default to allow puts/p/pp to work # without specifying output device. $> = $stderr -RubyLsp::Server.new.start +server.start diff --git a/exe/ruby-lsp-launcher b/exe/ruby-lsp-launcher index 7efa47fad..cfc38358d 100755 --- a/exe/ruby-lsp-launcher +++ b/exe/ruby-lsp-launcher @@ -6,6 +6,13 @@ # composed bundle # !!!!!!! +$stdin.sync = true +$stdout.sync = true +$stderr.sync = true +$stdin.binmode +$stdout.binmode +$stderr.binmode + setup_error = nil install_error = nil reboot = false @@ -28,7 +35,6 @@ else # Read the initialize request before even starting the server. We need to do this to figure out the workspace URI. # Editors are not required to spawn the language server process on the same directory as the workspace URI, so we need # to ensure that we're setting up the bundle in the right place - $stdin.binmode headers = $stdin.gets("\r\n\r\n") content_length = headers[/Content-Length: (\d+)/i, 1].to_i $stdin.read(content_length) @@ -143,22 +149,28 @@ if ARGV.include?("--debug") end end -# Ensure all output goes out stderr by default to allow puts/p/pp to work without specifying output device. -$> = $stderr - initialize_request = JSON.parse(raw_initialize, symbolize_names: true) if raw_initialize begin - RubyLsp::Server.new( + server = RubyLsp::Server.new( install_error: install_error, setup_error: setup_error, initialize_request: initialize_request, - ).start + ) + + # Ensure all output goes out stderr by default to allow puts/p/pp to work without specifying output device. + $> = $stderr + + server.start rescue ArgumentError # If the launcher is booting an outdated version of the server, then the initializer doesn't accept a keyword splat # and we already read the initialize request from the stdin pipe. In this case, we need to process the initialize # request manually and then start the main loop server = RubyLsp::Server.new + + # Ensure all output goes out stderr by default to allow puts/p/pp to work without specifying output device. + $> = $stderr + server.process_message(initialize_request) server.start end diff --git a/lib/ruby_lsp/base_server.rb b/lib/ruby_lsp/base_server.rb index e278060f0..52f8a1c16 100644 --- a/lib/ruby_lsp/base_server.rb +++ b/lib/ruby_lsp/base_server.rb @@ -6,11 +6,11 @@ module RubyLsp class BaseServer #: (**untyped options) -> void def initialize(**options) + @reader = MessageReader.new(options[:reader] || $stdin) #: MessageReader + @writer = MessageWriter.new(options[:writer] || $stdout) #: MessageWriter @test_mode = options[:test_mode] #: bool? @setup_error = options[:setup_error] #: StandardError? @install_error = options[:install_error] #: StandardError? - @writer = Transport::Stdio::Writer.new #: Transport::Stdio::Writer - @reader = Transport::Stdio::Reader.new #: Transport::Stdio::Reader @incoming_queue = Thread::Queue.new #: Thread::Queue @outgoing_queue = Thread::Queue.new #: Thread::Queue @cancelled_requests = [] #: Array[Integer] @@ -36,7 +36,7 @@ def initialize(**options) #: -> void def start - @reader.read do |message| + @reader.each_message do |message| method = message[:method] # We must parse the document under a mutex lock or else we might switch threads and accept text edits in the diff --git a/lib/ruby_lsp/utils.rb b/lib/ruby_lsp/utils.rb index bd28d872f..d69b844f9 100644 --- a/lib/ruby_lsp/utils.rb +++ b/lib/ruby_lsp/utils.rb @@ -5,7 +5,6 @@ module RubyLsp # rubocop:disable RubyLsp/UseLanguageServerAliases Interface = LanguageServer::Protocol::Interface Constant = LanguageServer::Protocol::Constant - Transport = LanguageServer::Protocol::Transport # rubocop:enable RubyLsp/UseLanguageServerAliases # Used to indicate that a request shouldn't return a response @@ -299,4 +298,37 @@ def none? = @level == :none #: -> bool def true_or_higher? = @level == :true || @level == :strict end + + # Reads JSON RPC messages from the given IO in a loop + class MessageReader + #: (IO) -> void + def initialize(io) + @io = io + end + + #: () { (Hash[Symbol, untyped]) -> void } -> void + def each_message(&block) + while (headers = @io.gets("\r\n\r\n")) + raw_message = @io.read(headers[/Content-Length: (\d+)/i, 1].to_i) #: as !nil + block.call(JSON.parse(raw_message, symbolize_names: true)) + end + end + end + + # Writes JSON RPC messages to the given IO + class MessageWriter + #: (IO) -> void + def initialize(io) + @io = io + end + + #: (Hash[Symbol, untyped]) -> void + def write(message) + message[:jsonrpc] = "2.0" + json_message = message.to_json + + @io.write("Content-Length: #{json_message.bytesize}\r\n\r\n#{json_message}") + @io.flush + end + end end diff --git a/test/integration_test.rb b/test/integration_test.rb index 3fdc4b5a6..06136ba47 100644 --- a/test/integration_test.rb +++ b/test/integration_test.rb @@ -333,6 +333,23 @@ def test_launch_mode_retries_if_setup_failed_after_successful_install end end + def test_launching_an_older_server_version + in_temp_dir do |dir| + File.write(File.join(dir, "Gemfile"), <<~RUBY) + source "https://rubygems.org" + gem "ruby-lsp", "0.23.0" + RUBY + + Bundler.with_unbundled_env do + capture_subprocess_io do + system("bundle", "install") + end + + launch(dir) + end + end + end + private def launch(workspace_path, exec = "ruby-lsp-launcher", extra_env = {})