Skip to content
Merged

Work #210

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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions crates/ty/docs/rules.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ty_project/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ camino = { workspace = true }
colored = { workspace = true }
compact_str = { workspace = true, features = ["serde"] }
crossbeam = { workspace = true }
dunce = { workspace = true }
get-size2 = { workspace = true, features = ["ordermap", "parking_lot"] }
globset = { workspace = true }
notify = { workspace = true }
Expand Down
89 changes: 62 additions & 27 deletions crates/ty_project/src/watch/project_watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use ty_module_resolver::system_module_search_paths;
use crate::db::{Db, ProjectDatabase};
use crate::watch::Watcher;

/// Wrapper around a [`Watcher`] that watches the relevant paths of a project.
/// Wrapper around a [`Watcher`] that watches the relevant paths of one or more projects.
pub struct ProjectWatcher {
watcher: Watcher,

Expand All @@ -27,24 +27,63 @@ pub struct ProjectWatcher {
impl ProjectWatcher {
/// Create a new project watcher.
pub fn new(watcher: Watcher, db: &ProjectDatabase) -> Self {
Self::for_projects(watcher, [db])
}

/// Create a watcher over every project in `dbs`, with one set of watched paths for all of them.
///
/// For a process that holds several projects at once, such as a language server with several
/// workspace folders.
pub fn for_projects<'a>(
watcher: Watcher,
dbs: impl IntoIterator<Item = &'a ProjectDatabase>,
) -> Self {
let mut watcher = Self {
watcher,
watched_paths: Vec::new(),
cache_key: None,
has_errored_paths: false,
};

watcher.update(db);
watcher.update_projects(dbs);

watcher
}

pub fn update(&mut self, db: &ProjectDatabase) {
let environment = db.project().program(db).resolver_environment(db);
let search_paths: Vec<_> = system_module_search_paths(db, environment).collect();
let project_path = db.project().root(db);
self.update_projects([db]);
}

/// Watches what every project in `dbs` needs watched, and stops watching anything else.
pub fn update_projects<'a>(&mut self, dbs: impl IntoIterator<Item = &'a ProjectDatabase>) {
let dbs: Vec<&ProjectDatabase> = dbs.into_iter().collect();

let mut project_paths = Vec::new();
let mut search_paths = Vec::new();
let mut config_paths = Vec::new();
for db in &dbs {
let project = db.project();
let project_path = project.root(*db);
project_paths.push(project_path);
project_paths.extend(
project
.included_paths_list(*db)
.iter()
.map(SystemPathBuf::as_path),
);

// Module search paths are already canonicalized. The ones under the project root
// are covered by watching the root.
let environment = project.program(*db).resolver_environment(*db);
search_paths.extend(
system_module_search_paths(*db, environment)
.filter(|path| !path.starts_with(project_path)),
);

config_paths.extend(project.metadata(*db).extra_configuration_paths());
}

let new_cache_key = Self::compute_cache_key(project_path, &search_paths);
let new_cache_key = Self::compute_cache_key(&project_paths, &search_paths, &config_paths);

if self.cache_key == Some(new_cache_key) {
return;
Expand All @@ -70,29 +109,20 @@ impl ProjectWatcher {

self.has_errored_paths = false;

let config_paths = db.project().metadata(db).extra_configuration_paths();

// Watch both the project root and any paths provided by the user on the CLI (removing any redundant nested paths).
// Watch both the project roots and any paths provided by the user on the CLI (removing any redundant nested paths).
// This is necessary to observe changes to files that are outside the project root.
// We always need to watch the project root to observe changes to its configuration.
let included_paths = ruff_db::system::deduplicate_nested_paths(
std::iter::once(project_path).chain(
db.project()
.included_paths_list(db)
.iter()
.map(SystemPathBuf::as_path),
),
);
let included_paths = ruff_db::system::deduplicate_nested_paths(project_paths);

// Find the non-overlapping module search paths and filter out paths that are already covered by the project.
// Module search paths are already canonicalized.
let unique_module_paths = ruff_db::system::deduplicate_nested_paths(
search_paths
.into_iter()
.filter(|path| !path.starts_with(project_path)),
);
// Find the non-overlapping module search paths. Those under a project's own root were
// filtered out above; a search path of one project can still be another project's root.
let unique_module_paths =
ruff_db::system::deduplicate_nested_paths(search_paths).filter(|path| {
!dbs.iter()
.any(|db| path.starts_with(db.project().root(*db)))
});

// Now add the new paths, first starting with the project path and then
// Now add the new paths, first starting with the project paths and then
// adding the library search paths, and finally the paths for configurations.
for path in included_paths
.chain(unique_module_paths)
Expand Down Expand Up @@ -127,10 +157,15 @@ impl ProjectWatcher {
self.cache_key = Some(new_cache_key);
}

fn compute_cache_key(project_root: &SystemPath, search_paths: &[&SystemPath]) -> u64 {
fn compute_cache_key(
project_paths: &[&SystemPath],
search_paths: &[&SystemPath],
config_paths: &[&SystemPath],
) -> u64 {
let mut cache_key_hasher = CacheKeyHasher::new();
search_paths.cache_key(&mut cache_key_hasher);
project_root.cache_key(&mut cache_key_hasher);
project_paths.cache_key(&mut cache_key_hasher);
config_paths.cache_key(&mut cache_key_hasher);

cache_key_hasher.finish()
}
Expand Down
79 changes: 74 additions & 5 deletions crates/ty_project/src/watch/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
reason = "This implementation is specific to real file systems."
)]

use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use notify::event::{CreateKind, MetadataKind, ModifyKind, RemoveKind, RenameMode};
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher as _, recommended_watcher};

Expand All @@ -18,6 +21,8 @@ where
H: EventHandler,
{
let (sender, receiver) = crossbeam::channel::bounded(20);
let aliases = Aliases::default();
let debouncer_aliases = aliases.clone();

let debouncer = std::thread::Builder::new()
.name("watcher::debouncer".to_string())
Expand All @@ -31,7 +36,7 @@ where
}
};

let mut debouncer = Debouncer::default();
let mut debouncer = Debouncer::new(debouncer_aliases.clone());

debouncer.add_result(event);

Expand Down Expand Up @@ -89,6 +94,7 @@ where
watcher,
debouncer_sender,
debouncer_thread: debouncer,
aliases,
}),
})
}
Expand All @@ -109,13 +115,63 @@ struct WatcherInner {
watcher: RecommendedWatcher,
debouncer_sender: crossbeam::channel::Sender<DebouncerMessage>,
debouncer_thread: std::thread::JoinHandle<()>,
aliases: Aliases,
}

