Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

# Rubydex LSP additions
require "ruby_lsp/rubydex/definition"
require "ruby_lsp/rubydex/reference"

require "ruby-lsp"
require "ruby_lsp/base_server"
Expand Down
136 changes: 64 additions & 72 deletions lib/ruby_lsp/requests/rename.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def provider
def initialize(global_state, store, document, params)
super()
@global_state = global_state
@graph = global_state.graph #: Rubydex::Graph
@store = store
@document = document
@position = params[:position] #: Hash[Symbol, Integer]
Expand Down Expand Up @@ -56,17 +57,14 @@ def perform
name = RubyIndexer::Index.constant_name(target)
return unless name

entries = @global_state.index.resolve(name, node_context.nesting)
return unless entries
declaration = @graph.resolve_constant(name, node_context.nesting)
return unless declaration

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}"
if (conflict = @graph.resolve_constant(@new_name, node_context.nesting))
raise InvalidNameError, "The new name is already in use by #{conflict.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)
changes = collect_text_edits(declaration, name)

# If the client doesn't support resource operations, such as renaming files, then we can only return the basic
# text changes
Expand All @@ -78,99 +76,93 @@ def perform
# 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),
text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(uri: uri, version: nil),
edits: edits,
)
end

collect_file_renames(fully_qualified_name, document_changes)
collect_file_renames(declaration, 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)
#: (Rubydex::Declaration, Array[(Interface::RenameFile | Interface::TextDocumentEdit)]) -> void
def collect_file_renames(declaration, 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|
unless [
Rubydex::Class,
Rubydex::Module,
Rubydex::Constant,
Rubydex::ConstantAlias,
].any? { |type| declaration.is_a?(type) }
return
end

short_name = declaration.unqualified_name

declaration.definitions.each do |definition|
# Do not rename files that are not part of the workspace
uri = entry.uri
uri = URI(definition.location.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)
file_name = file_from_constant_name(short_name)
next unless "#{file_name}.rb" == File.basename(file_path)

if "#{file_name}.rb" == entry.file_name
new_file_name = file_from_constant_name(
@new_name.split("::").last, #: as !nil
)
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
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
document_changes << Interface::RenameFile.new(kind: "rename", old_uri: uri.to_s, new_uri: new_uri)
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)
#: (Rubydex::Declaration declaration, String name) -> Hash[String, Array[Interface::TextEdit]]
def collect_text_edits(declaration, name)
changes = {} #: Hash[String, Array[Interface::TextEdit]]
short_name = name.split("::").last #: as !nil
new_short_name = @new_name.split("::").last #: as !nil

# Collect edits for definition sites (where the constant is declared)
declaration.definitions.each do |definition|
name_loc = definition.name_location
next unless name_loc

uri_string = name_loc.uri
edits = (changes[uri_string] ||= [])

# The name_location spans the constant name as written in the definition.
# We only replace the unqualified name portion (the last segment).
range = Interface::Range.new(
start: Interface::Position.new(
line: name_loc.end_line,
character: name_loc.end_column - short_name.length,
),
end: Interface::Position.new(line: name_loc.end_line, character: name_loc.end_column),
)

edits = collect_changes(target, document.ast, name, document.uri)
changes[uri] = edits unless edits.empty?
edits << Interface::TextEdit.new(range: range, new_text: new_short_name)
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)
# Collect edits for reference sites (where the constant is used)
declaration.references.each do |reference|
ref = reference #: as Rubydex::ConstantReference
uri_string = ref.location.uri
edits = (changes[uri_string] ||= [])
edits << Interface::TextEdit.new(range: ref.to_lsp_range, new_text: new_short_name)
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)
changes
end

#: (String constant_name) -> String
Expand Down
16 changes: 16 additions & 0 deletions lib/ruby_lsp/rubydex/reference.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# typed: strict
# frozen_string_literal: true

module Rubydex
class ConstantReference
#: () -> RubyLsp::Interface::Range
def to_lsp_range
loc = location

RubyLsp::Interface::Range.new(
start: RubyLsp::Interface::Position.new(line: loc.start_line, character: loc.start_column),
end: RubyLsp::Interface::Position.new(line: loc.end_line, character: loc.end_column),
)
end
end
end
Loading
Loading