diff --git a/Gemfile b/Gemfile index f80257b7..af6bf336 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,8 @@ gem "minitest", "~> 5.0" gem "pry" gem "rake", "~> 13.0" gem "gimme" +gem "m" + if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.5") gem "simplecov" diff --git a/Gemfile.lock b/Gemfile.lock index 8adfee90..970ce3f9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,6 +15,9 @@ GEM gimme (0.5.0) json (2.6.3) language_server-protocol (3.17.0.3) + m (1.6.1) + method_source (>= 0.6.7) + rake (>= 0.9.2.2) method_source (1.0.0) minitest (5.17.0) parallel (1.22.1) @@ -58,6 +61,7 @@ PLATFORMS DEPENDENCIES bundler gimme + m minitest (~> 5.0) pry rake (~> 13.0) diff --git a/lib/standard/lsp/logger.rb b/lib/standard/lsp/logger.rb new file mode 100644 index 00000000..3253f03b --- /dev/null +++ b/lib/standard/lsp/logger.rb @@ -0,0 +1,9 @@ +module Standard + module Lsp + class Logger + def puts(message) + warn("[server] #{message}") + end + end + end +end diff --git a/lib/standard/lsp/routes.rb b/lib/standard/lsp/routes.rb new file mode 100644 index 00000000..f17ea182 --- /dev/null +++ b/lib/standard/lsp/routes.rb @@ -0,0 +1,155 @@ +module Standard + module Lsp + class Routes + def initialize(writer, logger, standardizer) + @writer = writer + @logger = logger + @standardizer = standardizer + + @text_cache = {} + end + + def self.handle(name, &block) + define_method("handle_#{name}", &block) + end + + def for(name) + name = "handle_#{name}" + if respond_to?(name) + method(name) + end + end + + handle "initialize" do |request| + @writer.write(id: request[:id], result: Proto::Interface::InitializeResult.new( + capabilities: Proto::Interface::ServerCapabilities.new( + document_formatting_provider: true, + diagnostic_provider: true, + text_document_sync: Proto::Interface::TextDocumentSyncOptions.new( + change: Proto::Constant::TextDocumentSyncKind::FULL + ) + ) + )) + end + + handle "initialized" do |request| + @logger.puts "Standard Ruby v#{Standard::VERSION} LSP server initialized, pid #{Process.pid}" + end + + handle "shutdown" do |request| + @logger.puts "Client asked to shutdown Standard LSP server. Exiting..." + at_exit { + @writer.write(id: request[:id], result: nil) + } + exit 0 + end + + handle "textDocument/diagnostic" do |request| + doc = request[:params][:textDocument] + result = diagnostic(doc[:uri], doc[:text]) + @writer.write(result) + end + + handle "textDocument/didChange" do |request| + params = request[:params] + result = diagnostic(params[:textDocument][:uri], params[:contentChanges][0][:text]) + @writer.write(result) + end + + handle "textDocument/didOpen" do |request| + doc = request[:params][:textDocument] + result = diagnostic(doc[:uri], doc[:text]) + @writer.write(result) + end + + handle "textDocument/didClose" do |request| + @text_cache.delete(request.dig(:params, :textDocument, :uri)) + end + + handle "textDocument/formatting" do |request| + uri = request[:params][:textDocument][:uri] + @writer.write({id: request[:id], result: format_file(uri)}) + end + + handle "textDocument/didSave" do |_request| + # No-op + end + + handle "$/cancelRequest" do |_request| + # No-op + end + + handle "$/setTrace" do |_request| + # No-op + end + + private + + def format_file(file_uri) + text = @text_cache[file_uri] + if text.nil? + @logger.puts "Format request arrived before text synchonized; skipping: `#{file_uri}'" + [] + else + new_text = @standardizer.format(text) + if new_text == text + [] + else + [{ + newText: new_text, + range: { + start: {line: 0, character: 0}, + end: {line: text.count("\n") + 1, character: 0} + } + }] + end + end + end + + def diagnostic(file_uri, text) + @text_cache[file_uri] = text + offenses = @standardizer.offenses(text) + + lsp_diagnostics = offenses.map { |o| + code = o[:cop_name] + + msg = o[:message].delete_prefix(code) + loc = o[:location] + + severity = case o[:severity] + when "error", "fatal" + SEV::ERROR + when "warning" + SEV::WARNING + when "convention" + SEV::INFORMATION + when "refactor", "info" + SEV::HINT + else # the above cases fully cover what RuboCop sends at this time + logger.puts "Unknown severity: #{severity.inspect}" + SEV::HINT + end + + { + code: code, + message: msg, + range: { + start: {character: loc[:start_column] - 1, line: loc[:start_line] - 1}, + end: {character: loc[:last_column] - 1, line: loc[:last_line] - 1} + }, + severity: severity, + source: "standard" + } + } + + { + method: "textDocument/publishDiagnostics", + params: { + uri: file_uri, + diagnostics: lsp_diagnostics + } + } + end + end + end +end diff --git a/lib/standard/lsp/server.rb b/lib/standard/lsp/server.rb index 7c2839c3..ff85db61 100644 --- a/lib/standard/lsp/server.rb +++ b/lib/standard/lsp/server.rb @@ -1,144 +1,43 @@ require "language_server-protocol" require_relative "standardizer" +require_relative "routes" +require_relative "logger" module Standard - module LSP - class Server - Proto = LanguageServer::Protocol - SEV = Proto::Constant::DiagnosticSeverity + module Lsp + Proto = LanguageServer::Protocol + SEV = Proto::Constant::DiagnosticSeverity + class Server def self.start(standardizer) new(standardizer).start end - attr_accessor :standardizer, :writer, :reader, :logger, :text_cache, :subscribers - def initialize(standardizer) - self.standardizer = standardizer - self.writer = Proto::Transport::Io::Writer.new($stdout) - self.reader = Proto::Transport::Io::Reader.new($stdin) - self.logger = $stderr - self.text_cache = {} - - self.subscribers = { - "initialize" => ->(request) { - init_result = Proto::Interface::InitializeResult.new( - capabilities: Proto::Interface::ServerCapabilities.new( - document_formatting_provider: true, - diagnostic_provider: true, - text_document_sync: Proto::Constant::TextDocumentSyncKind::FULL - ) - ) - writer.write(id: request[:id], result: init_result) - }, - - "initialized" => ->(request) { logger.puts "standard v#{Standard::VERSION} initialized, pid #{Process.pid}" }, - - "shutdown" => ->(request) { - logger.puts "asked to shutdown, exiting..." - exit - }, - - "textDocument/didChange" => ->(request) { - params = request[:params] - result = diagnostic(params[:textDocument][:uri], params[:contentChanges][0][:text]) - writer.write(result) - }, - - "textDocument/didOpen" => ->(request) { - td = request[:params][:textDocument] - result = diagnostic(td[:uri], td[:text]) - writer.write(result) - }, - - "textDocument/didClose" => ->(request) { - text_cache.delete(request.dig(:params, :textDocument, :uri)) - }, - - "textDocument/formatting" => ->(request) { - uri = request[:params][:textDocument][:uri] - writer.write({id: request[:id], result: format_file(uri)}) - }, - - "textDocument/didSave" => ->(request) {} - } + @standardizer = standardizer + @writer = Proto::Transport::Io::Writer.new($stdout) + @reader = Proto::Transport::Io::Reader.new($stdin) + @logger = Logger.new + @routes = Routes.new(@writer, @logger, @standardizer) end def start - reader.read do |request| + @reader.read do |request| method = request[:method] - if (subscriber = subscribers[method]) - subscriber.call(request) + if (route = @routes.for(method)) + route.call(request) else - logger.puts "unknown method: #{method}" + @writer.write({id: request[:id], error: Proto::Interface::ResponseError.new( + code: Proto::Constant::ErrorCodes::METHOD_NOT_FOUND, + message: "Unsupported Method: #{method}" + )}) + @logger.puts "Unsupported Method: #{method}" end rescue => e - logger.puts "error #{e.class} #{e.message[0..100]}" - logger.puts e.backtrace.inspect + @logger.puts "Error #{e.class} #{e.message[0..100]}" + @logger.puts e.backtrace.inspect end end - - def format_file(file_uri) - text = text_cache[file_uri] - new_text = standardizer.format(text) - - if new_text == text - [] - else - [{ - newText: new_text, - range: { - start: {line: 0, character: 0}, - end: {line: text.count("\n") + 1, character: 0} - } - }] - end - end - - def diagnostic(file_uri, text) - text_cache[file_uri] = text - offenses = standardizer.offenses(text) - - lsp_diagnostics = offenses.map { |o| - code = o[:cop_name] - - msg = o[:message].delete_prefix(code) - loc = o[:location] - - severity = case o[:severity] - when "error", "fatal" - SEV::ERROR - when "warning" - SEV::WARNING - when "convention" - SEV::INFORMATION - when "refactor", "info" - SEV::HINT - else # the above cases fully cover what RuboCop sends at this time - logger.puts "unknown severity: #{severity.inspect}" - SEV::HINT - end - - { - code: code, - message: msg, - range: { - start: {character: loc[:start_column] - 1, line: loc[:start_line] - 1}, - end: {character: loc[:last_column] - 1, line: loc[:last_line] - 1} - }, - severity: severity, - source: "standard" - } - } - - { - method: "textDocument/publishDiagnostics", - params: { - diagnostics: lsp_diagnostics, - uri: file_uri - } - } - end end end end diff --git a/lib/standard/lsp/standardizer.rb b/lib/standard/lsp/standardizer.rb index 3cde1877..2b9e2bfc 100644 --- a/lib/standard/lsp/standardizer.rb +++ b/lib/standard/lsp/standardizer.rb @@ -2,7 +2,7 @@ require "tempfile" module Standard - module LSP + module Lsp class Standardizer def initialize(config) @template_options = config diff --git a/lib/standard/runners/lsp.rb b/lib/standard/runners/lsp.rb index 0e82d259..4d91dba1 100644 --- a/lib/standard/runners/lsp.rb +++ b/lib/standard/runners/lsp.rb @@ -4,8 +4,8 @@ module Standard module Runners class Lsp def call(config) - standardizer = Standard::LSP::Standardizer.new(config) - Standard::LSP::Server.start(standardizer) + standardizer = Standard::Lsp::Standardizer.new(config) + Standard::Lsp::Server.start(standardizer) end end end diff --git a/test/standard/runners/lsp_test.rb b/test/standard/runners/lsp_test.rb index 154611c9..0e4170e1 100644 --- a/test/standard/runners/lsp_test.rb +++ b/test/standard/runners/lsp_test.rb @@ -16,7 +16,7 @@ def test_server_initializes_and_responds_with_proper_capabilities assert_equal msgs.first, { id: 2, result: {capabilities: { - textDocumentSync: 1, + textDocumentSync: {change: 1}, documentFormattingProvider: true, diagnosticProvider: true }}, @@ -24,7 +24,7 @@ def test_server_initializes_and_responds_with_proper_capabilities } end - def test_get_diagnostics + def test_did_open msgs, err = run_server_on_requests({ method: "textDocument/didOpen", jsonrpc: "2.0", @@ -66,6 +66,48 @@ def test_get_diagnostics }, msgs.first) end + def test_diagnotic_route + msgs, err = run_server_on_requests({ + method: "textDocument/diagnostic", + jsonrpc: "2.0", + params: { + textDocument: { + languageId: "ruby", + text: "def hi\n [1, 2,\n 3 ]\nend\n", + uri: "file:///path/to/file.rb", + version: 0 + } + } + }) + + assert_equal "", err.string + assert_equal 1, msgs.count + assert_equal({ + method: "textDocument/publishDiagnostics", + params: { + diagnostics: [ + {code: "Layout/ArrayAlignment", + message: "Use one level of indentation for elements following the first line of a multi-line array.", + range: {start: {character: 3, line: 2}, end: {character: 3, line: 2}}, + severity: 3, + source: "standard"}, + {code: "Layout/ExtraSpacing", + message: "Unnecessary spacing detected.", + range: {start: {character: 4, line: 2}, end: {character: 4, line: 2}}, + severity: 3, + source: "standard"}, + {code: "Layout/SpaceInsideArrayLiteralBrackets", + message: "Do not use space inside array brackets.", + range: {start: {character: 4, line: 2}, end: {character: 5, line: 2}}, + severity: 3, + source: "standard"} + ], + uri: "file:///path/to/file.rb" + }, + jsonrpc: "2.0" + }, msgs.first) + end + def test_format msgs, err = run_server_on_requests( { @@ -120,6 +162,113 @@ def test_format ) end + def test_no_op_commands + _, err = run_server_on_requests( + { + method: "$/cancelRequest", + id: 1, + jsonrpc: "2.0", + params: {} + }, + { + method: "$/setTrace", + id: 1, + jsonrpc: "2.0", + params: {} + } + ) + + assert_empty err.string + end + + def test_initialized + _, err = run_server_on_requests( + { + method: "initialized", + id: 1, + jsonrpc: "2.0", + params: {} + } + ) + + assert_match(/Standard Ruby v\d+.\d+.\d+ LSP server initialized, pid \d+/, err.string) + end + + def test_format_with_unsynced_file + msgs, err = run_server_on_requests( + { + method: "textDocument/didOpen", + jsonrpc: "2.0", + params: { + textDocument: { + languageId: "ruby", + text: "def hi\n [1, 2,\n 3 ]\nend\n", + uri: "file:///path/to/file.rb", + version: 0 + } + } + }, + # didClose should cause the file to be unsynced + { + method: "textDocument/didClose", + jsonrpc: "2.0", + params: { + textDocument: { + uri: "file:///path/to/file.rb" + } + } + }, + { + method: "textDocument/formatting", + id: 20, + jsonrpc: "2.0", + params: { + options: {insertSpaces: true, tabSize: 2}, + textDocument: {uri: "file:///path/to/file.rb"} + } + } + ) + + assert_equal "[server] Format request arrived before text synchonized; skipping: `file:///path/to/file.rb'", err.string.chomp + format_result = msgs.last + assert_equal( + { + id: 20, + result: [], + jsonrpc: "2.0" + }, + format_result + ) + end + + def test_unknown_commands + msgs, err = run_server_on_requests( + { + id: 18, + method: "textDocument/didMassage", + jsonrpc: "2.0", + params: { + textDocument: { + languageId: "ruby", + text: "def hi\n [1, 2,\n 3 ]\nend\n", + uri: "file:///path/to/file.rb", + version: 0 + } + } + } + ) + + assert_equal "[server] Unsupported Method: textDocument/didMassage", err.string.chomp + assert_equal({ + id: 18, + error: { + code: -32601, + message: "Unsupported Method: textDocument/didMassage" + }, + jsonrpc: "2.0" + }, msgs.last) + end + private def run_server_on_requests(*requests)