diff --git a/lib/ruby_lsp/requests/on_type_formatting.rb b/lib/ruby_lsp/requests/on_type_formatting.rb index da4904e19..988492d0d 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,9 +132,13 @@ 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 next_line + 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) @@ -141,9 +148,15 @@ def handle_statement_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) + next_line = @lines[@position[:line] + 1] + + if supports_snippet_anchor? + add_edit_with_text("\n") + add_edit_with_text("#{indents}#{delimiter}") + move_cursor_to(@position[:line], @indentation + 2) + elsif next_line + add_edit_with_text("#{indents}#{delimiter}\n", { line: @position[:line] + 1, character: 0 }) + end end #: (String spaces) -> void @@ -164,9 +177,16 @@ def add_edit_with_text(text, position = @position) ) 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 69a75a9fe..47b7c2150 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 + # At the end of the file 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,88 @@ def test_no_snippet_if_not_vs_code "\n", "Foo", ).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: 1, character: 2 }, end: { line: 1, character: 2 } }, - newText: "\n", - }, - { - range: { start: { line: 1, character: 2 }, end: { line: 1, character: 2 } }, - newText: "end", + 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: +"",