From 2cb12ce958d8485ccd272a8b0eaf82790f3e9e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janko=20Marohni=C4=87?= Date: Wed, 10 Jun 2026 22:23:59 +0200 Subject: [PATCH 1/2] Support "on type formatting" in Zed Zed doesn't support snippet syntax ($0) in `document/onTypeFormatting` responses, nor is it possible for its Ruby extension to implement it. For "end" and "heredoc end", we can work around this by inserting them in the line *below* the cursor. This prevents the cursor from moving after the inserted text, avoiding the need to rewind it. We can't support this at the EOF, as there is no next line, but that's the rarer scenario. This won't work for formatting on the same line, because Zed cannot rewind the cursor. Zed supports auto-closing brackets, so we skip closing curly braces. We do the same for pipes, because it's better not to add anything than to do it incorrectly. --- lib/ruby_lsp/requests/on_type_formatting.rb | 34 ++++- test/requests/on_type_formatting_test.rb | 139 +++++++++++++++++++- 2 files changed, 159 insertions(+), 14 deletions(-) diff --git a/lib/ruby_lsp/requests/on_type_formatting.rb b/lib/ruby_lsp/requests/on_type_formatting.rb index da4904e192..988492d0d0 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 69a75a9fe3..47b7c21508 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: +"", From 4f1b1f4d0ff666721fab1d3d5560d0d2e7504f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janko=20Marohni=C4=87?= Date: Mon, 31 Aug 2026 22:59:36 +0200 Subject: [PATCH 2/2] Handle statement/heredoc end on EOF with trailing newline --- lib/ruby_lsp/requests/on_type_formatting.rb | 20 ++++-- test/requests/on_type_formatting_test.rb | 75 ++++++++++++++++++++- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/lib/ruby_lsp/requests/on_type_formatting.rb b/lib/ruby_lsp/requests/on_type_formatting.rb index 988492d0d0..b7cbe38af1 100644 --- a/lib/ruby_lsp/requests/on_type_formatting.rb +++ b/lib/ruby_lsp/requests/on_type_formatting.rb @@ -136,25 +136,26 @@ def handle_statement_end add_edit_with_text("\n") add_edit_with_text("#{indents}end") move_cursor_to(@position[:line], @indentation + 2) - elsif next_line + 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 - 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 + elsif line_below_cursor? add_edit_with_text("#{indents}#{delimiter}\n", { line: @position[:line] + 1, character: 0 }) end end @@ -177,6 +178,15 @@ 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 diff --git a/test/requests/on_type_formatting_test.rb b/test/requests/on_type_formatting_test.rb index 47b7c21508..193869caf5 100644 --- a/test/requests/on_type_formatting_test.rb +++ b/test/requests/on_type_formatting_test.rb @@ -914,8 +914,8 @@ def test_no_snippet_if_not_vs_code 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). + # 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, @@ -941,6 +941,77 @@ def test_no_end_for_snippetless_clients_at_end_of_file 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: 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: 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",