diff --git a/lib/ruby_lsp/requests/on_type_formatting.rb b/lib/ruby_lsp/requests/on_type_formatting.rb index da4904e192..b7cbe38af1 100644 --- a/lib/ruby_lsp/requests/on_type_formatting.rb +++ b/lib/ruby_lsp/requests/on_type_formatting.rb @@ -75,6 +75,8 @@ def perform #: -> void def handle_pipe + return unless supports_snippet_anchor? + current_line = @lines[@position[:line]] return unless /((?<=do)|(?<={))\s+\|/.match?(current_line) @@ -106,6 +108,7 @@ def handle_pipe #: -> void def handle_curly_brace + return unless supports_snippet_anchor? return unless /".*#\{/.match?(@previous_line) add_edit_with_text("}") @@ -129,21 +132,32 @@ def handle_statement_end next_line = @lines[@position[:line] + 1] if current_line.nil? || current_line.strip.empty? || current_line.include?(")") || current_line.include?("]") - add_edit_with_text("\n") - add_edit_with_text("#{indents}end") - move_cursor_to(@position[:line], @indentation + 2) + if supports_snippet_anchor? + add_edit_with_text("\n") + add_edit_with_text("#{indents}end") + move_cursor_to(@position[:line], @indentation + 2) + elsif line_below_cursor? + add_edit_with_text("#{indents}end\n", { line: @position[:line] + 1, character: 0 }) + end elsif next_line.nil? || next_line.strip.empty? - add_edit_with_text("#{indents}end\n", { line: @position[:line] + 1, character: @position[:character] }) - move_cursor_to(@position[:line] - 1, @indentation + @previous_line.size + 1) + if supports_snippet_anchor? || line_below_cursor? + add_edit_with_text("#{indents}end\n", { line: @position[:line] + 1, character: @position[:character] }) + move_cursor_to(@position[:line] - 1, @indentation + @previous_line.size + 1) + end end end #: (String delimiter) -> void def handle_heredoc_end(delimiter) indents = " " * @indentation - add_edit_with_text("\n") - add_edit_with_text("#{indents}#{delimiter}") - move_cursor_to(@position[:line], @indentation + 2) + + if supports_snippet_anchor? + add_edit_with_text("\n") + add_edit_with_text("#{indents}#{delimiter}") + move_cursor_to(@position[:line], @indentation + 2) + elsif line_below_cursor? + add_edit_with_text("#{indents}#{delimiter}\n", { line: @position[:line] + 1, character: 0 }) + end end #: (String spaces) -> void @@ -164,9 +178,25 @@ def add_edit_with_text(text, position = @position) ) end + # Whether the editor's document has a line below the cursor where edits can be anchored without disturbing the + # cursor. This cannot be answered by `@lines` alone: when the source ends with a newline, the editor has one + # more (empty) line than `String#lines` returns. So after breaking the last visible line of a file with a + # trailing newline, the line below the cursor exists in the editor even though `@lines` doesn't include it. + #: -> bool + def line_below_cursor? + @position[:line] + 1 <= @document.source.count("\n") + end + + # Whether the client interprets the `$0` snippet anchor in on type formatting edits to reposition the caret. + # This is not part of the LSP specification, so it is only known to work in VS Code and its forks. + #: -> bool + def supports_snippet_anchor? + /Visual Studio Code|Cursor|VSCodium|Windsurf/.match?(@client_name) + end + #: (Integer line, Integer character) -> void def move_cursor_to(line, character) - return unless /Visual Studio Code|Cursor|VSCodium|Windsurf/.match?(@client_name) + return unless supports_snippet_anchor? position = Interface::Position.new( line: line, diff --git a/test/requests/on_type_formatting_test.rb b/test/requests/on_type_formatting_test.rb index 69a75a9fe3..193869caf5 100644 --- a/test/requests/on_type_formatting_test.rb +++ b/test/requests/on_type_formatting_test.rb @@ -48,6 +48,34 @@ def test_adding_missing_ends assert_equal(expected_edits.to_json, edits.to_json) end + def test_adding_missing_ends_below_caret_for_snippetless_clients + # The caret sits on an already-indented body line (the editor auto-indents the new line on its own). + document = RubyLsp::RubyDocument.new( + source: +"class Foo\n def bar\n \nend", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 2, character: 4 }, + "\n", + "Zed", + ).perform + + # `end` is inserted on the line *below* the caret (pushing the enclosing `end` down). The caret's own line is + # left untouched so we don't fight the editor's indentation, and no `$0` anchor is emitted. + expected_edits = [ + { + range: { start: { line: 3, character: 0 }, end: { line: 3, character: 0 } }, + newText: " end\n", + }, + ] + assert_equal(expected_edits.to_json, edits.to_json) + end + def test_adding_missing_curly_brace_in_string_interpolation document = RubyLsp::RubyDocument.new( source: +"", @@ -860,8 +888,36 @@ def test_completing_end_token_inside_brackets end def test_no_snippet_if_not_vs_code + # Adding a method inside an existing class: there's a line below the caret (the class' `end`), so `end` is + # inserted there and the caret stays in the body. No `$0` anchor, since non-VS Code clients apply edits as text. document = RubyLsp::RubyDocument.new( - source: +"", + source: +"class Foo\n def bar\n \nend", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 2, character: 2 }, + "\n", + "Foo", + ).perform + expected_edits = [ + { + range: { start: { line: 3, character: 0 }, end: { line: 3, character: 0 } }, + newText: " end\n", + }, + ] + assert_equal(expected_edits.to_json, edits.to_json) + end + + def test_no_end_for_snippetless_clients_at_end_of_file + # The file doesn't end with a newline, so there's no line below the caret to anchor `end` to, and a snippet-less + # client can't keep the caret in the body, so nothing is inserted (the one case we deliberately don't support). + document = RubyLsp::RubyDocument.new( + source: +"class Foo", version: 1, uri: URI("file:///fake.rb"), global_state: @global_state, @@ -882,19 +938,159 @@ def test_no_snippet_if_not_vs_code "\n", "Foo", ).perform + assert_empty(edits) + end + + def test_adding_end_below_caret_for_snippetless_clients_at_end_of_file_with_trailing_newline + # Breaking the last visible line of a file that ends with a newline: `String#lines` doesn't include the trailing + # empty line, but the editor does, so `end` can still be anchored below the caret without moving it. + document = RubyLsp::RubyDocument.new( + source: +"class Foo\n \n", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 1, character: 2 }, + "\n", + "Zed", + ).perform expected_edits = [ { - range: { start: { line: 1, character: 2 }, end: { line: 1, character: 2 } }, - newText: "\n", + range: { start: { line: 2, character: 0 }, end: { line: 2, character: 0 } }, + newText: "end\n", }, + ] + assert_equal(expected_edits.to_json, edits.to_json) + end + + def test_adding_heredoc_delimiter_below_caret_at_end_of_file_with_trailing_newline + document = RubyLsp::RubyDocument.new( + source: +"str = <<~STR\n \n", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 1, character: 2 }, + "\n", + "Zed", + ).perform + expected_edits = [ { - range: { start: { line: 1, character: 2 }, end: { line: 1, character: 2 } }, - newText: "end", + range: { start: { line: 2, character: 0 }, end: { line: 2, character: 0 } }, + newText: "STR\n", }, ] assert_equal(expected_edits.to_json, edits.to_json) end + def test_no_end_for_snippetless_clients_when_breaking_line_at_end_of_file_without_trailing_newline + # Breaking a line in the middle (content after the caret) on the last line of a file without a trailing newline: + # there's no editor line below the caret's line to place `end` on, so we don't emit an out-of-bounds edit, which + # clients would clamp to the end of the content line, corrupting it. + document = RubyLsp::RubyDocument.new( + source: +"def foo\nbar", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 1, character: 0 }, + "\n", + "Zed", + ).perform + assert_empty(edits) + end + + def test_adding_heredoc_delimiter_below_caret_for_snippetless_clients + document = RubyLsp::RubyDocument.new( + source: +"def foo\n str = <<~STR\n \nend", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 2, character: 2 }, + "\n", + "Zed", + ).perform + # The closing delimiter is inserted on the line below the caret, with no `$0` anchor. + expected_edits = [ + { + range: { start: { line: 3, character: 0 }, end: { line: 3, character: 0 } }, + newText: " STR\n", + }, + ] + assert_equal(expected_edits.to_json, edits.to_json) + end + + def test_curly_brace_not_added_for_snippetless_clients + # Inline closer: the editor (e.g. Zed) auto-closes `{` itself, and the caret can't be kept inside without `$0`. + document = RubyLsp::RubyDocument.new( + source: +"", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + + document.push_edits( + [{ + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + text: "\"something#\{\"", + }], + version: 2, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 0, character: 11 }, + "{", + "Zed", + ).perform + assert_empty(edits) + end + + def test_pipe_not_added_for_snippetless_clients + # Inline closer: the caret can't be kept between the pipes without `$0`, so block parameters are left to the user. + document = RubyLsp::RubyDocument.new( + source: +"", + version: 1, + uri: URI("file:///fake.rb"), + global_state: @global_state, + ) + + document.push_edits( + [{ + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + text: "[].each do |", + }], + version: 2, + ) + document.parse! + + edits = RubyLsp::Requests::OnTypeFormatting.new( + document, + { line: 0, character: 12 }, + "|", + "Zed", + ).perform + assert_empty(edits) + end + def test_includes_snippets_on_vscode_insiders document = RubyLsp::RubyDocument.new( source: +"",