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
34 changes: 27 additions & 7 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,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)
Expand All @@ -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
Expand All @@ -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,
Expand Down
139 changes: 132 additions & 7 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
# 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,
Expand All @@ -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: +"",
Expand Down
Loading