mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 10:45:37 +08:00
[ty] Watch script dependencies in CLI watch mode (#28125)
## Summary This PR implements watching for script search paths. Before, changes to files in a script's search path were not picked up by ty, because the virtual environment is typically outside the `project_root` (at least when created by uv) and ty didn't explicitly watch the search paths for changes. This PR extends the CLI's watcher to also register script search paths as watched folders (while still deduplicating watched paths as before). The one caveat of registering script search paths is that we need to update the search paths after every edit because editing a script metadata block could change the script's search paths (e.g. when a user adds a new `tool.ty.environment` block). To make this as cheap as possible, computing the watch paths is a salsa query and we compute a `cache_key` that allows us to bail early (and extremely cheaply) if the search paths remain unchanged. I plan to make the same change for the LSP, but it's a bit more work because the LSP watcher simply watches the project folder. We never implemented the necessary dynamic registration to watch other search paths (e.g. extra paths outside the project etc). I plan to tackle this next. ## Test Plan Before, changes to a script's search path were not detected by ty: https://github.com/user-attachments/assets/06cfe9b8-fa74-4437-aeee-6f54b80d6a5a Now, changes are recognized and trigger a recheck of the script https://github.com/user-attachments/assets/2685261b-306d-4129-b238-a3eca2c88713
This commit is contained in:
1 parent
fe9f065a50
commit
d940b244f7
4 files changed
+462
-79
No files matched your search
@@ -505,10 +505,11 @@ impl MainLoop {
|
||||
let scripts: Vec<_> = db.project().script_files(db).iter().collect();
|
||||
self.synchronize_scripts(db, &scripts);
|
||||
}
|
||||
|
||||
if let Some(watcher) = self.watcher.as_mut() {
|
||||
watcher.update(db);
|
||||
}
|
||||
}
|
||||
if !changes.is_empty()
|
||||
&& let Some(watcher) = self.watcher.as_mut()
|
||||
{
|
||||
watcher.update(db);
|
||||
}
|
||||
request_check(&check_sender);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,16 @@ impl TestCase {
|
||||
}
|
||||
|
||||
fn apply_changes(&mut self, changes: &[ChangeEvent]) -> ChangeResult {
|
||||
self.db.apply_changes(changes)
|
||||
let result = self.db.apply_changes(changes);
|
||||
|
||||
if !changes.is_empty()
|
||||
&& let Some(watcher) = &mut self.watcher
|
||||
{
|
||||
watcher.update(&mut self.db);
|
||||
assert!(!watcher.has_errored_paths());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn update_options(&mut self, options: Options) -> anyhow::Result<()> {
|
||||
@@ -206,11 +215,6 @@ impl TestCase {
|
||||
let changes = self.take_watch_changes(event_for_file("pyproject.toml"));
|
||||
self.apply_changes(&changes);
|
||||
|
||||
if let Some(watcher) = &mut self.watcher {
|
||||
watcher.update(&self.db);
|
||||
assert!(!watcher.has_errored_paths());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -506,7 +510,7 @@ where
|
||||
let watcher = directory_watcher(move |events| sender.send(events).unwrap())
|
||||
.with_context(|| "Failed to create directory watcher")?;
|
||||
|
||||
let watcher = ProjectWatcher::new(watcher, &db);
|
||||
let watcher = ProjectWatcher::new(watcher, &mut db);
|
||||
assert!(!watcher.has_errored_paths());
|
||||
|
||||
let test_case = TestCase {
|
||||
@@ -1504,6 +1508,246 @@ fn remove_search_path() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_dependency_changes() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_file("dependencies/dependency.py", "value = 1")?;
|
||||
context.write_project_file(
|
||||
"script.py",
|
||||
r#"
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# [tool.ty.environment]
|
||||
# extra-paths = ["../dependencies"]
|
||||
# ///
|
||||
from dependency import value
|
||||
result: int = value
|
||||
"#,
|
||||
)
|
||||
})?;
|
||||
let dependency = case.root_path().join("dependencies/dependency.py");
|
||||
assert!(case.db().check().is_empty());
|
||||
|
||||
update_file(&dependency, "value = 'wrong'")?;
|
||||
let changes = case.stop_watch(event_for_file("dependency.py"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
let diagnostics = case.db().check();
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].id().as_str(), "invalid-assignment");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_script_search_paths_remain_watched_until_unused() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_file("dependencies/dependency.py", "value = 1")?;
|
||||
let script = r#"
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# [tool.ty.environment]
|
||||
# extra-paths = ["../dependencies"]
|
||||
# ///
|
||||
"#;
|
||||
context.write_project_file("first.py", script)?;
|
||||
context.write_project_file("second.py", script)
|
||||
})?;
|
||||
let dependency = case.root_path().join("dependencies/dependency.py");
|
||||
|
||||
update_file(case.project_path("first.py"), "")?;
|
||||
let changes = case.take_watch_changes(event_for_file("first.py"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
update_file(&dependency, "value = 2")?;
|
||||
let changes = case.take_watch_changes(event_for_file("dependency.py"));
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|event| matches!(event, ChangeEvent::Changed { path, .. } if path == &dependency)),
|
||||
"expected an edit while the search path is shared: {changes:?}"
|
||||
);
|
||||
|
||||
update_file(case.project_path("second.py"), "")?;
|
||||
let changes = case.take_watch_changes(event_for_file("second.py"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
update_file(&dependency, "value = 3")?;
|
||||
|
||||
// Neither script uses this path now, so edits to it should no longer produce watch events.
|
||||
let changes = case.try_stop_watch(event_for_file("dependency.py"), Duration::from_millis(100));
|
||||
|
||||
assert_eq!(changes, Err(vec![]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_script_metadata_rewatches_search_path() -> anyhow::Result<()> {
|
||||
let script = r#"
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# [tool.ty.environment]
|
||||
# extra-paths = ["../dependencies"]
|
||||
# ///
|
||||
"#;
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_file("dependencies/dependency.py", "value = 1")?;
|
||||
context.write_project_file("script.py", script)
|
||||
})?;
|
||||
let dependency = case.root_path().join("dependencies/dependency.py");
|
||||
|
||||
// Removing the script block removes its extra search path from the project.
|
||||
update_file(case.project_path("script.py"), "")?;
|
||||
let changes = case.take_watch_changes(event_for_file("script.py"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
// Restoring the script re-registers its search path.
|
||||
update_file(case.project_path("script.py"), script)?;
|
||||
let changes = case.take_watch_changes(event_for_file("script.py"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
update_file(&dependency, "value = 2")?;
|
||||
let changes = case.stop_watch(event_for_file("dependency.py"));
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|event| matches!(event, ChangeEvent::Changed { path, .. } if path == &dependency)),
|
||||
"expected an edit after restoring the script's search path: {changes:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_search_path_picks_up_missed_dependency_edit() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
context.write_file("dependencies/pkg/dependency.py", "value = 1")?;
|
||||
context.write_project_file(
|
||||
"main.py",
|
||||
r"
|
||||
from pkg.dependency import value
|
||||
result: int = value
|
||||
",
|
||||
)?;
|
||||
context.write_project_file(
|
||||
"pyproject.toml",
|
||||
r#"
|
||||
[tool.ty.environment]
|
||||
extra-paths = ["../dependencies"]
|
||||
"#,
|
||||
)
|
||||
})?;
|
||||
let dependency = case.root_path().join("dependencies/pkg/dependency.py");
|
||||
|
||||
// The first check caches the dependency through the parent search path.
|
||||
assert!(case.db().check().is_empty());
|
||||
|
||||
// Stop watching the parent before editing the dependency, so ty misses that file event.
|
||||
update_file(case.project_path("pyproject.toml"), "[tool.ty]\n")?;
|
||||
let changes = case.take_watch_changes(event_for_file("pyproject.toml"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
update_file(&dependency, "value = 'wrong'")?;
|
||||
|
||||
// The new search path is nested inside the former watch. Registering it must refresh
|
||||
// known files beneath it, even though that exact directory was never watched before.
|
||||
update_file(
|
||||
case.project_path("pyproject.toml"),
|
||||
r#"
|
||||
[tool.ty.environment]
|
||||
extra-paths = ["../dependencies/pkg"]
|
||||
"#,
|
||||
)?;
|
||||
let changes = case.take_watch_changes(event_for_file("pyproject.toml"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
// Import relative to the new search path and check that ty sees the missed edit.
|
||||
update_file(
|
||||
case.project_path("main.py"),
|
||||
r"
|
||||
from dependency import value
|
||||
result: int = value
|
||||
",
|
||||
)?;
|
||||
let changes = case.take_watch_changes(event_for_file("main.py"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
let diagnostics = case.db().check();
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].id().as_str(), "invalid-assignment");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pth_edit_while_unwatched_watches_new_search_path() -> anyhow::Result<()> {
|
||||
let site_packages = if cfg!(windows) {
|
||||
"venv/Lib/site-packages"
|
||||
} else {
|
||||
"venv/lib/python3.12/site-packages"
|
||||
};
|
||||
let project_config = r#"
|
||||
[tool.ty.environment]
|
||||
python = "../venv"
|
||||
"#;
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
let python_home = context.join_root_path("base/bin");
|
||||
context.write_file("base/bin/python", "")?;
|
||||
context.write_file(
|
||||
"venv/pyvenv.cfg",
|
||||
&format!(
|
||||
r"home = {python_home}
|
||||
version = 3.12
|
||||
"
|
||||
),
|
||||
)?;
|
||||
context.write_file(
|
||||
format!("{site_packages}/dependency.pth"),
|
||||
context.join_root_path("old").as_str(),
|
||||
)?;
|
||||
context.write_file("old/dependency.py", "value = 1")?;
|
||||
context.write_file("new/dependency.py", "value = 1")?;
|
||||
context.write_project_file(
|
||||
"main.py",
|
||||
r"
|
||||
from dependency import value
|
||||
result: int = value
|
||||
",
|
||||
)?;
|
||||
context.write_project_file("pyproject.toml", project_config)
|
||||
})?;
|
||||
|
||||
// The initial import resolves through `old`, as named by `dependency.pth`.
|
||||
assert!(case.db().check().is_empty());
|
||||
|
||||
// Stop watching the virtual environment before changing its `.pth` file.
|
||||
update_file(case.project_path("pyproject.toml"), "[tool.ty]\n")?;
|
||||
let changes = case.take_watch_changes(event_for_file("pyproject.toml"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
// This edit has no watcher event, so the cached `.pth` contents still name `old`.
|
||||
let new = case.root_path().join("new");
|
||||
update_file(
|
||||
case.root_path().join(site_packages).join("dependency.pth"),
|
||||
new.as_str(),
|
||||
)?;
|
||||
|
||||
// Restoring the environment initially sees the cached `old` path. Rewatching
|
||||
// `site-packages` must refresh `.pth` and register `new` in the same update.
|
||||
update_file(case.project_path("pyproject.toml"), project_config)?;
|
||||
let changes = case.take_watch_changes(event_for_file("pyproject.toml"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
// Receiving this edit proves that `new` is now watched.
|
||||
let dependency = new.join("dependency.py");
|
||||
update_file(&dependency, "value = 2")?;
|
||||
let changes = case.stop_watch(event_for_file("dependency.py"));
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|event| matches!(event, ChangeEvent::Changed { path, .. } if path == &dependency)),
|
||||
"expected an edit at the new search path: {changes:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_python_version_and_platform() -> anyhow::Result<()> {
|
||||
let mut case = setup(|context: &mut SetupContext| {
|
||||
@@ -2621,10 +2865,14 @@ mod uv_metadata {
|
||||
use ruff_db::diagnostic::DiagnosticId;
|
||||
use ruff_db::files::File;
|
||||
use ruff_db::system::{OsSystem, System as _};
|
||||
use ty_module_resolver::system_module_search_paths;
|
||||
use ty_project::{Db, ScriptEnvironmentAvailability, UseUv, UvSyncChanges, uv_test_env_vars};
|
||||
use ty_python_semantic::Db as _;
|
||||
use ty_static::EnvVars;
|
||||
|
||||
use super::{SetupContext, TestCase, event_for_file, setup_with_system, update_file};
|
||||
use super::{
|
||||
ChangeEvent, SetupContext, TestCase, event_for_file, setup_with_system, update_file,
|
||||
};
|
||||
|
||||
const MANIFEST: &str = r#"
|
||||
[project]
|
||||
@@ -2871,6 +3119,56 @@ mod uv_metadata {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_pth_adds_watched_search_path() -> anyhow::Result<()> {
|
||||
let mut case = setup_uv(
|
||||
UseUv::Scripts,
|
||||
&[
|
||||
(
|
||||
"script.py",
|
||||
r#"
|
||||
# /// script
|
||||
# dependencies = []
|
||||
# requires-python = ">=3.12"
|
||||
# ///
|
||||
from dependency import value
|
||||
result: int = value
|
||||
"#,
|
||||
),
|
||||
("../dependencies/dependency.py", "value = 1"),
|
||||
],
|
||||
)?;
|
||||
let db = case.db();
|
||||
let script = case.system_file(case.project_path("script.py"))?;
|
||||
let environment = db.program_file(script).program(db).resolver_environment(db);
|
||||
let site_packages = system_module_search_paths(db, environment)
|
||||
.find(|path| path.file_name() == Some("site-packages"))
|
||||
.context("script environment has no site-packages")?
|
||||
.to_path_buf();
|
||||
let pth = site_packages.join("dependency.pth");
|
||||
let diagnostics = case.db().check();
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].id().as_str(), "unresolved-import");
|
||||
|
||||
let dependencies = case.root_path().join("dependencies");
|
||||
std::fs::write(pth.as_std_path(), dependencies.as_str())?;
|
||||
let changes = case.take_watch_changes(event_for_file("dependency.pth"));
|
||||
case.apply_changes(&changes);
|
||||
|
||||
assert!(case.db().check().is_empty());
|
||||
|
||||
let dependency = dependencies.join("dependency.py");
|
||||
update_file(&dependency, "value = 2")?;
|
||||
let changes = case.stop_watch(event_for_file("dependency.py"));
|
||||
assert!(
|
||||
changes.iter().any(
|
||||
|event| matches!(event, ChangeEvent::Changed { path, .. } if path == &dependency)
|
||||
),
|
||||
"expected an edit at the new search path: {changes:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_uv(use_uv: UseUv, files: &[(&str, &str)]) -> anyhow::Result<TestCase> {
|
||||
let uv = OsSystem::default().which("uv")?;
|
||||
let mut case = setup_with_system(
|
||||
@@ -2962,10 +3260,10 @@ mod uv_metadata {
|
||||
changes.project = completed.project.or(changes.project);
|
||||
}
|
||||
|
||||
if changes.project.is_some()
|
||||
if !changes.is_empty()
|
||||
&& let Some(watcher) = &mut case.watcher
|
||||
{
|
||||
watcher.update(&case.db);
|
||||
watcher.update(&mut case.db);
|
||||
assert!(!watcher.has_errored_paths());
|
||||
}
|
||||
|
||||
|
||||
@@ -733,6 +733,7 @@ fn apply_sync_result(
|
||||
// previous metadata was cleared along with its virtual-environment path.
|
||||
//
|
||||
// FIXME: This is overbroad. A file watcher can tell us precisely what changed.
|
||||
// Remove this fallback once the language server also watches script environments.
|
||||
// Changes inside virtual environments should instead be watched and processed through `ProjectDatabase::apply_changes`.
|
||||
// Using a file watcher also ensures that virtual environment changes in
|
||||
// scripts without using uv are detected.
|
||||
|
||||
@@ -4,29 +4,33 @@ use std::hash::Hasher;
|
||||
use tracing::info;
|
||||
|
||||
use ruff_cache::{CacheKey, CacheKeyHasher};
|
||||
use ruff_db::files::Files;
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use rustc_hash::FxHashSet;
|
||||
use ty_module_resolver::system_module_search_paths;
|
||||
|
||||
use crate::Project;
|
||||
use crate::db::{Db, ProjectDatabase};
|
||||
use crate::script::Script;
|
||||
use crate::watch::Watcher;
|
||||
|
||||
/// Wrapper around a [`Watcher`] that watches the relevant paths of a project.
|
||||
pub struct ProjectWatcher {
|
||||
watcher: Watcher,
|
||||
|
||||
/// The paths that need to be watched. This includes paths for which setting up file watching failed.
|
||||
/// The paths currently watched, in registration order.
|
||||
watched_paths: Vec<SystemPathBuf>,
|
||||
|
||||
/// True if registering a watcher for any path failed.
|
||||
has_errored_paths: bool,
|
||||
|
||||
/// Cache key over the paths that need watching. It allows short-circuiting if the paths haven't changed.
|
||||
/// Cache key over the paths requested by the project and its scripts.
|
||||
cache_key: Option<u64>,
|
||||
}
|
||||
|
||||
impl ProjectWatcher {
|
||||
/// Create a new project watcher.
|
||||
pub fn new(watcher: Watcher, db: &ProjectDatabase) -> Self {
|
||||
pub fn new(watcher: Watcher, db: &mut ProjectDatabase) -> Self {
|
||||
let mut watcher = Self {
|
||||
watcher,
|
||||
watched_paths: Vec::new(),
|
||||
@@ -39,74 +43,78 @@ impl ProjectWatcher {
|
||||
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);
|
||||
pub fn update(&mut self, db: &mut ProjectDatabase) {
|
||||
const MAX_RECONCILIATION_PASSES: usize = 10;
|
||||
|
||||
let new_cache_key = Self::compute_cache_key(project_path, &search_paths);
|
||||
for _ in 0..MAX_RECONCILIATION_PASSES {
|
||||
if !self.update_once(db) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.cache_key == Some(new_cache_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut watcher_paths = self.watcher.paths_mut();
|
||||
|
||||
// Unregister all watch paths because ordering is important for linux because
|
||||
// it only emits an event for the last added watcher if a subtree is covered by multiple watchers.
|
||||
// A path can be covered by multiple watchers if a subdirectory symlinks to a path that's covered by another watch path:
|
||||
// ```text
|
||||
// - bar
|
||||
// - baz.py
|
||||
// - project
|
||||
// - bar -> /bar
|
||||
// - foo.py
|
||||
// ```
|
||||
for path in self.watched_paths.drain(..) {
|
||||
if let Err(error) = watcher_paths.remove(&path) {
|
||||
info!("Failed to remove the file watcher for path `{path}`: {error}");
|
||||
// `watch_paths` reads `.pth` files to find editable module search paths. If a
|
||||
// script's `site-packages` was unwatched when a `.pth` file changed from `/old`
|
||||
// to `/new`, this pass registered `/old` using stale contents. The refresh above
|
||||
// updates the `.pth` file; recompute the plan and register `/new` before returning.
|
||||
if self.cache_key == Some(watch_paths(db, db.project()).cache_key) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
"File watcher paths did not stabilize after {MAX_RECONCILIATION_PASSES} updates; changes outside the registered paths may be missed until the next update"
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns whether newly covered paths were refreshed.
|
||||
fn update_once(&mut self, db: &mut ProjectDatabase) -> bool {
|
||||
let watch_plan = watch_paths(db, db.project());
|
||||
|
||||
if self.cache_key == Some(watch_plan.cache_key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let paths = &watch_plan.paths;
|
||||
let previously_watched = self.watched_paths.clone();
|
||||
let mut watcher_paths = self.watcher.paths_mut();
|
||||
let mut newly_covered_paths = Vec::new();
|
||||
|
||||
// On Linux, overlapping watches through a symlink report events using the path of the
|
||||
// last registered watch. If `/project/bar` points to `/bar`, the module search path
|
||||
// `/bar` must be watched after `/project` so imports receive events under `/bar`.
|
||||
// Configuration paths come last so their events use their explicit paths. Appending
|
||||
// preserves this precedence; other changes require re-registering in the new order.
|
||||
let first_new_path = if !self.has_errored_paths && paths.starts_with(&self.watched_paths) {
|
||||
self.watched_paths.len()
|
||||
} else {
|
||||
for path in self.watched_paths.drain(..) {
|
||||
if let Err(error) = watcher_paths.remove(&path) {
|
||||
info!("Failed to remove the file watcher for path `{path}`: {error}");
|
||||
}
|
||||
}
|
||||
0
|
||||
};
|
||||
|
||||
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).
|
||||
// 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),
|
||||
),
|
||||
);
|
||||
|
||||
// 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)),
|
||||
);
|
||||
|
||||
// Now add the new paths, first starting with the project path and then
|
||||
// adding the library search paths, and finally the paths for configurations.
|
||||
for path in included_paths
|
||||
.chain(unique_module_paths)
|
||||
.chain(config_paths)
|
||||
.map(SystemPath::to_path_buf)
|
||||
{
|
||||
if let Err(error) = watcher_paths.add(&path) {
|
||||
for path in paths.iter().skip(first_new_path) {
|
||||
if let Err(error) = watcher_paths.add(path) {
|
||||
// TODO: Log a user-facing warning.
|
||||
tracing::warn!(
|
||||
"Failed to setup watcher for path `{path}`: {error}. You have to restart ty after making changes to files under this path or you might see stale results."
|
||||
);
|
||||
self.has_errored_paths = true;
|
||||
} else {
|
||||
self.watched_paths.push(path);
|
||||
// A previous recursive watch already covered this path, even if it must be
|
||||
// re-registered for precedence. Skip initial setup to avoid rescanning every
|
||||
// project file immediately after discovery.
|
||||
if self.cache_key.is_some()
|
||||
&& !previously_watched
|
||||
.iter()
|
||||
.any(|watched| path.starts_with(watched))
|
||||
{
|
||||
newly_covered_paths.push(path.clone());
|
||||
}
|
||||
self.watched_paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,15 +132,17 @@ impl ProjectWatcher {
|
||||
}
|
||||
);
|
||||
|
||||
self.cache_key = Some(new_cache_key);
|
||||
}
|
||||
self.cache_key = Some(watch_plan.cache_key);
|
||||
|
||||
fn compute_cache_key(project_root: &SystemPath, search_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);
|
||||
if newly_covered_paths.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
cache_key_hasher.finish()
|
||||
// A newly covered path may contain known files that changed while it was unwatched.
|
||||
// Registering the watch does not update their cached contents, so refresh them here.
|
||||
Files::sync_all_recursive(db, newly_covered_paths);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns `true` if setting up watching for any path failed.
|
||||
@@ -149,6 +159,79 @@ impl ProjectWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct WatchPaths {
|
||||
cache_key: u64,
|
||||
paths: Box<[SystemPathBuf]>,
|
||||
}
|
||||
|
||||
/// Watches are registered in project, module, then configuration order. On Linux, the last
|
||||
/// registered watch determines the path reported for overlapping symlinks.
|
||||
///
|
||||
/// Project roots and explicitly included paths come first because project files are discovered
|
||||
/// by walking them. Module search paths come next so imports are reported relative to their
|
||||
/// search roots, rather than through symlinks inside the project. Configuration paths come last
|
||||
/// so their events use the explicit paths checked for configuration changes.
|
||||
#[salsa::tracked(returns(ref))]
|
||||
fn watch_paths(db: &dyn Db, project: Project) -> WatchPaths {
|
||||
let project_path = project.root(db);
|
||||
|
||||
// Watch both the project root 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(
|
||||
project
|
||||
.included_paths_list(db)
|
||||
.iter()
|
||||
.map(SystemPathBuf::as_path),
|
||||
),
|
||||
);
|
||||
|
||||
let environment = project.program(db).resolver_environment(db);
|
||||
let mut search_paths: Vec<_> = system_module_search_paths(db, environment).collect();
|
||||
for file in project.script_files(db).iter() {
|
||||
if let Some(script) = Script::for_file(db, file) {
|
||||
search_paths.extend(system_module_search_paths(
|
||||
db,
|
||||
script.program(db).resolver_environment(db),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The project watch covers search paths inside its root. Deduplicate the others so
|
||||
// shared or nested search paths are registered once in stable order.
|
||||
let unique_module_paths = ruff_db::system::deduplicate_nested_paths(
|
||||
search_paths
|
||||
.into_iter()
|
||||
.filter(|path| !path.starts_with(project_path)),
|
||||
);
|
||||
|
||||
let paths: Vec<_> = included_paths
|
||||
.chain(unique_module_paths)
|
||||
.chain(project.metadata(db).extra_configuration_paths())
|
||||
.map(SystemPath::to_path_buf)
|
||||
.collect();
|
||||
|
||||
// Keep the last occurrence so later groups retain their registration precedence.
|
||||
let mut seen = FxHashSet::default();
|
||||
let mut unique_paths = Vec::new();
|
||||
for path in paths.into_iter().rev() {
|
||||
if seen.insert(path.clone()) {
|
||||
unique_paths.push(path);
|
||||
}
|
||||
}
|
||||
unique_paths.reverse();
|
||||
let paths = unique_paths.into_boxed_slice();
|
||||
|
||||
let mut hasher = CacheKeyHasher::new();
|
||||
paths.cache_key(&mut hasher);
|
||||
WatchPaths {
|
||||
cache_key: hasher.finish(),
|
||||
paths,
|
||||
}
|
||||
}
|
||||
|
||||
struct DisplayWatchedPaths<'a> {
|
||||
paths: &'a [SystemPathBuf],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user