diff --git a/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb b/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb index 8bc0708aec..1b4b02636e 100644 --- a/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb +++ b/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb @@ -123,17 +123,48 @@ def autocorrect_action #: -> Array[Interface::TextEdit] def offense_replacements + source_lines = @document.cache_fetch("rubocop_source_lines") do + lines = @document.source.lines + # String#lines omits the empty logical line at the end of a newline-terminated document. + lines << "" if lines.empty? || @document.source.end_with?("\n") + lines + end #: Array[String] + @offense.corrector.as_replacements.map do |range, replacement| Interface::TextEdit.new( range: Interface::Range.new( - start: Interface::Position.new(line: range.line - 1, character: range.column), - end: Interface::Position.new(line: range.last_line - 1, character: range.last_column), + start: position(range.line, range.column, source_lines.fetch(range.line - 1)), + end: position(range.last_line, range.last_column, source_lines.fetch(range.last_line - 1)), ), new_text: replacement, ) end end + #: (Integer line, Integer column, String source_line) -> Interface::Position + def position(line, column, source_line) + Interface::Position.new( + line: line - 1, + character: position_column(column, source_line), + ) + end + + #: (Integer column, String source_line) -> Integer + def position_column(column, source_line) + # RuboCop columns count codepoints, while LSP columns use the negotiated encoding. + prefix = source_line.slice(0, column) || "" + case @document.encoding + when Encoding::UTF_8 + prefix.bytesize + when Encoding::UTF_16LE + prefix.codepoints.sum do |codepoint| + codepoint > RubyLsp::Document::Scanner::SURROGATE_PAIR_START ? 2 : 1 + end + else + prefix.length + end + end + #: -> Interface::CodeAction def disable_line_action Interface::CodeAction.new( diff --git a/test/requests/code_actions_formatting_test.rb b/test/requests/code_actions_formatting_test.rb index 2e6a375147..b4733a15ae 100644 --- a/test/requests/code_actions_formatting_test.rb +++ b/test/requests/code_actions_formatting_test.rb @@ -13,6 +13,50 @@ def test_disable_line__emoji assert_disable_line("emoji", "Lint/UselessAssignment") end + def test_autocorrect_string_literals_with_multibyte_characters_utf8 + assert_string_literal_autocorrected("utf-8") + end + + def test_autocorrect_string_literals_with_multibyte_characters_utf16 + assert_string_literal_autocorrected("utf-16") + end + + def test_autocorrect_string_literals_with_multibyte_characters_utf32 + assert_string_literal_autocorrected("utf-32") + end + + def test_autocorrect_trailing_empty_lines_utf8 + assert_trailing_empty_lines_autocorrected("utf-8") + end + + def test_autocorrect_trailing_empty_lines_utf16 + assert_trailing_empty_lines_autocorrected("utf-16") + end + + def test_autocorrect_trailing_empty_lines_utf32 + assert_trailing_empty_lines_autocorrected("utf-32") + end + + def test_autocorrect_empty_document + document = RubyLsp::RubyDocument.new( + source: "", + version: 1, + uri: URI::Generic.from_path(path: __FILE__), + global_state: RubyLsp::GlobalState.new, + ) + range = stub(line: 1, column: 0, last_line: 1, last_column: 0) + offense = stub(corrector: stub(as_replacements: [[range, "# comment"]])) + diagnostic = RubyLsp::Requests::Support::RuboCopDiagnostic.new(document, offense, document.uri) + + edit = diagnostic.send(:offense_replacements).first + assert_equal(0, edit.range.start.line) + assert_equal(0, edit.range.start.character) + assert_equal(0, edit.range.end.character) + + range.stubs(:last_line).returns(2) + assert_raises(IndexError) { diagnostic.send(:offense_replacements) } + end + def test_disable_line__lambda_indentation assert_disable_line("lambda_indentation", "Layout/IndentationConsistency") end @@ -50,6 +94,45 @@ def foo; end private + def assert_trailing_empty_lines_autocorrected(position_encoding) + ["\n", "\r\n"].each do |newline| + source = "value = \"こんにちは 😀\"#{newline}#{newline}" + expected = "value = \"こんにちは 😀\"\n" + assert_corrects_to_expected( + "Layout/TrailingEmptyLines", + "Autocorrect Layout/TrailingEmptyLines", + source, + expected, + position_encoding: position_encoding, + ) + end + + assert_corrects_to_expected( + "Layout/TrailingEmptyLines", + "Autocorrect Layout/TrailingEmptyLines", + "\n\n", + "\n", + position_encoding: position_encoding, + ) + end + + def assert_string_literal_autocorrected(position_encoding) + source = <<~RUBY + value = 'こんにちは 😀' + RUBY + expected = <<~RUBY + value = "こんにちは 😀" + RUBY + + assert_corrects_to_expected( + "Style/StringLiterals", + "Autocorrect Style/StringLiterals", + source, + expected, + position_encoding: position_encoding, + ) + end + def assert_disable_line(fixture, cop_name) assert_fixtures_match( "rubocop_#{fixture}", @@ -84,9 +167,13 @@ def load_expectation(name) [source, expected] end - #: (String diagnostic_code, String code_action_title, String source, String expected) -> untyped - def assert_corrects_to_expected(diagnostic_code, code_action_title, source, expected) - document, _diagnostic, result = setup_code_action_context(source, diagnostic_code) + #: (String diagnostic_code, String code_action_title, String source, String expected, ?position_encoding: String?) -> untyped + def assert_corrects_to_expected(diagnostic_code, code_action_title, source, expected, position_encoding: nil) + document, _diagnostic, result = setup_code_action_context( + source, + diagnostic_code, + position_encoding: position_encoding, + ) selected_action = find_code_action_by_title(result, code_action_title) #: as !nil @@ -105,12 +192,16 @@ def assert_corrects_to_expected(diagnostic_code, code_action_title, source, expe assert_equal(document.source, expected) end - #: (String source, String diagnostic_code) -> [RubyLsp::RubyDocument, LanguageServer::Protocol::Interface::Diagnostic, Array[LanguageServer::Protocol::Interface::CodeAction]?] - def setup_code_action_context(source, diagnostic_code) + #: (String source, String diagnostic_code, ?position_encoding: String?) -> [RubyLsp::RubyDocument, LanguageServer::Protocol::Interface::Diagnostic, Array[LanguageServer::Protocol::Interface::CodeAction]?] + def setup_code_action_context(source, diagnostic_code, position_encoding: nil) global_state = RubyLsp::GlobalState.new - global_state.apply_options({ + options = { initializationOptions: { linters: ["rubocop_internal"] }, - }) + } + if position_encoding + options[:capabilities] = { general: { positionEncodings: [position_encoding] } } + end + global_state.apply_options(options) global_state.register_formatter( "rubocop_internal", RubyLsp::Requests::Support::RuboCopFormatter.new,