# 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 @position = T.let(params[:position], T::Hash[Symbol, Integer]) @new_name = T.let(params[:newName], String) end sig { override.returns(T.nilable(Interface::WorkspaceEdit)) } def perform 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], code_units_cache: @document.code_units_cache, ) 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(@new_name, 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 reference_target = RubyIndexer::ReferenceFinder::ConstTarget.new(fully_qualified_name) changes = collect_text_edits(reference_target, 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.client_capabilities.supports_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(T.must(@new_name.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 do params( target: RubyIndexer::ReferenceFinder::Target, name: String, ).returns(T::Hash[String, T::Array[Interface::TextEdit]]) end def collect_text_edits(target, 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(target, parse_result, name, uri) changes[uri.to_s] = edits unless edits.empty? rescue Errno::EISDIR, Errno::ENOENT # If `path` is a directory, just ignore it and continue. If the file doesn't exist, then we also ignore it. end @store.each do |uri, document| edits = collect_changes(target, document.parse_result, name, document.uri) changes[uri] = edits unless edits.empty? end changes end sig do params( target: RubyIndexer::ReferenceFinder::Target, parse_result: Prism::ParseResult, name: String, uri: URI::Generic, ).returns(T::Array[Interface::TextEdit]) end def collect_changes(target, parse_result, name, uri) dispatcher = Prism::Dispatcher.new finder = RubyIndexer::ReferenceFinder.new(target, @global_state.index, dispatcher) dispatcher.visit(parse_result.value) finder.references.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, @new_name) 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