Skip to content

Commit

Permalink
Add on type formatting completions
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Aug 19, 2022
1 parent 45a2214 commit edce6de
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 0 deletions.
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
132 changes: 132 additions & 0 deletions lib/ruby_lsp/requests/on_type_formatting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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 })
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[LanguageServer::Protocol::Interface::TextEdit])
@trigger_character = trigger_character
end

sig { override.returns(T.nilable(T.all(T::Array[LanguageServer::Protocol::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 = LanguageServer::Protocol::Interface::Position.new(
line: @position[:line],
character: @position[:character]
)

@edits << LanguageServer::Protocol::Interface::TextEdit.new(
range: LanguageServer::Protocol::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 = LanguageServer::Protocol::Interface::Position.new(
line: line,
character: character
)

@edits << LanguageServer::Protocol::Interface::TextEdit.new(
range: LanguageServer::Protocol::Interface::Range.new(
start: position,
end: position
),
new_text: "$0"
)
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 @@ -150,6 +158,14 @@ module RubyLsp
Requests::Formatting.new(uri, store.get(uri)).run
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.
Empty file.
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
19 changes: 19 additions & 0 deletions test/requests/on_type_formatting_expectations_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# typed: true
# frozen_string_literal: true

require "test_helper"
require "expectations/expectations_test_runner"

class OnTypeFormattingExpectationsTest < ExpectationsTestRunner
expectations_tests RubyLsp::Requests::OnTypeFormatting, "on_type_formatting"

def run_expectations(source)
document = RubyLsp::Document.new(source)
params = @__params&.any? ? @__params : default_args
T.unsafe(RubyLsp::Requests::OnTypeFormatting).new(document, *params).run
end

def default_args
[{ line: 1, character: 0 }, "\n"]
end
end
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

0 comments on commit edce6de

Please sign in to comment.