Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add on type formatting completions #253

Merged
merged 4 commits into from
Aug 26, 2022
Merged
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
2 changes: 2 additions & 0 deletions lib/ruby_lsp/requests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module RubyLsp
# - {RubyLsp::Requests::SelectionRanges}
# - {RubyLsp::Requests::SemanticHighlighting}
# - {RubyLsp::Requests::Formatting}
# - {RubyLsp::Requests::OnTypeFormatting}
# - {RubyLsp::Requests::Diagnostics}
# - {RubyLsp::Requests::CodeActions}
# - {RubyLsp::Requests::DocumentHighlight}
Expand All @@ -21,6 +22,7 @@ module Requests
autoload :SelectionRanges, "ruby_lsp/requests/selection_ranges"
autoload :SemanticHighlighting, "ruby_lsp/requests/semantic_highlighting"
autoload :Formatting, "ruby_lsp/requests/formatting"
autoload :OnTypeFormatting, "ruby_lsp/requests/on_type_formatting"
autoload :Diagnostics, "ruby_lsp/requests/diagnostics"
autoload :CodeActions, "ruby_lsp/requests/code_actions"
autoload :DocumentHighlight, "ruby_lsp/requests/document_highlight"
Expand Down
135 changes: 135 additions & 0 deletions lib/ruby_lsp/requests/on_type_formatting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Requests
# ![On type formatting demo](../../misc/on_type_formatting.gif)
#
# The [on type formatting](https://microsoft.github.io/language-server-protocol/specification#textDocument_onTypeFormatting)
# request formats code as the user is typing. For example, automatically adding `end` to class definitions.
#
# # Example
#
# ```ruby
# class Foo # <-- upon adding a line break, on type formatting is triggered
# # <-- cursor ends up here
# end # <-- end is automatically added
# ```
class OnTypeFormatting < BaseRequest
extend T::Sig

END_REGEXES = T.let([
/(if|unless|for|while|class|module|until|def|case).*/,
/.*\sdo/,
], T::Array[Regexp])

sig { params(document: Document, position: Document::PositionShape, trigger_character: String).void }
def initialize(document, position, trigger_character)
super(document)

scanner = Document::Scanner.new(document.source)
line_begin = position[:line] == 0 ? 0 : scanner.find_position({ line: position[:line] - 1, character: 0 })
vinistock marked this conversation as resolved.
Show resolved Hide resolved
line_end = scanner.find_position(position)
line = T.must(@document.source[line_begin..line_end])

@indentation = T.let(find_indentation(line), Integer)
@previous_line = T.let(line.strip.chomp, String)
@position = position
@edits = T.let([], T::Array[Interface::TextEdit])
@trigger_character = trigger_character
end

sig { override.returns(T.nilable(T.all(T::Array[Interface::TextEdit], Object))) }
def run
return unless @document.syntax_errors?

case @trigger_character
when "{"
handle_curly_brace
when "|"
handle_pipe
when "\n"
handle_statement_end
end

@edits
end

private

sig { void }
def handle_pipe
return unless /".*|/.match?(@previous_line)

add_edit_with_text("|")
move_cursor_to(@position[:line], @position[:character])
end

sig { void }
def handle_curly_brace
return unless /".*#\{/.match?(@previous_line)

add_edit_with_text("}")
move_cursor_to(@position[:line], @position[:character])
end

sig { void }
def handle_statement_end
return unless END_REGEXES.any? { |regex| regex.match?(@previous_line) }

indents = " " * @indentation

add_edit_with_text(" \n#{indents}end")
move_cursor_to(@position[:line], @indentation + 2)
end

sig { params(text: String).void }
def add_edit_with_text(text)
position = Interface::Position.new(
line: @position[:line],
character: @position[:character]
)

@edits << Interface::TextEdit.new(
range: Interface::Range.new(
start: position,
end: position
),
new_text: text
)
end

sig { params(line: Integer, character: Integer).void }
def move_cursor_to(line, character)
position = Interface::Position.new(
line: line,
character: character
)

# The $0 is a special snippet anchor that moves the cursor to that given position. See the snippets
# documentation for more information:
# https://code.visualstudio.com/docs/editor/userdefinedsnippets#_create-your-own-snippets
@edits << Interface::TextEdit.new(
range: Interface::Range.new(
start: position,
end: position
),
new_text: "$0"
vinistock marked this conversation as resolved.
Show resolved Hide resolved
)
end

sig { params(line: String).returns(Integer) }
def find_indentation(line)
count = 0