/// The watched paths that are not where they resolve to, by the path they resolve to.
///
/// macOS reports a change at the path the file system resolved, whatever path was watched:
/// watching `/tmp/project`, a write to `/tmp/project/a.py` is reported at
/// `/private/tmp/project/a.py`. The files in a project are known by the paths under its root as
/// it was given, so an event is put back under the watched path before it is handed on, or it
/// names a file nothing knows about and is lost.
#[derive(Clone, Default)]
struct Aliases(Arc<Mutex<Vec<(PathBuf, SystemPathBuf)>>>);

impl Aliases {
fn add(&self, watched: &SystemPath) {
// `dunce` keeps windows paths in the `C:\…` spelling events are reported in, rather than
// the verbatim `\\?\C:\…` that would make every watched path look like an alias
let Ok(resolved) = dunce::canonicalize(watched.as_std_path()) else {
return;
};
if resolved != watched.as_std_path() {
self.lock().push((resolved, watched.to_path_buf()));
}
}

fn remove(&self, watched: &SystemPath) {
self.lock().retain(|(_, path)| path.as_path() != watched);
}

/// `path` under the watched path it was reported for, if it was reported under the path
/// that one resolves to.
fn to_watched(&self, path: PathBuf) -> PathBuf {
let aliases = self.lock();
for (resolved, watched) in aliases.iter() {
if let Ok(rest) = path.strip_prefix(resolved) {
return watched.as_std_path().join(rest);
}
}
path
}

fn lock(&self) -> std::sync::MutexGuard<'_, Vec<(PathBuf, SystemPathBuf)>> {
// the list is only ever pushed to and filtered, so a panic while it was locked cannot
// have left it half-updated
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}

impl Watcher {
/// Returns a transaction-like view for updating watched paths in one backend operation.
pub(crate) fn paths_mut(&mut self) -> WatcherPathsMut<'_> {
let inner = self.inner_mut();
WatcherPathsMut {
inner: self.inner_mut().watcher.paths_mut(),
inner: inner.watcher.paths_mut(),
aliases: &inner.aliases,
}
}

Expand Down Expand Up @@ -163,16 +219,21 @@ impl Watcher {

pub(crate) struct WatcherPathsMut<'a> {
inner: Box<dyn notify::PathsMut + 'a>,
aliases: &'a Aliases,
}

impl WatcherPathsMut<'_> {
pub(crate) fn add(&mut self, path: &SystemPath) -> notify::Result<()> {
tracing::debug!("Watching path: `{path}`");
self.inner.add(path.as_std_path(), RecursiveMode::Recursive)
self.inner
.add(path.as_std_path(), RecursiveMode::Recursive)?;
self.aliases.add(path);
Ok(())
}

pub(crate) fn remove(&mut self, path: &SystemPath) -> notify::Result<()> {
tracing::debug!("Unwatching path: `{path}`");
self.aliases.remove(path);
self.inner.remove(path.as_std_path())
}

Expand All @@ -187,13 +248,21 @@ impl Drop for Watcher {
}
}

#[derive(Default)]
struct Debouncer {
events: Vec<ChangeEvent>,
rescan_event: Option<ChangeEvent>,
aliases: Aliases,
}

impl Debouncer {
fn new(aliases: Aliases) -> Self {
Self {
events: Vec::new(),
rescan_event: None,
aliases,
}
}

fn add_result(&mut self, result: notify::Result<notify::Event>) {
tracing::trace!("Handling file watcher event: {result:?}");
match result {
Expand Down Expand Up @@ -234,7 +303,7 @@ impl Debouncer {
return;
};

let path = match SystemPathBuf::from_path_buf(path) {
let path = match SystemPathBuf::from_path_buf(self.aliases.to_watched(path)) {
Ok(path) => path,
Err(path) => {
tracing::debug!(
Expand Down
Loading
Loading