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 raw_text =
fs::read_to_string(path).with_context(|| format!("re-read {}", path.display()))?;
let current = source_from_text(
path,
raw_text.clone(),
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(&raw_text, edits, old_name, &request.to)?;
writes.push((path, raw_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 line_starts = raw_line_starts(source);
let mut ordered = Vec::with_capacity(edits.len());
for edit in edits {
let line_start = *line_starts
.get(
edit.line
.checked_sub(1)
.context("edit line is out of range")?,
)
.context("edit line is out of range")?;
let start = line_start
.checked_add(edit.start_column - 1)
.context("edit span is out of range")?;
let end = line_start
.checked_add(edit.end_column - 1)
.context("edit span is out of range")?;
ordered.push((edit, start, end));
}
ordered.sort_by_key(|(_, start, _)| std::cmp::Reverse(*start));
for (_, start, end) in ordered {
if end > text.len() || !text.is_char_boundary(start) || !text.is_char_boundary(end) {
bail!("refusing to apply: edit span is out of range");
}
if &text[start..end] != old_name {
bail!("refusing to apply: a target span no longer holds `{old_name}`");
}
text.replace_range(start..end, new_name);
}
Ok(text)
}
fn raw_line_starts(text: &str) -> Vec<usize> {
let bytes = text.as_bytes();
let mut offset = usize::from(text.starts_with('\u{feff}')) * '\u{feff}'.len_utf8();
let mut starts = vec![offset];
while offset < bytes.len() {
match bytes[offset] {
b'\r' if bytes.get(offset + 1) == Some(&b'\n') => {
offset += 2;
starts.push(offset);
}
b'\r' | b'\n' => {
offset += 1;
starts.push(offset);
}
_ => offset += 1,
}
}
starts
}
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::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{Request, build_other, output};
use crate::engine::flags::GitFlags;
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);
}
#[test]
fn apply_preserves_bom_and_crlf() {
let path = std::env::temp_dir().join(format!(
"readseek-rename-{}-{}.txt",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::write(&path, "\u{feff}value\r\nvalue\r\n").unwrap();
let request = Request {
target: path.clone(),
line: 1,
column: Some(1),
to: "renamed".to_owned(),
workspace: None,
apply: true,
language: None,
flags: GitFlags {
cached: false,
others: false,
ignored: false,
},
};
output(&request).unwrap();
assert_eq!(
fs::read(&path).unwrap(),
"\u{feff}renamed\r\nrenamed\r\n".as_bytes()
);
fs::remove_file(path).unwrap();
}
}