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] 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 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: +"",