line.chars.each do |c|
break unless c == " "

count += 1
end

count
end
end
end
end
16 changes: 16 additions & 0 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ module RubyLsp
}
end

on_type_formatting_provider = if enabled_features.include?("onTypeFormatting")
Interface::DocumentOnTypeFormattingOptions.new(
first_trigger_character: "{",
more_trigger_character: ["\n", "|"]
)
end

# TODO: switch back to using Interface::ServerCapabilities once the gem is updated for spec 3.17
Interface::InitializeResult.new(
capabilities: {
Expand All @@ -63,6 +70,7 @@ module RubyLsp
documentFormattingProvider: enabled_features.include?("formatting"),
documentHighlightProvider: enabled_features.include?("documentHighlights"),
codeActionProvider: enabled_features.include?("codeActions"),
documentOnTypeFormattingProvider: on_type_formatting_provider,
diagnosticProvider: diagnostics_provider,
}.reject { |_, v| !v }
)
Expand Down Expand Up @@ -153,6 +161,14 @@ module RubyLsp
nil
end

on("textDocument/onTypeFormatting", parallel: true) do |request|
uri = request.dig(:params, :textDocument, :uri)
position = request.dig(:params, :position)
character = request.dig(:params, :ch)

Requests::OnTypeFormatting.new(store.get(uri), position, character).run
end

on("textDocument/documentHighlight", parallel: true) do |request|
document = store.get(request.dig(:params, :textDocument, :uri))

Expand Down
Binary file added misc/on_type_formatting.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions test/integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class IntegrationTest < Minitest::Test
"selectionRanges" => :selectionRangeProvider,
"semanticHighlighting" => :semanticTokensProvider,
"formatting" => :documentFormattingProvider,
"onTypeFormatting" => :documentOnTypeFormattingProvider,
"codeActions" => :codeActionProvider,
"diagnostics" => :diagnosticProvider,
}.freeze
Expand Down Expand Up @@ -125,6 +126,19 @@ class Foo
FORMATTED
end

def test_on_type_formatting
initialize_lsp(["onTypeFormatting"])
open_file_with("class Foo\nend")

assert_telemetry("textDocument/didOpen")

response = make_request(
"textDocument/onTypeFormatting",
{ textDocument: { uri: "file://#{__FILE__}", position: { line: 0, character: 0 }, character: "\n" } }
)
assert_nil(response[:result])
end

def test_code_actions
initialize_lsp(["codeActions"])
open_file_with("class Foo\nend")
Expand Down
72 changes: 72 additions & 0 deletions test/requests/on_type_formatting_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# typed: true
# frozen_string_literal: true

require "test_helper"

class OnTypeFormattingTest < Minitest::Test
def test_adding_missing_ends
document = RubyLsp::Document.new(+"")

document.push_edits([{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
text: "class Foo\n",
}])

edits = RubyLsp::Requests::OnTypeFormatting.new(document, { line: 0, character: 8 }, "\n").run
expected_edits = [
{
range: { start: { line: 0, character: 8 }, end: { line: 0, character: 8 } },
newText: " \nend",
},
{
range: { start: { line: 0, character: 2 }, end: { line: 0, character: 2 } },
newText: "$0",
},
]
assert_equal(expected_edits.to_json, T.must(edits).to_json)
end

def test_adding_missing_curly_brace_in_string_interpolation
document = RubyLsp::Document.new(+"")

document.push_edits([{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
text: "\"something#\{\"",
}])

edits = RubyLsp::Requests::OnTypeFormatting.new(document, { line: 0, character: 11 }, "{").run
expected_edits = [
{
range: { start: { line: 0, character: 11 }, end: { line: 0, character: 11 } },
newText: "}",
},
{
range: { start: { line: 0, character: 11 }, end: { line: 0, character: 11 } },
newText: "$0",
},
]
assert_equal(expected_edits.to_json, T.must(edits).to_json)
end

def test_adding_missing_pipe
document = RubyLsp::Document.new(+"")

document.push_edits([{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
text: "[].each do |",
}])

edits = RubyLsp::Requests::OnTypeFormatting.new(document, { line: 0, character: 11 }, "|").run
expected_edits = [
{
range: { start: { line: 0, character: 11 }, end: { line: 0, character: 11 } },
newText: "|",
},
{
range: { start: { line: 0, character: 11 }, end: { line: 0, character: 11 } },
newText: "$0",
},
]
assert_equal(expected_edits.to_json, T.must(edits).to_json)
end
end