use super::*;
pub(crate) fn will_rename_via_db(
snapshot: &Analysis,
renames: &[(PathBuf, PathBuf)],
encoding: PositionEncoding,
) -> Option<WorkspaceEdit> {
let edits =
salsa::Cancelled::catch(AssertUnwindSafe(|| snapshot.source_rename_edits(renames))).ok()?;
let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
for (sourcer, range, new_text) in edits {
if let Some((uri, edit)) = text_edit_in(snapshot, &sourcer, range, &new_text, encoding) {
changes.entry(uri).or_default().push(edit);
}
}
finalize_rename(changes)
}
pub(crate) fn apply_file_renames(
db: &mut IncrementalDatabase,
renames: &[(PathBuf, PathBuf)],
) -> bool {
let Some(ws) = db.workspace() else {
return false;
};
let mut members: Vec<SourceFile> = ws.members(db).to_vec();
let old_roots = ws.roots(db).to_vec();
let roots = rebase_roots(&old_roots, renames);
let known: Vec<PathBuf> = members
.iter()
.filter_map(|&f| db.file_path(f).map(Path::to_path_buf))
.collect();
let expanded = expand_dir_renames(renames, known.iter().map(PathBuf::as_path));
let mut scope = WorkspaceScope::new(&roots);
let mut changed = false;
for (old, new) in &expanded {
if let Some(old_file) = db.lookup_file(old) {
let before = members.len();
members.retain(|&f| f != old_file);
changed |= members.len() != before;
}
if scope.contains(new)
&& let Ok(text) = std::fs::read_to_string(new)
{
let new_file = db.upsert_file(new, text);
if !members.contains(&new_file) {
members.push(new_file);
changed = true;
}
}
}
if changed || roots != old_roots {
db.set_workspace_members(members, roots);
}
changed
}
fn rebase_roots(roots: &[PathBuf], renames: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
let pairs: Vec<(PathBuf, PathBuf)> = renames
.iter()
.map(|(old, new)| (normalize_path(old), normalize_path(new)))
.filter(|(old, new)| old != new)
.collect();
if pairs.is_empty() {
return roots.to_vec();
}
roots
.iter()
.map(|root| {
let normalized = normalize_path(root);
pairs
.iter()
.filter_map(|(old, new)| {
let rel = normalized.strip_prefix(old).ok()?;
let rebased = if rel.as_os_str().is_empty() {
new.clone()
} else {
new.join(rel)
};
Some((old.components().count(), rebased))
})
.max_by_key(|(depth, _)| *depth)
.map_or_else(|| root.clone(), |(_, rebased)| rebased)
})
.collect()
}
pub(crate) fn file_renames_to_paths(params: &RenameFilesParams) -> Vec<(PathBuf, PathBuf)> {
params
.files
.iter()
.filter_map(|f| {
let old = f
.old_uri
.parse::<Uri>()
.ok()
.and_then(|u| uri::to_path(&u))?;
let new = f
.new_uri
.parse::<Uri>()
.ok()
.and_then(|u| uri::to_path(&u))?;
Some((old, new))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn will_rename_rewrites_dependent_source_literal() {
let b_src = "source(\"a.R\")\nbar <- function() foo()\n";
let snapshot = rename_workspace("foo <- function() 1\n", b_src);
let uri_a = uri::from_path(&ws_path("a.R")).unwrap();
let uri_b = uri::from_path(&ws_path("b.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[(ws_path("a.R"), ws_path("a_renamed.R"))],
PositionEncoding::Utf16,
)
.expect("the dependent literal is rewritten");
let (_, new_text) = sole_edit(&edit, &uri_b);
assert_eq!(new_text, "\"a_renamed.R\"");
assert!(
edit.changes.as_ref().unwrap().get(&uri_a).is_none(),
"the renamed file itself is not edited"
);
}
#[test]
fn will_rename_preserves_single_quotes() {
let b_src = "source('a.R')\n";
let snapshot = rename_workspace("foo <- function() 1\n", b_src);
let uri_b = uri::from_path(&ws_path("b.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[(ws_path("a.R"), ws_path("a2.R"))],
PositionEncoding::Utf16,
)
.expect("rewritten");
assert_eq!(sole_edit(&edit, &uri_b).1, "'a2.R'");
}
#[test]
fn will_rename_recomputes_relative_path_across_directories() {
let (_dir, snapshot, a_path, b_path) = rename_package(
"foo <- function() 1\n",
"source(\"a.R\")\nbar <- function() foo()\n",
);
let uri_b = uri::from_path(&b_path).unwrap();
let new_a = a_path.parent().unwrap().join("sub").join("a.R");
let edit = will_rename_via_db(&snapshot, &[(a_path, new_a)], PositionEncoding::Utf16)
.expect("rewritten");
assert_eq!(sole_edit(&edit, &uri_b).1, "\"sub/a.R\"");
}
#[test]
fn will_rename_applies_a_batch_of_renames() {
let mut db = IncrementalDatabase::default();
let a = db.upsert_file(&ws_path("a.R"), "foo <- function() 1\n".to_string());
let c = db.upsert_file(&ws_path("c.R"), "qux <- function() 2\n".to_string());
let b = db.upsert_file(
&ws_path("b.R"),
"source(\"a.R\")\nsource(\"c.R\")\n".to_string(),
);
db.set_workspace_members(vec![a, b, c], vec![ws_root()]);
let snapshot = db.snapshot();
let uri_b = uri::from_path(&ws_path("b.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[
(ws_path("a.R"), ws_path("a2.R")),
(ws_path("c.R"), ws_path("c2.R")),
],
PositionEncoding::Utf16,
)
.expect("both literals rewritten");
let edits = edit.changes.unwrap().remove(&uri_b).expect("b.R edited");
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
assert_eq!(texts, vec!["\"a2.R\"", "\"c2.R\""], "sorted by position");
}
#[test]
fn will_rename_leaves_dynamic_source_untouched() {
let snapshot = rename_workspace("foo <- function() 1\n", "source(paste0(d, \"a.R\"))\n");
assert!(
will_rename_via_db(
&snapshot,
&[(ws_path("a.R"), ws_path("a2.R"))],
PositionEncoding::Utf16
)
.is_none(),
"a dynamic source() is not rewritten"
);
}
#[test]
fn will_rename_ignores_a_noop_rename() {
let snapshot = rename_workspace("foo <- function() 1\n", "source(\"a.R\")\n");
assert!(
will_rename_via_db(
&snapshot,
&[(ws_path("a.R"), ws_path("a.R"))],
PositionEncoding::Utf16
)
.is_none(),
"renaming a file to itself produces no edits"
);
}
#[test]
fn apply_file_renames_swaps_membership() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let old = root.join("a.R");
let new = root.join("b.R");
std::fs::write(&old, "foo <- function() 1\n").expect("write a.R");
let mut db = IncrementalDatabase::default();
let a = db.upsert_file(&old, "foo <- function() 1\n".to_string());
db.set_workspace_members(vec![a], vec![root.to_path_buf()]);
std::fs::rename(&old, &new).expect("move a.R -> b.R");
assert!(apply_file_renames(&mut db, &[(old.clone(), new.clone())]));
let new_file = db.lookup_file(&new).expect("new path is tracked");
let members = db.workspace().unwrap().members(&db).to_vec();
assert!(members.contains(&new_file), "new path is a member");
assert!(!members.contains(&a), "old member is dropped from the set");
}
#[test]
fn apply_file_renames_ignores_a_folder_holding_nothing_tracked() {
let (dir, mut db, _a) = seeded_package();
let data = dir.path().join("data");
std::fs::create_dir(&data).expect("data/");
let data2 = dir.path().join("data2");
let before = db.workspace().unwrap().members(&db).to_vec();
std::fs::rename(&data, &data2).expect("move data -> data2");
assert!(
!apply_file_renames(&mut db, &[(data, data2)]),
"a folder holding nothing tracked is not a membership change"
);
assert!(db.workspace().unwrap().members(&db).to_vec() == before);
}
fn rename_outcome(db: &mut IncrementalDatabase, old: &Path, new: &Path) -> (bool, bool, bool) {
let returned = apply_file_renames(db, &[(old.to_path_buf(), new.to_path_buf())]);
let tracked = db.lookup_file(new);
let member = tracked.is_some_and(|f| db.workspace().unwrap().members(db).contains(&f));
(returned, tracked.is_some(), member)
}
#[test]
fn apply_file_renames_drops_a_move_out_of_the_workspace() {
let (_dir, mut db, a) = seeded_package();
let elsewhere = tempfile::tempdir().expect("second tempdir");
let new = elsewhere.path().join("a.R");
let a_file = db.lookup_file(&a).expect("tracked");
std::fs::rename(&a, &new).expect("move out of the workspace");
assert_eq!(
rename_outcome(&mut db, &a, &new),
(true, false, false),
"the member set changed, but the destination is untracked"
);
assert!(!db.workspace().unwrap().members(&db).contains(&a_file));
}
#[test]
fn apply_file_renames_drops_a_move_into_an_excluded_directory() {
let (dir, mut db, a) = seeded_package();
let renv = dir.path().join("renv");
std::fs::create_dir(&renv).expect("renv/");
let new = renv.join("a.R");
std::fs::rename(&a, &new).expect("move into renv/");
assert_eq!(rename_outcome(&mut db, &a, &new), (true, false, false));
}
#[test]
fn apply_file_renames_drops_a_rename_to_a_non_r_extension() {
let (dir, mut db, a) = seeded_package();
let new = dir.path().join("R").join("a.txt");
std::fs::rename(&a, &new).expect("move a.R -> a.txt");
assert_eq!(rename_outcome(&mut db, &a, &new), (true, false, false));
}
#[test]
fn apply_file_renames_keeps_a_move_that_stays_in_scope() {
let (dir, mut db, a) = seeded_package();
let extdata = dir.path().join("inst").join("extdata");
std::fs::create_dir_all(&extdata).expect("inst/extdata/");
let new = extdata.join("a.R");
std::fs::rename(&a, &new).expect("move into inst/extdata/");
assert_eq!(rename_outcome(&mut db, &a, &new), (true, true, true));
}
#[test]
fn apply_file_renames_adds_a_move_into_scope() {
let (dir, mut db, _a) = seeded_package();
let renv = dir.path().join("renv");
std::fs::create_dir(&renv).expect("renv/");
let old = renv.join("vendored.R");
std::fs::write(&old, "x <- 1\n").expect("vendored.R");
let new = dir.path().join("R").join("vendored.R");
std::fs::rename(&old, &new).expect("move out of renv/");
assert_eq!(rename_outcome(&mut db, &old, &new), (true, true, true));
}
#[test]
fn apply_file_renames_drops_the_old_member_when_the_new_path_is_gone() {
let (_dir, mut db, a) = seeded_package();
let a_file = db.lookup_file(&a).expect("tracked");
let new = a.with_file_name("b.R");
std::fs::remove_file(&a).expect("remove a.R");
assert!(
apply_file_renames(&mut db, &[(a, new)]),
"the old member leaves the set"
);
assert!(!db.workspace().unwrap().members(&db).contains(&a_file));
}
#[test]
fn apply_file_renames_follows_a_renamed_root() {
let dir = tempfile::tempdir().expect("tempdir");
let proj = dir.path().join("proj");
std::fs::create_dir(&proj).expect("proj/");
let old_a = proj.join("a.R");
std::fs::write(&old_a, "foo <- function() 1\n").expect("a.R");
let mut db = IncrementalDatabase::default();
let a = db.upsert_file(&old_a, "foo <- function() 1\n".to_string());
db.set_workspace_members(vec![a], vec![proj.clone()]);
let proj2 = dir.path().join("proj2");
std::fs::rename(&proj, &proj2).expect("move proj -> proj2");
assert!(apply_file_renames(&mut db, &[(proj, proj2.clone())]));
assert_eq!(
db.workspace().unwrap().roots(&db).to_vec(),
vec![proj2.clone()],
"the root follows the rename"
);
let new_file = db.lookup_file(&proj2.join("a.R")).expect("tracked");
assert!(
db.workspace()
.unwrap()
.members(&db)
.to_vec()
.contains(&new_file)
);
}
#[test]
fn will_rename_folder_leaves_a_colocated_literal_untouched() {
let snapshot = rename_workspace_files(&[
("R/a.R", "source(\"b.R\")\n"),
("R/b.R", "foo <- function() 1\n"),
]);
assert!(
will_rename_via_db(
&snapshot,
&[(ws_path("R"), ws_path("src"))],
PositionEncoding::Utf16
)
.is_none(),
"a literal that still resolves from the new folder is not rewritten"
);
}
#[test]
fn will_rename_folder_rewrites_a_literal_escaping_the_folder() {
let snapshot = rename_workspace_files(&[
("R/a.R", "source(\"../data/x.R\")\n"),
("data/x.R", "foo <- function() 1\n"),
]);
let uri_a = uri::from_path(&ws_path("R/a.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[(ws_path("R"), ws_path("nested/src"))],
PositionEncoding::Utf16,
)
.expect("the moved sourcer's own literal is rebased");
assert_eq!(sole_edit(&edit, &uri_a).1, "\"../../data/x.R\"");
}
#[test]
fn will_rename_folder_rewrites_an_outside_sourcer() {
let snapshot = rename_workspace_files(&[
("main.R", "source(\"R/a.R\")\n"),
("R/a.R", "foo <- function() 1\n"),
]);
let uri_main = uri::from_path(&ws_path("main.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[(ws_path("R"), ws_path("src"))],
PositionEncoding::Utf16,
)
.expect("the outside dependent is rewritten");
assert_eq!(sole_edit(&edit, &uri_main).1, "\"src/a.R\"");
}
#[test]
fn will_rename_folder_batched_with_a_file_rename() {
let snapshot = rename_workspace_files(&[
("main.R", "source(\"R/a.R\")\n"),
("R/a.R", "foo <- function() 1\n"),
]);
let uri_main = uri::from_path(&ws_path("main.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[
(ws_path("R"), ws_path("src")),
(ws_path("main.R"), ws_path("sub/main2.R")),
],
PositionEncoding::Utf16,
)
.expect("both moves are accounted for");
assert_eq!(sole_edit(&edit, &uri_main).1, "\"../src/a.R\"");
}
#[test]
fn will_rename_folder_prefers_the_deepest_rename() {
let snapshot = rename_workspace_files(&[
("main.R", "source(\"R/sub/a.R\")\n"),
("R/sub/a.R", "foo <- function() 1\n"),
]);
let uri_main = uri::from_path(&ws_path("main.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[
(ws_path("R"), ws_path("src")),
(ws_path("R/sub"), ws_path("other")),
],
PositionEncoding::Utf16,
)
.expect("the deepest matching prefix decides");
assert_eq!(sole_edit(&edit, &uri_main).1, "\"other/a.R\"");
}
#[test]
fn will_rename_folder_rewrites_a_non_member_target() {
let snapshot = rename_workspace_files(&[("R/a.R", "source(\"../scripts/x.R\")\n")]);
let uri_a = uri::from_path(&ws_path("R/a.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[(ws_path("scripts"), ws_path("tools"))],
PositionEncoding::Utf16,
)
.expect("a non-member source target is remapped");
assert_eq!(sole_edit(&edit, &uri_a).1, "\"../tools/x.R\"");
}
#[test]
fn will_rename_folder_leaves_a_noncanonical_spelling_alone() {
let snapshot = rename_workspace_files(&[
("R/a.R", "source(\"./b.R\")\n"),
("R/b.R", "foo <- function() 1\n"),
]);
assert!(
will_rename_via_db(
&snapshot,
&[(ws_path("R"), ws_path("src"))],
PositionEncoding::Utf16
)
.is_none(),
"a still-resolving literal is left exactly as written"
);
}
#[test]
fn will_rename_folder_rewrites_an_absolute_literal() {
let absolute = ws_path("R/a.R").display().to_string().replace('\\', "/");
let snapshot = rename_workspace_files(&[
("main.R", &format!("source(\"{absolute}\")\n")),
("R/a.R", "foo <- function() 1\n"),
]);
let uri_main = uri::from_path(&ws_path("main.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[(ws_path("R"), ws_path("src"))],
PositionEncoding::Utf16,
)
.expect("the absolute literal follows its target");
let expected = ws_path("src/a.R").display().to_string().replace('\\', "/");
assert_eq!(sole_edit(&edit, &uri_main).1, format!("\"{expected}\""));
}
#[test]
fn will_rename_folder_with_no_members_is_a_noop() {
let snapshot = rename_workspace("foo <- function() 1\n", "source(\"a.R\")\n");
assert!(
will_rename_via_db(
&snapshot,
&[(ws_path("data"), ws_path("data2"))],
PositionEncoding::Utf16
)
.is_none(),
"a folder holding nothing we track produces no edits"
);
}
#[test]
fn will_rename_moved_file_rebases_its_own_literal() {
let snapshot = rename_workspace("source(\"b.R\")\n", "foo <- function() 1\n");
let uri_a = uri::from_path(&ws_path("a.R")).unwrap();
let edit = will_rename_via_db(
&snapshot,
&[(ws_path("a.R"), ws_path("sub/a.R"))],
PositionEncoding::Utf16,
)
.expect("the moved file's own literal is rebased");
assert_eq!(sole_edit(&edit, &uri_a).1, "\"../b.R\"");
}
#[test]
fn apply_file_renames_expands_a_folder_rename() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let old_dir = root.join("R");
let new_dir = root.join("src");
std::fs::create_dir(&old_dir).expect("create R/");
let src = "foo <- function() 1\n";
std::fs::write(old_dir.join("a.R"), src).expect("write a.R");
std::fs::write(old_dir.join("b.R"), src).expect("write b.R");
let mut db = IncrementalDatabase::default();
let a = db.upsert_file(&old_dir.join("a.R"), src.to_string());
let b = db.upsert_file(&old_dir.join("b.R"), src.to_string());
db.set_workspace_members(vec![a, b], vec![root.to_path_buf()]);
std::fs::rename(&old_dir, &new_dir).expect("move R/ -> src/");
assert!(apply_file_renames(&mut db, &[(old_dir, new_dir.clone())]));
let members = db.workspace().unwrap().members(&db).to_vec();
for name in ["a.R", "b.R"] {
let file = db
.lookup_file(&new_dir.join(name))
.expect("new path is tracked");
assert!(members.contains(&file), "{name} moved with the folder");
}
assert!(!members.contains(&a), "old a.R is dropped");
assert!(!members.contains(&b), "old b.R is dropped");
}
}