Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions lib/ruby_lsp/requests/on_type_formatting.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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("}")
Expand All @@ -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
Expand All @@ -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,
Expand Down
206 changes: 201 additions & 5 deletions test/requests/on_type_formatting_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: +"",
Expand Down Expand Up @@ -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,
Expand All @@ -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: +"",
Expand Down
Loading