From 1c49b58db35a3d1ca21abfa8595c6a68b1f84303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Sat, 10 Oct 2026 18:24:48 +0300 Subject: [PATCH 1/2] Fix RuboCop quickfix ranges for multibyte text --- .../requests/support/rubocop_diagnostic.rb | 30 ++++++++++- test/requests/code_actions_formatting_test.rb | 51 ++++++++++++++++--- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb b/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb index 8bc0708aec..a0894fa2e9 100644 --- a/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb +++ b/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb @@ -123,17 +123,43 @@ def autocorrect_action #: -> Array[Interface::TextEdit] def offense_replacements + source_lines = @document.cache_fetch("rubocop_source_lines") { @document.source.lines } #: 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..b137d09001 100644 --- a/test/requests/code_actions_formatting_test.rb +++ b/test/requests/code_actions_formatting_test.rb @@ -13,6 +13,18 @@ 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_disable_line__lambda_indentation assert_disable_line("lambda_indentation", "Layout/IndentationConsistency") end @@ -50,6 +62,23 @@ def foo; end private + 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 +113,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 +138,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, From b8632d7dce02c272e878cd47838e4b1171b2f877 Mon Sep 17 00:00:00 2001 From: Efe Date: Tue, 3 Nov 2026 03:42:55 +0300 Subject: [PATCH 2/2] Fix RuboCop quickfix ranges at the end of documents --- .../requests/support/rubocop_diagnostic.rb | 7 ++- test/requests/code_actions_formatting_test.rb | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb b/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb index a0894fa2e9..1b4b02636e 100644 --- a/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb +++ b/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb @@ -123,7 +123,12 @@ def autocorrect_action #: -> Array[Interface::TextEdit] def offense_replacements - source_lines = @document.cache_fetch("rubocop_source_lines") { @document.source.lines } #: Array[String] + 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( diff --git a/test/requests/code_actions_formatting_test.rb b/test/requests/code_actions_formatting_test.rb index b137d09001..b4733a15ae 100644 --- a/test/requests/code_actions_formatting_test.rb +++ b/test/requests/code_actions_formatting_test.rb @@ -25,6 +25,38 @@ 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 @@ -62,6 +94,28 @@ 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 = 'こんにちは 😀'