diff --git a/jekyll/images/rename.gif b/jekyll/images/rename.gif new file mode 100644 index 0000000000..94618115f5 Binary files /dev/null and b/jekyll/images/rename.gif differ diff --git a/jekyll/index.markdown b/jekyll/index.markdown index 6aabf98d99..a3333a902d 100644 --- a/jekyll/index.markdown +++ b/jekyll/index.markdown @@ -45,6 +45,7 @@ Want to discuss Ruby developer experience? Consider joining the public - [Show syntax tree](#show-syntax-tree) - [ERB support](#erb-support) - [Guessed types](#guessed-types) + - [Rename symbol](#rename-symbol) - [VS Code only features](#vs-code-features) - [Dependencies view](#dependencies-view) - [Rails generator integrations](#rails-generator-integrations) @@ -409,6 +410,14 @@ end # randomly user.a ``` +### Rename symbol + +Rename allows developers to rename all occurrences of the entity under the cursor across the entire project. In VS Code +renaming can be triggered by right clicking the entity to rename or by pressing F2 on it. You can also preview the +edits that will be applied by pressing CTRL/CMD + Enter after typing the desired new name. + +![Rename demo](images/rename.gif) + ## VS Code features The following features are all custom made for VS Code. diff --git a/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb b/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb new file mode 100644 index 0000000000..69a9cb1ac6 --- /dev/null +++ b/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb @@ -0,0 +1,262 @@ +# typed: strict +# frozen_string_literal: true + +module RubyIndexer + class ReferenceFinder + extend T::Sig + + class Reference + extend T::Sig + + sig { returns(String) } + attr_reader :name + + sig { returns(Prism::Location) } + attr_reader :location + + sig { params(name: String, location: Prism::Location).void } + def initialize(name, location) + @name = name + @location = location + end + end + + sig { returns(T::Array[Reference]) } + attr_reader :references + + sig do + params( + fully_qualified_name: String, + index: RubyIndexer::Index, + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(fully_qualified_name, index, dispatcher) + @fully_qualified_name = fully_qualified_name + @index = index + @stack = T.let([], T::Array[String]) + @references = T.let([], T::Array[Reference]) + + dispatcher.register( + self, + :on_class_node_enter, + :on_class_node_leave, + :on_module_node_enter, + :on_module_node_leave, + :on_singleton_class_node_enter, + :on_singleton_class_node_leave, + :on_def_node_enter, + :on_def_node_leave, + :on_multi_write_node_enter, + :on_constant_path_write_node_enter, + :on_constant_path_or_write_node_enter, + :on_constant_path_operator_write_node_enter, + :on_constant_path_and_write_node_enter, + :on_constant_or_write_node_enter, + :on_constant_path_node_enter, + :on_constant_read_node_enter, + :on_constant_write_node_enter, + :on_constant_or_write_node_enter, + :on_constant_and_write_node_enter, + :on_constant_operator_write_node_enter, + ) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + constant_path = node.constant_path + name = constant_path.slice + nesting = actual_nesting(name) + + if nesting.join("::") == @fully_qualified_name + @references << Reference.new(name, constant_path.location) + end + + @stack << name + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_leave(node) + @stack.pop + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_enter(node) + constant_path = node.constant_path + name = constant_path.slice + nesting = actual_nesting(name) + + if nesting.join("::") == @fully_qualified_name + @references << Reference.new(name, constant_path.location) + end + + @stack << name + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_leave(node) + @stack.pop + end + + sig { params(node: Prism::SingletonClassNode).void } + def on_singleton_class_node_enter(node) + expression = node.expression + return unless expression.is_a?(Prism::SelfNode) + + @stack << "" + end + + sig { params(node: Prism::SingletonClassNode).void } + def on_singleton_class_node_leave(node) + @stack.pop + end + + sig { params(node: Prism::ConstantPathNode).void } + def on_constant_path_node_enter(node) + name = constant_name(node) + return unless name + + collect_constant_references(name, node.location) + end + + sig { params(node: Prism::ConstantReadNode).void } + def on_constant_read_node_enter(node) + name = constant_name(node) + return unless name + + collect_constant_references(name, node.location) + end + + sig { params(node: Prism::MultiWriteNode).void } + def on_multi_write_node_enter(node) + [*node.lefts, *node.rest, *node.rights].each do |target| + case target + when Prism::ConstantTargetNode, Prism::ConstantPathTargetNode + collect_constant_references(target.name.to_s, target.location) + end + end + end + + sig { params(node: Prism::ConstantPathWriteNode).void } + def on_constant_path_write_node_enter(node) + target = node.target + return unless target.parent.nil? || target.parent.is_a?(Prism::ConstantReadNode) + + name = constant_name(target) + return unless name + + collect_constant_references(name, target.location) + end + + sig { params(node: Prism::ConstantPathOrWriteNode).void } + def on_constant_path_or_write_node_enter(node) + target = node.target + return unless target.parent.nil? || target.parent.is_a?(Prism::ConstantReadNode) + + name = constant_name(target) + return unless name + + collect_constant_references(name, target.location) + end + + sig { params(node: Prism::ConstantPathOperatorWriteNode).void } + def on_constant_path_operator_write_node_enter(node) + target = node.target + return unless target.parent.nil? || target.parent.is_a?(Prism::ConstantReadNode) + + name = constant_name(target) + return unless name + + collect_constant_references(name, target.location) + end + + sig { params(node: Prism::ConstantPathAndWriteNode).void } + def on_constant_path_and_write_node_enter(node) + target = node.target + return unless target.parent.nil? || target.parent.is_a?(Prism::ConstantReadNode) + + name = constant_name(target) + return unless name + + collect_constant_references(name, target.location) + end + + sig { params(node: Prism::ConstantWriteNode).void } + def on_constant_write_node_enter(node) + collect_constant_references(node.name.to_s, node.name_loc) + end + + sig { params(node: Prism::ConstantOrWriteNode).void } + def on_constant_or_write_node_enter(node) + collect_constant_references(node.name.to_s, node.name_loc) + end + + sig { params(node: Prism::ConstantAndWriteNode).void } + def on_constant_and_write_node_enter(node) + collect_constant_references(node.name.to_s, node.name_loc) + end + + sig { params(node: Prism::ConstantOperatorWriteNode).void } + def on_constant_operator_write_node_enter(node) + collect_constant_references(node.name.to_s, node.name_loc) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + if node.receiver.is_a?(Prism::SelfNode) + @stack << "" + end + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_leave(node) + if node.receiver.is_a?(Prism::SelfNode) + @stack.pop + end + end + + private + + sig { params(name: String).returns(T::Array[String]) } + def actual_nesting(name) + nesting = @stack + [name] + corrected_nesting = [] + + nesting.reverse_each do |name| + corrected_nesting.prepend(name.delete_prefix("::")) + + break if name.start_with?("::") + end + + corrected_nesting + end + + sig { params(name: String, location: Prism::Location).void } + def collect_constant_references(name, location) + entries = @index.resolve(name, @stack) + return unless entries + + entries.each do |entry| + next unless entry.name == @fully_qualified_name + + @references << Reference.new(name, location) + end + end + + sig do + params( + node: T.any( + Prism::ConstantPathNode, + Prism::ConstantReadNode, + Prism::ConstantPathTargetNode, + ), + ).returns(T.nilable(String)) + end + def constant_name(node) + node.full_name + rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError, + Prism::ConstantPathNode::MissingNodesInConstantPathError + nil + end + end +end diff --git a/lib/ruby_indexer/ruby_indexer.rb b/lib/ruby_indexer/ruby_indexer.rb index 996af2f8ca..2d888428e9 100644 --- a/lib/ruby_indexer/ruby_indexer.rb +++ b/lib/ruby_indexer/ruby_indexer.rb @@ -6,6 +6,7 @@ require "ruby_indexer/lib/ruby_indexer/indexable_path" require "ruby_indexer/lib/ruby_indexer/declaration_listener" +require "ruby_indexer/lib/ruby_indexer/reference_finder" require "ruby_indexer/lib/ruby_indexer/enhancement" require "ruby_indexer/lib/ruby_indexer/index" require "ruby_indexer/lib/ruby_indexer/entry" diff --git a/lib/ruby_indexer/test/reference_finder_test.rb b/lib/ruby_indexer/test/reference_finder_test.rb new file mode 100644 index 0000000000..c75d724710 --- /dev/null +++ b/lib/ruby_indexer/test/reference_finder_test.rb @@ -0,0 +1,86 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module RubyIndexer + class ReferenceFinderTest < Minitest::Test + def test_finds_constant_references + refs = find_references("Foo::Bar", <<~RUBY) + module Foo + class Bar + end + + Bar + end + + Foo::Bar + RUBY + + assert_equal("Bar", refs[0].name) + assert_equal(2, refs[0].location.start_line) + + assert_equal("Bar", refs[1].name) + assert_equal(5, refs[1].location.start_line) + + assert_equal("Foo::Bar", refs[2].name) + assert_equal(8, refs[2].location.start_line) + end + + def test_finds_constant_references_inside_singleton_contexts + refs = find_references("Foo::::Bar", <<~RUBY) + class Foo + class << self + class Bar + end + + Bar + end + end + RUBY + + assert_equal("Bar", refs[0].name) + assert_equal(3, refs[0].location.start_line) + + assert_equal("Bar", refs[1].name) + assert_equal(6, refs[1].location.start_line) + end + + def test_finds_top_level_constant_references + refs = find_references("Bar", <<~RUBY) + class Bar + end + + class Foo + ::Bar + + class << self + ::Bar + end + end + RUBY + + assert_equal("Bar", refs[0].name) + assert_equal(1, refs[0].location.start_line) + + assert_equal("::Bar", refs[1].name) + assert_equal(5, refs[1].location.start_line) + + assert_equal("::Bar", refs[2].name) + assert_equal(8, refs[2].location.start_line) + end + + private + + def find_references(fully_qualified_name, source) + file_path = "/fake.rb" + index = Index.new + index.index_single(IndexablePath.new(nil, file_path), source) + parse_result = Prism.parse(source) + dispatcher = Prism::Dispatcher.new + finder = ReferenceFinder.new(fully_qualified_name, index, dispatcher) + dispatcher.visit(parse_result.value) + finder.references.uniq(&:location) + end + end +end diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index b7d5da0024..2478dbe801 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -23,6 +23,9 @@ class GlobalState sig { returns(T::Boolean) } attr_reader :supports_watching_files, :experimental_features, :supports_request_delegation + sig { returns(T::Array[String]) } + attr_reader :supported_resource_operations + sig { returns(TypeInferrer) } attr_reader :type_inferrer @@ -42,6 +45,7 @@ def initialize @type_inferrer = T.let(TypeInferrer.new(@index), TypeInferrer) @addon_settings = T.let({}, T::Hash[String, T.untyped]) @supports_request_delegation = T.let(false, T::Boolean) + @supported_resource_operations = T.let([], T::Array[String]) end sig { params(addon_name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) } @@ -133,6 +137,9 @@ def apply_options(options) end @supports_request_delegation = options.dig(:capabilities, :experimental, :requestDelegation) || false + supported_resource_operations = options.dig(:capabilities, :workspace, :workspaceEdit, :resourceOperations) + @supported_resource_operations = supported_resource_operations if supported_resource_operations + notifications end diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 97049c55a3..13c0ca47ac 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -81,3 +81,4 @@ require "ruby_lsp/requests/signature_help" require "ruby_lsp/requests/type_hierarchy_supertypes" require "ruby_lsp/requests/workspace_symbol" +require "ruby_lsp/requests/rename" diff --git a/lib/ruby_lsp/requests/rename.rb b/lib/ruby_lsp/requests/rename.rb new file mode 100644 index 0000000000..8896aa03d7 --- /dev/null +++ b/lib/ruby_lsp/requests/rename.rb @@ -0,0 +1,189 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Requests + # The + # [rename](https://microsoft.github.io/language-server-protocol/specification#textDocument_rename) + # request renames all instances of a symbol in a document. + class Rename < Request + extend T::Sig + include Support::Common + + class InvalidNameError < StandardError; end + + sig do + params( + global_state: GlobalState, + store: Store, + document: T.any(RubyDocument, ERBDocument), + params: T::Hash[Symbol, T.untyped], + ).void + end + def initialize(global_state, store, document, params) + super() + @global_state = global_state + @store = store + @document = document + @params = params + end + + sig { override.returns(T.nilable(Interface::WorkspaceEdit)) } + def perform + position = @params[:position] + char_position = @document.create_scanner.find_char_position(position) + + node_context = RubyDocument.locate( + @document.parse_result.value, + char_position, + node_types: [Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::ConstantPathTargetNode], + ) + target = node_context.node + parent = node_context.parent + return if !target || target.is_a?(Prism::ProgramNode) + + if target.is_a?(Prism::ConstantReadNode) && parent.is_a?(Prism::ConstantPathNode) + target = determine_target( + target, + parent, + position, + ) + end + + target = T.cast( + target, + T.any(Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::ConstantPathTargetNode), + ) + + name = constant_name(target) + return unless name + + entries = @global_state.index.resolve(name, node_context.nesting) + return unless entries + + if (conflict_entries = @global_state.index.resolve(@params[:newName], node_context.nesting)) + raise InvalidNameError, "The new name is already in use by #{T.must(conflict_entries.first).name}" + end + + fully_qualified_name = T.must(entries.first).name + changes = collect_text_edits(fully_qualified_name, name) + + # If the client doesn't support resource operations, such as renaming files, then we can only return the basic + # text changes + unless @global_state.supported_resource_operations.include?("rename") + return Interface::WorkspaceEdit.new(changes: changes) + end + + # Text edits must be applied before any resource operations, such as renaming files. Otherwise, the file is + # renamed and then the URI associated to the text edit no longer exists, causing it to be dropped + document_changes = changes.map do |uri, edits| + Interface::TextDocumentEdit.new( + text_document: Interface::VersionedTextDocumentIdentifier.new(uri: uri, version: nil), + edits: edits, + ) + end + + collect_file_renames(fully_qualified_name, document_changes) + Interface::WorkspaceEdit.new(document_changes: document_changes) + end + + private + + sig do + params( + fully_qualified_name: String, + document_changes: T::Array[T.any(Interface::RenameFile, Interface::TextDocumentEdit)], + ).void + end + def collect_file_renames(fully_qualified_name, document_changes) + # Check if the declarations of the symbol being renamed match the file name. In case they do, we automatically + # rename the files for the user. + # + # We also look for an associated test file and rename it too + short_name = T.must(fully_qualified_name.split("::").last) + + T.must(@global_state.index[fully_qualified_name]).each do |entry| + # Do not rename files that are not part of the workspace + next unless entry.file_path.start_with?(@global_state.workspace_path) + + case entry + when RubyIndexer::Entry::Class, RubyIndexer::Entry::Module, RubyIndexer::Entry::Constant, + RubyIndexer::Entry::ConstantAlias, RubyIndexer::Entry::UnresolvedConstantAlias + + file_name = file_from_constant_name(short_name) + + if "#{file_name}.rb" == entry.file_name + new_file_name = file_from_constant_name(@params[:newName].split("::").last) + + old_uri = URI::Generic.from_path(path: entry.file_path).to_s + new_uri = URI::Generic.from_path(path: File.join( + File.dirname(entry.file_path), + "#{new_file_name}.rb", + )).to_s + + document_changes << Interface::RenameFile.new(kind: "rename", old_uri: old_uri, new_uri: new_uri) + end + end + end + end + + sig { params(fully_qualified_name: String, name: String).returns(T::Hash[String, T::Array[Interface::TextEdit]]) } + def collect_text_edits(fully_qualified_name, name) + changes = {} + + Dir.glob(File.join(@global_state.workspace_path, "**/*.rb")).each do |path| + uri = URI::Generic.from_path(path: path) + # If the document is being managed by the client, then we should use whatever is present in the store instead + # of reading from disk + next if @store.key?(uri) + + parse_result = Prism.parse_file(path) + edits = collect_changes(fully_qualified_name, parse_result, name, uri) + changes[uri.to_s] = edits unless edits.empty? + end + + @store.each do |uri, document| + edits = collect_changes(fully_qualified_name, document.parse_result, name, document.uri) + changes[uri] = edits unless edits.empty? + end + + changes + end + + sig do + params( + fully_qualified_name: String, + parse_result: Prism::ParseResult, + name: String, + uri: URI::Generic, + ).returns(T::Array[Interface::TextEdit]) + end + def collect_changes(fully_qualified_name, parse_result, name, uri) + dispatcher = Prism::Dispatcher.new + finder = RubyIndexer::ReferenceFinder.new(fully_qualified_name, @global_state.index, dispatcher) + dispatcher.visit(parse_result.value) + + finder.references.uniq(&:location).map do |reference| + adjust_reference_for_edit(name, reference) + end + end + + sig { params(name: String, reference: RubyIndexer::ReferenceFinder::Reference).returns(Interface::TextEdit) } + def adjust_reference_for_edit(name, reference) + # The reference may include a namespace in front. We need to check if the rename new name includes namespaces + # and then adjust both the text and the location to produce the correct edit + location = reference.location + new_text = reference.name.sub(name, @params[:newName]) + + Interface::TextEdit.new(range: range_from_location(location), new_text: new_text) + end + + sig { params(constant_name: String).returns(String) } + def file_from_constant_name(constant_name) + constant_name + .gsub(/([a-z])([A-Z])|([A-Z])([A-Z][a-z])/, '\1\3_\2\4') + .downcase + end + end + end +end diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index bf3aedaea4..c65fe4e236 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -67,6 +67,8 @@ def process_message(message) text_document_definition(message) when "textDocument/prepareTypeHierarchy" text_document_prepare_type_hierarchy(message) + when "textDocument/rename" + text_document_rename(message) when "typeHierarchy/supertypes" type_hierarchy_supertypes(message) when "typeHierarchy/subtypes" @@ -227,6 +229,7 @@ def run_initialize(message) workspace_symbol_provider: enabled_features["workspaceSymbol"] && !@global_state.has_type_checker, signature_help_provider: signature_help_provider, type_hierarchy_provider: type_hierarchy_provider, + rename_provider: !@global_state.has_type_checker, experimental: { addon_detection: true, }, @@ -613,6 +616,26 @@ def text_document_hover(message) ) end + sig { params(message: T::Hash[Symbol, T.untyped]).void } + def text_document_rename(message) + params = message[:params] + document = @store.get(params.dig(:textDocument, :uri)) + + unless document.is_a?(RubyDocument) + send_empty_response(message[:id]) + return + end + + send_message( + Result.new( + id: message[:id], + response: Requests::Rename.new(@global_state, @store, document, params).perform, + ), + ) + rescue Requests::Rename::InvalidNameError => e + send_message(Error.new(id: message[:id], code: Constant::ErrorCodes::REQUEST_FAILED, message: e.message)) + end + sig { params(document: Document[T.untyped]).returns(RubyDocument::SorbetLevel) } def sorbet_level(document) return RubyDocument::SorbetLevel::Ignore unless @global_state.has_type_checker diff --git a/lib/ruby_lsp/store.rb b/lib/ruby_lsp/store.rb index 2426051a94..4de87f2f15 100644 --- a/lib/ruby_lsp/store.rb +++ b/lib/ruby_lsp/store.rb @@ -99,6 +99,18 @@ def delete(uri) @state.delete(uri.to_s) end + sig { params(uri: URI::Generic).returns(T::Boolean) } + def key?(uri) + @state.key?(uri.to_s) + end + + sig { params(block: T.proc.params(uri: String, document: Document[T.untyped]).void).void } + def each(&block) + @state.each do |uri, document| + block.call(uri, document) + end + end + sig do type_parameters(:T) .params( diff --git a/test/fixtures/rename_me.rb b/test/fixtures/rename_me.rb new file mode 100644 index 0000000000..24639c782b --- /dev/null +++ b/test/fixtures/rename_me.rb @@ -0,0 +1,4 @@ +class RenameMe +end + +RenameMe diff --git a/test/requests/rename_test.rb b/test/requests/rename_test.rb new file mode 100644 index 0000000000..643c926b4f --- /dev/null +++ b/test/requests/rename_test.rb @@ -0,0 +1,100 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +class RenameTest < Minitest::Test + def test_empty_diagnostics_for_ignored_file + expected = <<~RUBY + class Article + end + + Article + RUBY + + expect_renames( + "test/fixtures/rename_me.rb", + File.join("test", "fixtures", "article.rb"), + expected, + { line: 0, character: 7 }, + "Article", + ) + end + + def test_renaming_conflict + fixture_path = "test/fixtures/rename_me.rb" + source = File.read(fixture_path) + global_state = RubyLsp::GlobalState.new + global_state.apply_options({ + capabilities: { + workspace: { + workspaceEdit: { + resourceOperations: ["rename"], + }, + }, + }, + }) + path = File.expand_path(fixture_path) + global_state.index.index_single(RubyIndexer::IndexablePath.new(nil, path), source) + global_state.index.index_single(RubyIndexer::IndexablePath.new(nil, "/fake.rb"), <<~RUBY) + class Conflicting + end + RUBY + + store = RubyLsp::Store.new + document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: URI::Generic.from_path(path: path)) + + assert_raises(RubyLsp::Requests::Rename::InvalidNameError) do + RubyLsp::Requests::Rename.new( + global_state, + store, + document, + { position: { line: 3, character: 7 }, newName: "Conflicting" }, + ).perform + end + end + + private + + def expect_renames(fixture_path, new_fixture_path, expected, position, new_name) + source = File.read(fixture_path) + global_state = RubyLsp::GlobalState.new + global_state.apply_options({ + capabilities: { + workspace: { + workspaceEdit: { + resourceOperations: ["rename"], + }, + }, + }, + }) + path = File.expand_path(fixture_path) + global_state.index.index_single(RubyIndexer::IndexablePath.new(nil, path), source) + + store = RubyLsp::Store.new + document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: URI::Generic.from_path(path: path)) + workspace_edit = T.must( + RubyLsp::Requests::Rename.new( + global_state, + store, + document, + { position: position, newName: new_name }, + ).perform, + ) + + file_renames = workspace_edit.document_changes.filter_map do |text_edit_or_rename| + next text_edit_or_rename unless text_edit_or_rename.is_a?(RubyLsp::Interface::TextDocumentEdit) + + document.push_edits( + text_edit_or_rename.edits.map do |edit| + { range: edit.range.to_hash.transform_values(&:to_hash), text: edit.new_text } + end, + version: 2, + ) + nil + end + + assert_equal(expected, document.source) + assert_equal(File.expand_path(new_fixture_path), URI(file_renames.first.new_uri).to_standardized_path) + end +end