From f0fc789e9a64edf13d6595a157a5a3d645d81fcc Mon Sep 17 00:00:00 2001 From: rubemoon Date: Sat, 8 Aug 2026 21:34:18 -0300 Subject: [PATCH] Fix Unicode offsets in completion replacement ranges --- lib/ruby_lsp/listeners/completion.rb | 54 +++++++++++++++++++--------- lib/ruby_lsp/requests/completion.rb | 1 + test/requests/completion_test.rb | 23 ++++++++++++ 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/lib/ruby_lsp/listeners/completion.rb b/lib/ruby_lsp/listeners/completion.rb index 8f3a13ae1..c25f30e18 100644 --- a/lib/ruby_lsp/listeners/completion.rb +++ b/lib/ruby_lsp/listeners/completion.rb @@ -50,7 +50,7 @@ class Completion "__LINE__", ].freeze - #: (ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] response_builder, GlobalState global_state, NodeContext node_context, SorbetLevel sorbet_level, Prism::Dispatcher dispatcher, URI::Generic uri, String? trigger_character) -> void + #: (ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] response_builder, GlobalState global_state, NodeContext node_context, SorbetLevel sorbet_level, Prism::Dispatcher dispatcher, URI::Generic uri, (^(Integer arg0) -> Integer | Prism::CodeUnitsCache) code_units_cache, String? trigger_character) -> void def initialize( # rubocop:disable Metrics/ParameterLists response_builder, global_state, @@ -58,6 +58,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists sorbet_level, dispatcher, uri, + code_units_cache, trigger_character ) @response_builder = response_builder @@ -67,6 +68,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists @node_context = node_context @sorbet_level = sorbet_level @uri = uri + @code_units_cache = code_units_cache @trigger_character = trigger_character dispatcher.register( @@ -105,7 +107,7 @@ def on_constant_read_node_enter(node) name = RubyIndexer::Index.constant_name(node) return if name.nil? - range = range_from_location(node.location) + range = range_from_prism_location(node.location) candidates = @index.constant_completion_candidates(name, @node_context.nesting) candidates.each do |entries| complete_name = entries.first #: as !nil @@ -136,7 +138,7 @@ def on_constant_path_node_enter(node) end return if name.nil? - constant_path_completion(name, range_from_location(node.location)) + constant_path_completion(name, range_from_prism_location(node.location)) end #: (Prism::CallNode node) -> void @@ -161,8 +163,14 @@ def on_call_node_enter(node) constant_path_completion( "#{name}::", Interface::Range.new( - start: Interface::Position.new(line: start_loc.start_line - 1, character: start_loc.start_column), - end: Interface::Position.new(line: end_loc.end_line - 1, character: end_loc.end_column), + start: Interface::Position.new( + line: start_loc.start_line - 1, + character: start_loc.cached_start_code_units_column(@code_units_cache), + ), + end: Interface::Position.new( + line: end_loc.end_line - 1, + character: end_loc.cached_end_code_units_column(@code_units_cache), + ), ), ) return @@ -275,6 +283,20 @@ def on_class_variable_write_node_enter(node) private + #: (Prism::Location location) -> Interface::Range + def range_from_prism_location(location) + Interface::Range.new( + start: Interface::Position.new( + line: location.start_line - 1, + character: location.cached_start_code_units_column(@code_units_cache), + ), + end: Interface::Position.new( + line: location.end_line - 1, + character: location.cached_end_code_units_column(@code_units_cache), + ), + ) + end + #: (String name, Interface::Range range) -> void def constant_path_completion(name, range) top_level_reference = if name.start_with?("::") @@ -338,7 +360,7 @@ def handle_global_variable_completion(name, location) return if candidates.none? - range = range_from_location(location) + range = range_from_prism_location(location) candidates.flatten.uniq(&:name).each do |entry| entry_name = entry.name @@ -360,7 +382,7 @@ def handle_class_variable_completion(name, location) type = @type_inferrer.infer_receiver_type(@node_context) return unless type - range = range_from_location(location) + range = range_from_prism_location(location) @index.class_variable_completion_candidates(name, type.name).each do |entry| variable_name = entry.name @@ -395,7 +417,7 @@ def handle_instance_variable_completion(name, location) type = @type_inferrer.infer_receiver_type(@node_context) return unless type - range = range_from_location(location) + range = range_from_prism_location(location) @index.instance_variable_completion_candidates(name, type.name).each do |entry| variable_name = entry.name @@ -495,16 +517,16 @@ def complete_methods(node, name) method_name = @trigger_character == "." ? nil : name range = if method_name - range_from_location( - node.message_loc, #: as !nil - ) + location = node.message_loc #: as !nil + range_from_prism_location(location) else loc = node.call_operator_loc if loc + character = loc.cached_start_code_units_column(@code_units_cache) + 1 Interface::Range.new( - start: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column + 1), - end: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column + 1), + start: Interface::Position.new(line: loc.start_line - 1, character: character), + end: Interface::Position.new(line: loc.start_line - 1, character: character), ) end end @@ -550,7 +572,7 @@ def complete_methods(node, name) #: (Prism::CallNode node, String name) -> void def add_local_completions(node, name) - range = range_from_location( + range = range_from_prism_location( node.message_loc, #: as !nil ) @@ -572,7 +594,7 @@ def add_local_completions(node, name) #: (Prism::CallNode node, String name) -> void def add_keyword_completions(node, name) - range = range_from_location( + range = range_from_prism_location( node.message_loc, #: as !nil ) @@ -598,7 +620,7 @@ def build_completion(label, node) Interface::CompletionItem.new( label: label, text_edit: Interface::TextEdit.new( - range: range_from_location(loc), + range: range_from_prism_location(loc), new_text: label, ), kind: Constant::CompletionItemKind::FILE, diff --git a/lib/ruby_lsp/requests/completion.rb b/lib/ruby_lsp/requests/completion.rb index 176c8174a..a887dfafb 100644 --- a/lib/ruby_lsp/requests/completion.rb +++ b/lib/ruby_lsp/requests/completion.rb @@ -70,6 +70,7 @@ def initialize(document, global_state, params, sorbet_level, dispatcher) sorbet_level, dispatcher, document.uri, + document.code_units_cache, params.dig(:context, :triggerCharacter), ) diff --git a/test/requests/completion_test.rb b/test/requests/completion_test.rb index 0ef14cb38..311081924 100644 --- a/test/requests/completion_test.rb +++ b/test/requests/completion_test.rb @@ -1535,6 +1535,29 @@ def do_something(abc1, abc2, abc3) end end + def test_completion_for_locals_with_unicode_before_cursor + source = +<<~'RUBY' + test = "moo" + "🐮 says #{te}" + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message(id: 1, method: "textDocument/completion", params: { + textDocument: { uri: uri }, + position: { line: 1, character: 13 }, + }) + + result = server.pop_response.response + item = result.find { |completion| completion.label == "test" } #: as !nil + + assert_equal("test", item.text_edit.new_text) + assert_equal( + { start: { line: 1, character: 11 }, end: { line: 1, character: 13 } }, + item.text_edit.range.to_hash.transform_values(&:to_hash), + ) + end + end + def test_completion_for_locals_only_happens_when_there_is_no_receiver source = +<<~RUBY class Child