use super::*;
pub(crate) const WATCHED_GLOBS: [&str; 4] = [
"**/*.{R,r}",
"**/arity.toml",
"**/DESCRIPTION",
"**/NAMESPACE",
];
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct WatchedFilesBatch {
pub(crate) r_created: Vec<PathBuf>,
pub(crate) r_deleted: Vec<PathBuf>,
pub(crate) r_changed: Vec<PathBuf>,
pub(crate) package_meta_changed: bool,
}
impl WatchedFilesBatch {
pub(crate) fn is_empty(&self) -> bool {
self.r_created.is_empty()
&& self.r_deleted.is_empty()
&& self.r_changed.is_empty()
&& !self.package_meta_changed
}
}
pub(crate) struct WatchedClassification {
pub(crate) batch: WatchedFilesBatch,
pub(crate) config_changed: bool,
}
enum WatchedKind {
RSource,
PackageMeta,
Config,
Other,
}
fn classify_path(path: &Path) -> WatchedKind {
match path.file_name().and_then(|n| n.to_str()) {
Some("arity.toml") => return WatchedKind::Config,
Some("DESCRIPTION") | Some("NAMESPACE") => return WatchedKind::PackageMeta,
_ => {}
}
match path.extension().and_then(|e| e.to_str()) {
Some("R") | Some("r") => WatchedKind::RSource,
_ => WatchedKind::Other,
}
}
pub(crate) fn classify_watched_files(
params: &DidChangeWatchedFilesParams,
is_open: impl Fn(&Uri) -> bool,
) -> WatchedClassification {
let mut batch = WatchedFilesBatch::default();
let mut config_changed = false;
for ev in ¶ms.changes {
let Some(path) = uri::to_path(&ev.uri) else {
continue;
};
match classify_path(&path) {
WatchedKind::RSource => {
let t = ev.typ;
if t == FileChangeType::CREATED {
batch.r_created.push(path);
} else if t == FileChangeType::DELETED {
batch.r_deleted.push(path);
} else if t == FileChangeType::CHANGED && !is_open(&ev.uri) {
batch.r_changed.push(path);
}
}
WatchedKind::PackageMeta => batch.package_meta_changed = true,
WatchedKind::Config => config_changed = true,
WatchedKind::Other => {}
}
}
WatchedClassification {
batch,
config_changed,
}
}
pub(crate) fn apply_r_membership(
db: &mut IncrementalDatabase,
created: &[PathBuf],
deleted: &[PathBuf],
) -> bool {
let Some(ws) = db.workspace() else {
return false;
};
let mut members: Vec<SourceFile> = ws.members(db).to_vec();
let roots = ws.roots(db).to_vec();
let mut changed = false;
for path in deleted {
if let Some(old) = db.lookup_file(path) {
let before = members.len();
members.retain(|&m| m != old);
changed |= members.len() != before;
}
}
for path in created {
if !in_workspace_scope(&roots, path) {
continue;
}
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
let file = db.upsert_file(path, text);
if !members.contains(&file) {
members.push(file);
changed = true;
}
}
if changed {
db.set_workspace_members(members, roots);
}
changed
}
fn in_workspace_scope(roots: &[PathBuf], path: &Path) -> bool {
let Some(root) = roots
.iter()
.filter(|r| path.starts_with(r))
.max_by_key(|r| r.components().count())
else {
return false;
};
let exclude = crate::linter::check::resolve_exclude_at(root);
crate::linter::check::scope_members(std::slice::from_ref(root), &exclude)
.iter()
.any(|p| p == path)
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::FileEvent;
fn ws_uri(path: &str) -> Uri {
let p = if cfg!(windows) {
PathBuf::from(format!(r"C:\ws\{path}"))
} else {
PathBuf::from(format!("/ws/{path}"))
};
uri::from_path(&p).expect("file uri")
}
fn event(path: &str, typ: FileChangeType) -> FileEvent {
FileEvent::new(ws_uri(path), typ)
}
fn classify(changes: Vec<FileEvent>, open: &[&str]) -> WatchedClassification {
let open_uris: HashSet<Uri> = open.iter().map(|n| ws_uri(n)).collect();
classify_watched_files(&DidChangeWatchedFilesParams { changes }, |uri| {
open_uris.contains(uri)
})
}
#[test]
fn splits_r_sources_by_change_type() {
let c = classify(
vec![
event("R/new.R", FileChangeType::CREATED),
event("R/gone.R", FileChangeType::DELETED),
event("R/edited.R", FileChangeType::CHANGED),
],
&[],
);
assert_eq!(c.batch.r_created.len(), 1);
assert_eq!(c.batch.r_deleted.len(), 1);
assert_eq!(c.batch.r_changed.len(), 1);
assert!(!c.batch.package_meta_changed);
assert!(!c.config_changed);
}
#[test]
fn a_disk_change_to_an_open_file_is_dropped() {
let c = classify(
vec![event("edited.R", FileChangeType::CHANGED)],
&["edited.R"],
);
assert!(
c.batch.is_empty(),
"open-file change dropped: {:?}",
c.batch
);
}
#[test]
fn description_and_namespace_set_package_meta() {
for name in ["DESCRIPTION", "NAMESPACE"] {
let c = classify(vec![event(name, FileChangeType::CHANGED)], &[]);
assert!(c.batch.package_meta_changed, "{name} sets package meta");
assert!(!c.config_changed);
}
}
#[test]
fn arity_toml_sets_config_changed_only() {
let c = classify(vec![event("arity.toml", FileChangeType::CHANGED)], &[]);
assert!(c.config_changed);
assert!(c.batch.is_empty(), "config change is not lint-thread work");
}
#[test]
fn unrelated_files_are_ignored() {
let c = classify(vec![event("README.md", FileChangeType::CHANGED)], &[]);
assert!(c.batch.is_empty());
assert!(!c.config_changed);
}
fn seeded_package() -> (tempfile::TempDir, IncrementalDatabase, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(root.join("DESCRIPTION"), "Package: testpkg\n").expect("DESCRIPTION");
let r_dir = root.join("R");
std::fs::create_dir(&r_dir).expect("R/");
let a = r_dir.join("a.R");
std::fs::write(&a, "foo <- function() 1\n").expect("a.R");
let mut db = IncrementalDatabase::default();
let file = db.upsert_file(&a, "foo <- function() 1\n".to_string());
db.set_workspace_members(vec![file], vec![root.to_path_buf()]);
(dir, db, a)
}
#[test]
fn apply_r_membership_adds_created_and_drops_deleted() {
let (dir, mut db, a) = seeded_package();
let b = dir.path().join("R").join("b.R");
std::fs::write(&b, "bar <- function() 2\n").expect("b.R");
assert!(apply_r_membership(&mut db, std::slice::from_ref(&b), &[]));
let b_file = db.lookup_file(&b).expect("b.R tracked");
assert!(db.workspace().unwrap().members(&db).contains(&b_file));
let a_file = db.lookup_file(&a).expect("a.R tracked");
assert!(apply_r_membership(&mut db, &[], std::slice::from_ref(&a)));
assert!(!db.workspace().unwrap().members(&db).contains(&a_file));
}
#[test]
fn apply_r_membership_ignores_a_create_outside_any_root() {
let (_dir, mut db, _a) = seeded_package();
let stray = if cfg!(windows) {
PathBuf::from(r"C:\elsewhere\stray.R")
} else {
PathBuf::from("/elsewhere/stray.R")
};
assert!(
!apply_r_membership(&mut db, std::slice::from_ref(&stray), &[]),
"a file outside every tracked root is not added"
);
assert!(db.lookup_file(&stray).is_none());
}
}