# 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
include Support::Common
class InvalidNameError < StandardError; end
class << self
#: -> Interface::RenameOptions
def provider
Interface::RenameOptions.new(prepare_provider: true)
end
end
#: (GlobalState global_state, Store store, (RubyDocument | ERBDocument) document, Hash[Symbol, untyped] params) -> void
def initialize(global_state, store, document, params)
super()
@global_state = global_state
@store = store
@document = document
@position = params[:position] #: Hash[Symbol, Integer]
@new_name = params[:newName] #: String
end
# @override
#: -> Interface::WorkspaceEdit?
def perform
char_position, _ = @document.find_index_by_position(@position)
node_context = RubyDocument.locate(
@document.ast,
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 = target #: as Prism::ConstantReadNode | Prism::ConstantPathNode | Prism::ConstantPathTargetNode
name = RubyIndexer::Index.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 #{conflict_entries.first&.name}"
end
fully_qualified_name = entries.first #: as !nil
.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
#: (String fully_qualified_name, Array[(Interface::RenameFile | Interface::TextDocumentEdit)] document_changes) -> void
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 = fully_qualified_name.split("::").last #: as !nil
@global_state.index[fully_qualified_name]&.each do |entry|
# Do not rename files that are not part of the workspace
uri = entry.uri
file_path = uri.full_path
next unless 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(
@new_name.split("::").last, #: as !nil
)
new_uri = URI::Generic.from_path(path: File.join(
File.dirname(file_path),
"#{new_file_name}.rb",
)).to_s
document_changes << Interface::RenameFile.new(kind: "rename", old_uri: uri.to_s, new_uri: new_uri)
end
end
end
end
#: (RubyIndexer::ReferenceFinder::Target target, String name) -> Hash[String, Array[Interface::TextEdit]]
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.value, 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|
next unless document.is_a?(RubyDocument) || document.is_a?(ERBDocument)
edits = collect_changes(target, document.ast, name, document.uri)
changes[uri] = edits unless edits.empty?
end
changes
end
#: (RubyIndexer::ReferenceFinder::Target target, Prism::Node ast, String name, URI::Generic uri) -> Array[Interface::TextEdit]
def collect_changes(target, ast, name, uri)
dispatcher = Prism::Dispatcher.new
finder = RubyIndexer::ReferenceFinder.new(target, @global_state.index, dispatcher, uri)
dispatcher.visit(ast)
finder.references.map do |reference|
adjust_reference_for_edit(name, reference)
end
end
#: (String name, RubyIndexer::ReferenceFinder::Reference reference) -> 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
#: (String constant_name) -> 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