diff --git a/jekyll/images/range_formatting.gif b/jekyll/images/range_formatting.gif new file mode 100644 index 000000000..a03aeff48 Binary files /dev/null and b/jekyll/images/range_formatting.gif differ diff --git a/jekyll/index.markdown b/jekyll/index.markdown index bfd143889..1ac195ff5 100644 --- a/jekyll/index.markdown +++ b/jekyll/index.markdown @@ -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 diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 0e9171322..bcf595723 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -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" diff --git a/lib/ruby_lsp/requests/range_formatting.rb b/lib/ruby_lsp/requests/range_formatting.rb new file mode 100644 index 000000000..63705d4f8 --- /dev/null +++ b/lib/ruby_lsp/requests/range_formatting.rb @@ -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 diff --git a/lib/ruby_lsp/requests/support/formatter.rb b/lib/ruby_lsp/requests/support/formatter.rb index d7f1d47ab..5e3d5c51e 100644 --- a/lib/ruby_lsp/requests/support/formatter.rb +++ b/lib/ruby_lsp/requests/support/formatter.rb @@ -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, diff --git a/lib/ruby_lsp/requests/support/rubocop_formatter.rb b/lib/ruby_lsp/requests/support/rubocop_formatter.rb index 9a080edd7..4050eb4aa 100644 --- a/lib/ruby_lsp/requests/support/rubocop_formatter.rb +++ b/lib/ruby_lsp/requests/support/rubocop_formatter.rb @@ -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, diff --git a/lib/ruby_lsp/requests/support/syntax_tree_formatter.rb b/lib/ruby_lsp/requests/support/syntax_tree_formatter.rb index c0b8a3b84..dab58b7f5 100644 --- a/lib/ruby_lsp/requests/support/syntax_tree_formatter.rb +++ b/lib/ruby_lsp/requests/support/syntax_tree_formatter.rb @@ -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, diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 1da67ff4d..2a00e1983 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -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" @@ -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, }, @@ -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 diff --git a/test/requests/formatting_test.rb b/test/requests/formatting_test.rb index 34b8980f4..6da6ee46c 100644 --- a/test/requests/formatting_test.rb +++ b/test/requests/formatting_test.rb @@ -160,7 +160,6 @@ 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 @@ -168,20 +167,11 @@ def formatted_document(formatter) 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 diff --git a/test/requests/range_formatting_test.rb b/test/requests/range_formatting_test.rb new file mode 100644 index 000000000..628ff400f --- /dev/null +++ b/test/requests/range_formatting_test.rb @@ -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 diff --git a/test/server_test.rb b/test/server_test.rb index 5779a312d..42982266f 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -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