use crate::engine::binding::{self, OccurrenceKind};
use crate::engine::flags::GitFlags;
use crate::engine::lang::Language;
use crate::engine::output::{RenameConflict, RenameEdit, RenameFileOutput, RenameOutput};
use crate::engine::paths::{command_paths, identifier_spans};
use crate::engine::source::{
ContentCategory, SourceFile, read_source_containing, source_from_text,
};
use anyhow::{Context, Result, bail};
use rayon::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) struct Request {
pub(crate) target: PathBuf,
pub(crate) line: usize,
pub(crate) column: Option<usize>,
pub(crate) to: String,
pub(crate) workspace: Option<PathBuf>,
pub(crate) apply: bool,
pub(crate) language: Option<Language>,
pub(crate) flags: GitFlags,
}
#[allow(clippy::too_many_lines)]
pub(crate) fn output(request: &Request) -> Result<RenameOutput> {
let line = request.line;
let column = request.column.unwrap_or(1);
if line == 0 || column == 0 {
bail!("line and column must be greater than zero");
}
if request.to.is_empty() {
bail!("new name must not be empty");
}
if !is_plain_identifier(&request.to) {
bail!("new name must be a plain identifier");
}
if !request.target.is_file() {
bail!("rename requires a single regular file target");
}
let bytes =
fs::read(&request.target).with_context(|| format!("read {}", request.target.display()))?;
let text = String::from_utf8(bytes).context("file is not valid UTF-8")?;
let source = source_from_text(
&request.target,
text,
request.language,
ContentCategory::Text,
None,
);
let cursor_byte = source.cursor_byte(line, column)?;
let (old_name, conflicts, edits, is_binding) = if let Some((binding, raw_conflicts)) =
binding::resolve_with_conflicts(&source, cursor_byte, Some(&request.to))
{
if binding.name == request.to {
bail!("new name is identical to the current name");
}
let conflicts = raw_conflicts
.into_iter()
.map(|conflict| {
let (line, column) = source.line_column(conflict.byte);
RenameConflict {
line,
column,
reason: conflict.reason,
}
})
.collect();
let edits = binding
.occurrences
.iter()
.filter(|occurrence| occurrence.kind != OccurrenceKind::Shadowed)
.map(|occurrence| {
rename_edit(
&source,
occurrence.start_byte,
occurrence.end_byte,
occurrence.kind,
)
})
.collect();
(binding.name, conflicts, edits, true)
} else {
let name = binding::identifier_at(&source, cursor_byte).with_context(|| {
format!(
"no identifier at {}:{line}:{column}",
request.target.display()
)
})?;
if name == request.to {
bail!("new name is identical to the current name");
}
let plan = build_other(&source, &name, &request.to, false).with_context(|| {
format!(
"`{name}` has no renamable occurrences in {}",
request.target.display()
)
})?;
(name, plan.conflicts, plan.edits, false)
};
let others = workspace_others(request, &old_name, is_binding)?;
let applied = if request.apply {
apply_all(request, &old_name, &edits, &conflicts, &others)?;
true
} else {
false
};
Ok(RenameOutput {
file: source.path.clone(),
language: source.detection.language,
engine: source.detection.engine,
file_hash: source.file_hash.clone(),
old_name,
new_name: request.to.clone(),
applied,
conflicts,
edits,
others,
})
}
fn workspace_others(
request: &Request,
old_name: &str,
target_is_binding: bool,
) -> Result<Vec<RenameFileOutput>> {
let Some(workspace) = request.workspace.as_ref() else {
return Ok(Vec::new());
};
let paths = command_paths(workspace, request.flags)?;
let origin = request.target.canonicalize().ok();
let mut others: Vec<RenameFileOutput> = paths
.par_iter()
.filter(|path| !is_origin(path, &request.target, origin.as_deref()))
.filter_map(|path| {
let source = read_source_containing(path, old_name, request.language)?;
build_other(&source, old_name, &request.to, target_is_binding)
})
.collect();
others.sort_by(|a, b| a.file.cmp(&b.file));
Ok(others)
}
fn build_other(
source: &SourceFile,
old_name: &str,
new_name: &str,
target_is_binding: bool,
) -> Option<RenameFileOutput> {
let mut occurrences = name_scan(source, old_name);
if occurrences.is_empty() {
return None;
}
let matches = binding::cross_file_matches(source, old_name, new_name);
let (excluded, conflict_bytes) = if target_is_binding {
match matches {
Some(matches) => (matches.excluded, matches.conflicts),
None => (Vec::new(), Vec::new()),
}
} else {
let conflicts = matches.map(|matches| matches.conflicts).unwrap_or_default();
(Vec::new(), conflicts)
};
if !excluded.is_empty() {
occurrences.retain(|(start_byte, _)| excluded.binary_search(start_byte).is_err());
if occurrences.is_empty() {
return None;
}
}
let edits = occurrences
.into_iter()
.map(|(start_byte, end_byte)| {
rename_edit(source, start_byte, end_byte, OccurrenceKind::Reference)
})
.collect();
let conflicts = conflict_bytes
.into_iter()
.map(|byte| {
let (line, column) = source.line_column(byte);
RenameConflict {
line,
column,
reason: format!("`{new_name}` already resolves to a binding here"),
}
})
.collect();
Some(RenameFileOutput {
file: source.path.clone(),
language: source.detection.language,
engine: source.detection.engine,
file_hash: source.file_hash.clone(),
conflicts,
edits,
})
}
fn name_scan(source: &SourceFile, name: &str) -> Vec<(usize, usize)> {
let text = source.text.as_bytes();
let needle = name.as_bytes();
identifier_spans(text, needle)
.map(|index| (index, index + needle.len()))
.collect()
}
fn is_origin(path: &Path, target: &Path, origin_canon: Option<&Path>) -> bool {
if path == target {
return true;
}
match (path.canonicalize(), origin_canon) {
(Ok(candidate), Some(origin)) => candidate == origin,
_ => false,
}
}
fn rename_edit(
source: &SourceFile,
start_byte: usize,
end_byte: usize,
kind: OccurrenceKind,
) -> RenameEdit {
let line_idx = source.line_index(start_byte);
let source_line = &source.lines[line_idx];
let line_start = source.line_starts[line_idx];
RenameEdit {
line: source_line.number,
start_column: start_byte - line_start + 1,
end_column: end_byte - line_start + 1,
start_byte,
end_byte,
occurrence: kind,
line_hash: source_line.hash(),
text: source_line.text.clone(),
}
}
fn apply_all(
request: &Request,
old_name: &str,
edits: &[RenameEdit],
conflicts: &[RenameConflict],
others: &[RenameFileOutput],
) -> Result<()> {
let conflict_count = conflicts.len() + others.iter().map(|o| o.conflicts.len()).sum::<usize>();
if conflict_count > 0 {
bail!(
"refusing to apply: {conflict_count} naming conflict(s); resolve them or rename to a free name"
);
}
let plans: Vec<(&Path, &[RenameEdit])> = std::iter::once((request.target.as_path(), edits))
.chain(
others
.iter()
.map(|other| (other.file.as_path(), other.edits.as_slice())),
)
.collect();
let mut writes: Vec<(&Path, String, String)> = Vec::new();
for (path, edits) in plans {
if edits.is_empty() {
continue;
}
let current =
fs::read_to_string(path).with_context(|| format!("re-read {}", path.display()))?;
let current =
source_from_text(path, current, request.language, ContentCategory::Text, None);
for edit in edits {
let line = current.line(edit.line).with_context(|| {
format!("line {} no longer exists in {}", edit.line, path.display())
})?;
if line.hash() != edit.line_hash {
bail!(
"refusing to apply: {}:{} changed since the plan was computed",
path.display(),
edit.line
);
}
}
let new_text = rewrite(¤t.text, edits, old_name, &request.to)?;
writes.push((path, current.text, new_text));
}
let mut written: Vec<(&Path, &str)> = Vec::new();
for (path, original, new_text) in &writes {
if let Err(error) = fs::write(path, new_text) {
let _ = fs::write(path, original);
for (done_path, done_original) in &written {
let _ = fs::write(done_path, done_original);
}
return Err(error).with_context(|| format!("write {}", path.display()));
}
written.push((path, original));
}
Ok(())
}
fn rewrite(source: &str, edits: &[RenameEdit], old_name: &str, new_name: &str) -> Result<String> {
let mut text = source.to_owned();
let mut ordered: Vec<&RenameEdit> = edits.iter().collect();
ordered.sort_by_key(|edit| std::cmp::Reverse(edit.start_byte));
for edit in ordered {
if edit.end_byte > text.len()
|| !text.is_char_boundary(edit.start_byte)
|| !text.is_char_boundary(edit.end_byte)
{
bail!("refusing to apply: edit span is out of range");
}
if &text[edit.start_byte..edit.end_byte] != old_name {
bail!("refusing to apply: a target span no longer holds `{old_name}`");
}
text.replace_range(edit.start_byte..edit.end_byte, new_name);
}
Ok(text)
}
fn is_plain_identifier(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_alphabetic() || first == '_') && chars.all(|ch| ch.is_alphanumeric() || ch == '_')
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::build_other;
use crate::engine::lang::Language;
use crate::engine::source::{ContentCategory, source_from_text};
#[test]
fn workspace_rename_excludes_local_shadow() {
let source = source_from_text(
Path::new("other.py"),
"def f():\n value = 2\n return value\n\nprint(value)\n".to_owned(),
Some(Language::Python),
ContentCategory::Text,
None,
);
let plan = build_other(&source, "value", "renamed", true).expect("workspace rename plan");
assert_eq!(plan.edits.len(), 1);
assert_eq!(plan.edits[0].line, 5);
}
#[test]
fn workspace_rename_keeps_c_macro_body_occurrence() {
let source = source_from_text(
Path::new("other.c"),
"#define INC() (value++)\nint other;\n".to_owned(),
Some(Language::C),
ContentCategory::Text,
None,
);
let plan = build_other(&source, "value", "renamed", true).expect("workspace rename plan");
assert_eq!(plan.edits.len(), 1);
assert_eq!(plan.edits[0].line, 1);
}
}