diff --git a/jekyll/images/rename.gif b/jekyll/images/rename.gif new file mode 100644 index 000000000..94618115f Binary files /dev/null and b/jekyll/images/rename.gif differ diff --git a/jekyll/index.markdown b/jekyll/index.markdown index 73cefd56e..6813a5d00 100644 --- a/jekyll/index.markdown +++ b/jekyll/index.markdown @@ -44,6 +44,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](#rename) - [VS Code only features](#vs-code-features) - [Dependencies view](#dependencies-view) - [Rails generator integrations](#rails-generator-integrations) @@ -403,6 +404,13 @@ end # randomly user.a ``` +### Rename + +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. + +![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 000000000..8a4663b72 --- /dev/null +++ b/lib/ruby_indexer/lib/ruby_indexer/reference_finder.rb @@ -0,0 +1,264 @@ +# 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, + parse_result: Prism::ParseResult, + ).void + end + def initialize(fully_qualified_name, index, dispatcher, parse_result) + @fully_qualified_name = fully_qualified_name + @index = index + @inside_def = T.let(false, T::Boolean) + @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 996af2f8c..2d888428e 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 000000000..d0c2f4ba9 --- /dev/null +++ b/lib/ruby_indexer/test/reference_finder_test.rb @@ -0,0 +1,43 @@ +# 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 + + 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, parse_result) + dispatcher.visit(parse_result.value) + finder.references.uniq(&:location) + end + end +end diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 265eb4312..ddb93c6c3 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -80,3 +80,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 000000000..39021e605 --- /dev/null +++ b/lib/ruby_lsp/requests/rename.rb @@ -0,0 +1,98 @@ +# 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 + + sig do + params( + global_state: GlobalState, + document: T.any(RubyDocument, ERBDocument), + params: T::Hash[Symbol, T.untyped], + ).void + end + def initialize(global_state, document, params) + super() + @global_state = global_state + @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 unless target + + 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 + + fully_qualified_name = T.must(entries.first).name + + changes = {} + + Dir.glob(File.join(@global_state.workspace_path, "**/*.rb")).each do |path| + parse_result = Prism.parse_file(path) + dispatcher = Prism::Dispatcher.new + finder = RubyIndexer::ReferenceFinder.new( + fully_qualified_name, + @global_state.index, + dispatcher, + parse_result, + ) + dispatcher.visit(parse_result.value) + + edits = finder.references.uniq(&:location).map do |reference| + adjust_reference_for_edit(name, reference) + end + + changes[URI::Generic.from_path(path: path).to_s] = edits unless edits.empty? + end + + Interface::WorkspaceEdit.new(changes: changes) + end + + private + + 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 + end + end +end diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index b959d9f27..e0f4fc2f0 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, }, @@ -602,6 +605,24 @@ 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, document, params).perform, + ), + ) + 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/test/fixtures/rename.rb b/test/fixtures/rename.rb new file mode 100644 index 000000000..24639c782 --- /dev/null +++ b/test/fixtures/rename.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 000000000..0619f0ec6 --- /dev/null +++ b/test/requests/rename_test.rb @@ -0,0 +1,45 @@ +# 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.rb", expected, { line: 0, character: 7 }, "Article") + end + + private + + def expect_renames(fixture_path, expected, position, new_name) + source = File.read(fixture_path) + global_state = RubyLsp::GlobalState.new + global_state.index.index_single(RubyIndexer::IndexablePath.new(nil, "/fake.rb"), source) + + document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: URI::Generic.from_path(path: "/fake.rb")) + workspace_edit = T.must( + RubyLsp::Requests::Rename.new( + global_state, + document, + { position: position, newName: new_name }, + ).perform, + ) + + workspace_edit.changes.each do |_uri, edits| + document.push_edits( + edits.map do |edit| + { range: edit.range.to_hash.transform_values(&:to_hash), text: edit.new_text } + end, + version: 2, + ) + end + + assert_equal(expected, document.source) + end +end