Skip to content

Commit

Permalink
Add support for range formatting with Syntax Tree
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Oct 2, 2024
1 parent 970b466 commit 67c25e5
Show file tree
Hide file tree
Showing 11 changed files with 178 additions and 14 deletions.
Binary file added jekyll/images/range_formatting.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions jekyll/index.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,22 @@ In VS Code, format on type is disabled by default. You can enable it with `"edit

![On type formatting demo](images/on_type_formatting.gif)

### Range formatting

Range formatting allows users to format a selection in the editor, without formatting the entire file. It is also the
feature that enables format on paste to work.

{: .note }
In VS Code, format on paste is disabled by default. You can enable it with `"editor.formatOnPaste": true`

{: .note }
Currently, only the [Syntax Tree](https://github.com/ruby-syntax-tree/syntax_tree) formatter has support for partially
formatting a file. Supporting range formatting for RuboCop or Standard requires new APIs to be exposed so that the
Ruby LSP can inform the formatter of the base indentation at the place of the selection. Additionally, the formatter
can only apply corrections that make sense for the portion of the document.

![Range formatting demo](images/range_formatting.gif)

### Selection range

Selection range (or smart ranges) expands or shrinks a selection based on the code's constructs. In VS Code, this can
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
require "ruby_lsp/requests/inlay_hints"
require "ruby_lsp/requests/on_type_formatting"
require "ruby_lsp/requests/prepare_type_hierarchy"
require "ruby_lsp/requests/range_formatting"
require "ruby_lsp/requests/references"
require "ruby_lsp/requests/rename"
require "ruby_lsp/requests/selection_ranges"
Expand Down
55 changes: 55 additions & 0 deletions lib/ruby_lsp/requests/range_formatting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Requests
# The [range formatting](https://microsoft.github.io/language-server-protocol/specification#textDocument_rangeFormatting)
# is used to format a selection or to format on paste.
class RangeFormatting < Request
extend T::Sig

sig { params(global_state: GlobalState, document: RubyDocument, params: T::Hash[Symbol, T.untyped]).void }
def initialize(global_state, document, params)
super()
@document = document
@uri = T.let(document.uri, URI::Generic)
@params = params
@active_formatter = T.let(global_state.active_formatter, T.nilable(Support::Formatter))
end

sig { override.returns(T.nilable(T::Array[Interface::TextEdit])) }
def perform
return unless @active_formatter
return if @document.syntax_error?

target = @document.locate_first_within_range(@params[:range])
return unless target

location = target.location

formatted_text = @active_formatter.run_range_formatting(
@uri,
target.slice,
location.start_column / 2,
)
return unless formatted_text

[
Interface::TextEdit.new(
range: Interface::Range.new(
start: Interface::Position.new(
line: location.start_line - 1,
character: location.start_code_units_column(@document.encoding),
),
end: Interface::Position.new(
line: location.end_line - 1,
character: location.end_code_units_column(@document.encoding),
),
),
new_text: formatted_text.strip,
),
]
end
end
end
end
3 changes: 3 additions & 0 deletions lib/ruby_lsp/requests/support/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ module Formatter
sig { abstract.params(uri: URI::Generic, document: RubyDocument).returns(T.nilable(String)) }
def run_formatting(uri, document); end

sig { abstract.params(uri: URI::Generic, source: String, base_indentation: Integer).returns(T.nilable(String)) }
def run_range_formatting(uri, source, base_indentation); end

sig do
abstract.params(
uri: URI::Generic,
Expand Down
6 changes: 6 additions & 0 deletions lib/ruby_lsp/requests/support/rubocop_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ def run_formatting(uri, document)
@format_runner.formatted_source
end

# RuboCop does not support range formatting
sig { override.params(uri: URI::Generic, source: String, base_indentation: Integer).returns(T.nilable(String)) }
def run_range_formatting(uri, source, base_indentation)
nil
end

sig do
override.params(
uri: URI::Generic,
Expand Down
8 changes: 8 additions & 0 deletions lib/ruby_lsp/requests/support/syntax_tree_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ def run_formatting(uri, document)
SyntaxTree.format(document.source, @options.print_width, options: @options.formatter_options)
end

sig { override.params(uri: URI::Generic, source: String, base_indentation: Integer).returns(T.nilable(String)) }
def run_range_formatting(uri, source, base_indentation)
path = uri.to_standardized_path
return if path && @options.ignore_files.any? { |pattern| File.fnmatch?("*/#{pattern}", path) }

SyntaxTree.format(source, @options.print_width, base_indentation, options: @options.formatter_options)
end

sig do
override.params(
uri: URI::Generic,
Expand Down
31 changes: 31 additions & 0 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def process_message(message)
text_document_semantic_tokens_range(message)
when "textDocument/formatting"
text_document_formatting(message)
when "textDocument/rangeFormatting"
text_document_range_formatting(message)
when "textDocument/documentHighlight"
text_document_document_highlight(message)
when "textDocument/onTypeFormatting"
Expand Down Expand Up @@ -233,6 +235,7 @@ def run_initialize(message)
type_hierarchy_provider: type_hierarchy_provider,
rename_provider: !@global_state.has_type_checker,
references_provider: !@global_state.has_type_checker,
document_range_formatting_provider: true,
experimental: {
addon_detection: true,
},
Expand Down Expand Up @@ -516,6 +519,34 @@ def text_document_semantic_tokens_range(message)
send_message(Result.new(id: message[:id], response: request.perform))
end

sig { params(message: T::Hash[Symbol, T.untyped]).void }
def text_document_range_formatting(message)
# If formatter is set to `auto` but no supported formatting gem is found, don't attempt to format
if @global_state.formatter == "none"
send_empty_response(message[:id])
return
end

params = message[:params]
uri = params.dig(:textDocument, :uri)
# Do not format files outside of the workspace. For example, if someone is looking at a gem's source code, we
# don't want to format it
path = uri.to_standardized_path
unless path.nil? || path.start_with?(@global_state.workspace_path)
send_empty_response(message[:id])
return
end

document = @store.get(uri)
unless document.is_a?(RubyDocument)
send_empty_response(message[:id])
return
end

response = Requests::RangeFormatting.new(@global_state, document, params).perform
send_message(Result.new(id: message[:id], response: response))
end

sig { params(message: T::Hash[Symbol, T.untyped]).void }
def text_document_formatting(message)
# If formatter is set to `auto` but no supported formatting gem is found, don't attempt to format
Expand Down
14 changes: 2 additions & 12 deletions test/requests/formatting_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,28 +160,18 @@ def test_returns_nil_when_formatter_is_none
private

def formatted_document(formatter)
require "ruby_lsp/requests/formatting"
@global_state.formatter = formatter
RubyLsp::Requests::Formatting.new(@global_state, @document).perform&.first&.new_text
end

def with_syntax_tree_config_file(contents)
filepath = File.join(Dir.pwd, ".streerc")
File.write(filepath, contents)
clear_syntax_tree_runner_singleton_instance
formatter_with_options = RubyLsp::Requests::Support::SyntaxTreeFormatter.new
@global_state.stubs(:active_formatter).returns(formatter_with_options)

yield
ensure
FileUtils.rm(filepath) if filepath
clear_syntax_tree_runner_singleton_instance
end

def clear_syntax_tree_runner_singleton_instance
return unless defined?(RubyLsp::Requests::Support::SyntaxTreeFormatter)

@global_state.register_formatter(
"syntax_tree",
RubyLsp::Requests::Support::SyntaxTreeFormatter.new,
)
end
end
54 changes: 54 additions & 0 deletions test/requests/range_formatting_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# typed: true
# frozen_string_literal: true

require "test_helper"

class RangeFormattingTest < Minitest::Test
def setup
@global_state = RubyLsp::GlobalState.new
@global_state.formatter = "syntax_tree"
regular_formatter = RubyLsp::Requests::Support::SyntaxTreeFormatter.new
@global_state.register_formatter("syntax_tree", regular_formatter)
@global_state.stubs(:active_formatter).returns(regular_formatter)
@document = RubyLsp::RubyDocument.new(source: +<<~RUBY, version: 1, uri: URI::Generic.from_path(path: __FILE__))
class Foo
def foo
[
1,
2,
3,
4,
]
end
end
RUBY
end

def test_syntax_tree_supports_range_formatting
# Note how only the selected array is formatted, otherwise the blank lines would be removed
expect_formatted_range({ start: { line: 3, character: 2 }, end: { line: 8, character: 5 } }, <<~RUBY)
class Foo
def foo
[1, 2, 3, 4]
end
end
RUBY
end

private

def expect_formatted_range(range, expected)
edits = T.must(RubyLsp::Requests::RangeFormatting.new(@global_state, @document, { range: range }).perform)

@document.push_edits(
edits.map do |edit|
{ range: edit.range.to_hash.transform_values(&:to_hash), text: edit.new_text }
end,
version: 2,
)

assert_equal(expected, @document.source)
end
end
4 changes: 2 additions & 2 deletions test/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ def test_initialize_enabled_features_with_array
hash = JSON.parse(result.response.to_json)
capabilities = hash["capabilities"]

# TextSynchronization + encodings + semanticHighlighting + experimental
assert_equal(4, capabilities.length)
# TextSynchronization + encodings + semanticHighlighting + range formatting + experimental
assert_equal(5, capabilities.length)
assert_includes(capabilities, "semanticTokensProvider")
end

Expand Down

0 comments on commit 67c25e5

Please sign in to comment.