diff --git a/Makefile b/Makefile index b5dc795..0a2c2d4 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ + .PHONY: image guard continuous_development console test shell run_in_shell server gem gem_release publish_cross_platform_image PROJECT_NAME=ruby_language_server LOCAL_LINK=-v $(PWD):/tmp/src -w /tmp/src @@ -9,7 +10,7 @@ force_rebuild_image: guard: image echo > active_record.log - docker run -it --rm $(LOCAL_LINK) -e LOG_LEVEL=DEBUG $(PROJECT_NAME) bundle exec guard + ./bin/run_in_shell bundle exec guard echo > active_record.log continuous_development: image @@ -23,30 +24,30 @@ continuous_development: image done console: image - docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) bin/console + ./bin/run_in_shell bin/console test: image - docker run --rm $(LOCAL_LINK) $(PROJECT_NAME) sh -c 'bundle exec rake test && bundle exec rubocop' + ./bin/run_in_shell "bundle exec rake test && bundle exec rubocop" shell: image - docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) sh + ./bin/run_in_shell sh -run_in_shell: image - docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) sh -c '$(COMMAND)' +run_in_shell: + docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) sh -c "${SHELL_COMMAND}" # Just to make sure it works. server: image - docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) + ./bin/run_in_shell gem: image rm -f $(PROJECT_NAME)*.gem - docker run $(LOCAL_LINK) $(PROJECT_NAME) gem build $(PROJECT_NAME) + ./bin/run_in_shell gem build $(PROJECT_NAME) # Requires rubygems be installed on host gem_release: gem - docker run -it --rm $(LOCAL_LINK) $(PROJECT_NAME) gem push $(PROJECT_NAME)*.gem + ./bin/run_in_shell gem push $(PROJECT_NAME)*.gem publish_cross_platform_image: - (docker buildx ls | grep mybuilder) || docker buildx create --name mybuilder - docker buildx use mybuilder - docker buildx build --push --platform linux/amd64,linux/arm64/v8 -t kwerle/$(PROJECT_NAME) . + (docker buildx ls | grep mybuilder) || ./bin/run_in_shell docker buildx create --name mybuilder + ./bin/run_in_shell docker buildx use mybuilder + ./bin/run_in_shell docker buildx build --push --platform linux/amd64,linux/arm64/v8 -t kwerle/$(PROJECT_NAME) . diff --git a/README.md b/README.md index 92f7337..bf19b3b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ Help welcome. * Definitions * Completions -* Lint - thanks to [RuboCop](https://github.com/bbatsov/rubocop) * Please see the [FAQ_ROADMAP.md](./FAQ_ROADMAP.md) # Editor Integrations diff --git a/bin/run_in_shell b/bin/run_in_shell new file mode 100755 index 0000000..ee180c7 --- /dev/null +++ b/bin/run_in_shell @@ -0,0 +1,5 @@ +#!/bin/sh +# Mostly so AI can run commands in a consistent environment +SHELL_COMMAND="$*" +export SHELL_COMMAND +make run_in_shell diff --git a/lib/config/initializers/active_record.rb b/lib/config/initializers/active_record.rb index 2c4ca15..5acd68d 100644 --- a/lib/config/initializers/active_record.rb +++ b/lib/config/initializers/active_record.rb @@ -1,24 +1,19 @@ # frozen_string_literal: true +extension_path = if Gem.win_platform? + 'liblevenshtein.dll' + else + '/usr/local/lib/liblevenshtein.so.0.0.0' + end + ActiveRecord::Base.establish_connection( adapter: 'sqlite3', database: '/database', pool: 5, - timeout: 30.seconds # does not seem to help + timeout: 30.seconds, # does not seem to help + extensions: [extension_path] ) -ActiveSupport.on_load(:active_record) do - database = ActiveRecord::Base.connection.raw_connection - database.enable_load_extension(1) - if Gem.win_platform? - # load DLL from PATH - database.load_extension('liblevenshtein.dll') - else - database.load_extension('/usr/local/lib/liblevenshtein.so.0.0.0') - end - database.enable_load_extension(0) -end - if ENV['LOG_LEVEL'] == 'DEBUG' begin warn('Turning on active record logging to active_record.log') diff --git a/lib/ruby_language_server/io.rb b/lib/ruby_language_server/io.rb index dd1152b..004e7ea 100644 --- a/lib/ruby_language_server/io.rb +++ b/lib/ruby_language_server/io.rb @@ -1,16 +1,22 @@ # frozen_string_literal: true require 'json' +require 'socket' module RubyLanguageServer class IO + attr_reader :using_socket + def initialize(server, mutex) @server = server @mutex = mutex server.io = self + + configure_io + loop do - (id, response) = process_request($stdin) - return_response(id, response, $stdout) unless id.nil? + (id, response) = process_request + return_response(id, response) unless id.nil? rescue SignalException => e RubyLanguageServer.logger.error "We received a signal. Let's bail: #{e}" exit @@ -19,38 +25,66 @@ def initialize(server, mutex) backtrace = e.backtrace * "\n" RubyLanguageServer.logger.error "Backtrace:\n#{backtrace}" end + return unless @using_socket + + begin + @in&.close + rescue StandardError => e + RubyLanguageServer.logger.error "Error closing socket: #{e}" + end end - def return_response(id, response, io = $stdout) + def send_notification(message, params) + io ||= out full_response = { jsonrpc: '2.0', - id:, - result: response + method: message, + params: } - response_body = JSON.unparse(full_response) - RubyLanguageServer.logger.info "return_response body: #{response_body}" - io.write "Content-Length: #{response_body.length + 0}\r\n" + body = JSON.generate(full_response) + RubyLanguageServer.logger.info "send_notification body: #{body}" + io.write "Content-Length: #{body.length}\r\n" io.write "\r\n" - io.write response_body - io.flush + io.write body + io.flush if io.respond_to?(:flush) + end + + private + + attr_accessor :in, :out + + def configure_io + if ENV['LSP_PORT'] + @tcp_server = TCPServer.new(ENV['LSP_PORT'].to_i) + RubyLanguageServer.logger.info "Listening on TCP port #{ENV['LSP_PORT']} for LSP connections" + self.in = @tcp_server.accept + self.out = self.in + @using_socket = true + RubyLanguageServer.logger.info 'Accepted LSP socket connection' + else + self.in = $stdin + self.out = $stdout + @using_socket = false + end end - def send_notification(message, params, io = $stdout) + def return_response(id, response) + io ||= out full_response = { jsonrpc: '2.0', - method: message, - params: + id:, + result: response } - body = JSON.unparse(full_response) - RubyLanguageServer.logger.info "send_notification body: #{body}" - io.write "Content-Length: #{body.length + 0}\r\n" + response_body = JSON.generate(full_response) + RubyLanguageServer.logger.info "return_response body: #{response_body}" + io.write "Content-Length: #{response_body.length}\r\n" io.write "\r\n" - io.write body - io.flush + io.write response_body + io.flush if io.respond_to?(:flush) end - def process_request(io = $stdin) - request_body = get_request(io) + def process_request + request_body = get_request # RubyLanguageServer.logger.debug "request_body: #{request_body}" request_json = JSON.parse request_body id = request_json['id'] @@ -77,14 +111,14 @@ def process_request(io = $stdin) end end - def get_request(io = $stdin) - initial_line = get_initial_request_line(io) + def get_request # rubocop:disable Naming/AccessorMethodName + initial_line = get_initial_request_line RubyLanguageServer.logger.debug "initial_line: #{initial_line}" length = get_length(initial_line) content = '' while content.length < length + 2 begin - content += get_content(length + 2, io) # Why + 2? CRLF? + content += get_content(length + 2) # Why + 2? CRLF? rescue Exception => e RubyLanguageServer.logger.error e # We have almost certainly been disconnected from the server @@ -95,8 +129,8 @@ def get_request(io = $stdin) content end - def get_initial_request_line(io = $stdin) - io.gets + def get_initial_request_line # rubocop:disable Naming/AccessorMethodName + self.in.gets end def get_length(string) @@ -105,37 +139,8 @@ def get_length(string) string.match(/Content-Length: (\d+)/)[1].to_i end - def get_content(size, io = $stdin) - io.read(size) + def get_content(size) + self.in.read(size) end - - # http://www.alecjacobson.com/weblog/?p=75 - # def stdin_read_char - # begin - # # save previous state of stty - # old_state = `stty -g` - # # disable echoing and enable raw (not having to press enter) - # system "stty raw -echo" - # c = STDIN.getc.chr - # # gather next two characters of special keys - # if(c=="\e") - # extra_thread = Thread.new{ - # c = c + STDIN.getc.chr - # c = c + STDIN.getc.chr - # } - # # wait just long enough for special keys to get swallowed - # extra_thread.join(0.00001) - # # kill thread so not-so-long special keys don't wait on getc - # extra_thread.kill - # end - # rescue Exception => ex - # puts "#{ex.class}: #{ex.message}" - # puts ex.backtrace - # ensure - # # restore previous state of stty - # system "stty #{old_state}" - # end - # return c - # end end # class end # module diff --git a/spec/lib/ruby_language_server/io_spec.rb b/spec/lib/ruby_language_server/io_spec.rb new file mode 100644 index 0000000..491bda4 --- /dev/null +++ b/spec/lib/ruby_language_server/io_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative '../../test_helper' +require 'stringio' +require 'json' + +class TestRubyLanguageServerIO < Minitest::Test + def setup + @fake_in = StringIO.new + @fake_out = StringIO.new + @server = Object.new + @mutex = Object.new + end + + def test_initialize_sets_up_stdio_streams_and_assigns_server_io + # Patch ENV to ensure stdio mode + assert_nil ENV.fetch('LSP_PORT', nil) + + # Create a subclass that overrides initialization to avoid the infinite loop + fake_in = @fake_in + fake_out = @fake_out + test_io_class = Class.new(RubyLanguageServer::IO) do + define_method(:initialize) do |server, mutex| + @server = server + @mutex = mutex + server.io = self + # Call configure_io but skip the loop + send(:in=, fake_in) + send(:out=, fake_out) + @using_socket = false + end + end + + server = Object.new + server.define_singleton_method(:io=) { |val| @io = val } + server.define_singleton_method(:io) { @io } + + # No need for a thread since we're not looping + test_io_class.new(server, @mutex) + + assert_instance_of test_io_class, server.io + end + + def test_return_response_writes_jsonrpc_response + io = RubyLanguageServer::IO.allocate + io.send(:out=, @fake_out) + io.send(:return_response, 1, {foo: 'bar'}) + str = @fake_out.string + assert_includes str, 'Content-Length:' + assert_includes str, 'jsonrpc' + assert_includes str, 'foo' + end + + def test_send_notification_writes_jsonrpc_notification + io = RubyLanguageServer::IO.allocate + io.send(:out=, @fake_out) + io.send(:send_notification, 'testMethod', {foo: 'bar'}) + str = @fake_out.string + assert_includes str, 'Content-Length:' + assert_includes str, 'testMethod' + assert_includes str, 'foo' + end + + def test_get_length_returns_correct_length + io = RubyLanguageServer::IO.allocate + assert_equal 42, io.send(:get_length, 'Content-Length: 42') + assert_equal 0, io.send(:get_length, nil) + end + + def test_get_initial_request_line_reads_line + io = RubyLanguageServer::IO.allocate + @fake_in.string = "Content-Length: 42\n" + io.send(:in=, @fake_in) + assert_equal "Content-Length: 42\n", io.send(:get_initial_request_line) + end + + def test_get_content_reads_bytes + io = RubyLanguageServer::IO.allocate + @fake_in.string = 'abcdefg' + io.send(:in=, @fake_in) + assert_equal 'abc', io.send(:get_content, 3) + end + + def test_get_request_reads_body + io = RubyLanguageServer::IO.allocate + header = "Content-Length: 5\n" + body = "abcde\r\n" + fake_in = StringIO.new(header + body) + io.send(:in=, fake_in) + assert_includes io.send(:get_request), 'abcde' + end + + def test_process_request_calls_server_method_and_returns_response + server = Object.new + def server.on_test_method(params) + { result: params['foo'] } + end + request = { id: 1, method: 'test_method', params: { 'foo' => 'bar' } }.to_json + header = "Content-Length: #{request.bytesize}\n" + body = "#{request}\r\n" + fake_in = StringIO.new(header + body) + io = RubyLanguageServer::IO.allocate + io.send(:in=, fake_in) + io.instance_variable_set(:@server, server) + result = io.send(:process_request) + assert_equal [1, { result: 'bar' }], result + end + + def test_process_request_returns_nil_if_server_does_not_respond + server = Object.new + request = { id: 1, method: 'no_such_method', params: {} }.to_json + header = "Content-Length: #{request.bytesize}\n" + body = "#{request}\r\n" + fake_in = StringIO.new(header + body) + io = RubyLanguageServer::IO.allocate + io.send(:in=, fake_in) + io.instance_variable_set(:@server, server) + assert_nil io.send(:process_request) + end +end