mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-03 18:28:56 +00:00

## Summary This PR implements the first part of https://github.com/astral-sh/ruff/discussions/16440. It ensures that Red Knot's module resolver is case sensitive on all systems. This PR combines a few approaches: 1. It uses `canonicalize` on non-case-sensitive systems to get the real casing of a path. This works for as long as no symlinks or mapped network drives (the windows `E:\` is mapped to `\\server\share` thingy). This is the same as what Pyright does 2. If 1. fails, fall back to recursively list the parent directory and test if the path's file name matches the casing exactly as listed in by list dir. This is the same approach as CPython takes in its module resolver. The main downside is that it requires more syscalls because, unlike CPython, we Red Knot needs to invalidate its caches if a file name gets renamed (CPython assumes that the folders are immutable). It's worth noting that the file watching test that I added that renames `lib.py` to `Lib.py` currently doesn't pass on case-insensitive systems. Making it pass requires some more involved changes to `Files`. I plan to work on this next. There's the argument that landing this PR on its own isn't worth it without this issue being addressed. I think it's still a good step in the right direction even when some of the details on how and where the path case sensitive comparison is implemented. ## Test plan I added multiple integration tests (including a failing one). I tested that the `case-sensitivity` detection works as expected on Windows, MacOS and Linux and that the fast-paths are taken accordingly.
1869 lines
58 KiB
Rust
1869 lines
58 KiB
Rust
use std::collections::HashSet;
|
|
use std::io::Write;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{anyhow, Context};
|
|
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
|
|
use red_knot_project::metadata::pyproject::{PyProject, Tool};
|
|
use red_knot_project::metadata::value::{RangedValue, RelativePathBuf};
|
|
use red_knot_project::watch::{directory_watcher, ChangeEvent, ProjectWatcher};
|
|
use red_knot_project::{Db, ProjectDatabase, ProjectMetadata};
|
|
use red_knot_python_semantic::{resolve_module, ModuleName, PythonPlatform};
|
|
use ruff_db::files::{system_path_to_file, File, FileError};
|
|
use ruff_db::source::source_text;
|
|
use ruff_db::system::{
|
|
OsSystem, System, SystemPath, SystemPathBuf, UserConfigDirectoryOverrideGuard,
|
|
};
|
|
use ruff_db::{Db as _, Upcast};
|
|
use ruff_python_ast::PythonVersion;
|
|
|
|
struct TestCase {
|
|
db: ProjectDatabase,
|
|
watcher: Option<ProjectWatcher>,
|
|
changes_receiver: crossbeam::channel::Receiver<Vec<ChangeEvent>>,
|
|
/// The temporary directory that contains the test files.
|
|
/// We need to hold on to it in the test case or the temp files get deleted.
|
|
_temp_dir: tempfile::TempDir,
|
|
root_dir: SystemPathBuf,
|
|
}
|
|
|
|
impl TestCase {
|
|
fn project_path(&self, relative: impl AsRef<SystemPath>) -> SystemPathBuf {
|
|
SystemPath::absolute(relative, self.db.project().root(&self.db))
|
|
}
|
|
|
|
fn root_path(&self) -> &SystemPath {
|
|
&self.root_dir
|
|
}
|
|
|
|
fn db(&self) -> &ProjectDatabase {
|
|
&self.db
|
|
}
|
|
|
|
#[track_caller]
|
|
fn stop_watch<M>(&mut self, matcher: M) -> Vec<ChangeEvent>
|
|
where
|
|
M: MatchEvent,
|
|
{
|
|
// track_caller is unstable for lambdas -> That's why this is a fn
|
|
#[track_caller]
|
|
fn panic_with_formatted_events(events: Vec<ChangeEvent>) -> Vec<ChangeEvent> {
|
|
panic!(
|
|
"Didn't observe the expected event. The following events occurred:\n{}",
|
|
events
|
|
.into_iter()
|
|
.map(|event| format!(" - {event:?}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)
|
|
}
|
|
|
|
self.try_stop_watch(matcher, Duration::from_secs(10))
|
|
.unwrap_or_else(panic_with_formatted_events)
|
|
}
|
|
|
|
fn try_stop_watch<M>(
|
|
&mut self,
|
|
mut matcher: M,
|
|
timeout: Duration,
|
|
) -> Result<Vec<ChangeEvent>, Vec<ChangeEvent>>
|
|
where
|
|
M: MatchEvent,
|
|
{
|
|
tracing::debug!("Try stopping watch with timeout {:?}", timeout);
|
|
|
|
let watcher = self
|
|
.watcher
|
|
.take()
|
|
.expect("Cannot call `stop_watch` more than once");
|
|
|
|
let start = Instant::now();
|
|
let mut all_events = Vec::new();
|
|
|
|
loop {
|
|
let events = self
|
|
.changes_receiver
|
|
.recv_timeout(Duration::from_millis(100))
|
|
.unwrap_or_default();
|
|
|
|
if events
|
|
.iter()
|
|
.any(|event| matcher.match_event(event) || event.is_rescan())
|
|
{
|
|
all_events.extend(events);
|
|
break;
|
|
}
|
|
|
|
all_events.extend(events);
|
|
|
|
if start.elapsed() > timeout {
|
|
return Err(all_events);
|
|
}
|
|
}
|
|
|
|
watcher.flush();
|
|
tracing::debug!("Flushed file watcher");
|
|
watcher.stop();
|
|
tracing::debug!("Stopping file watcher");
|
|
|
|
// Consume remaining events
|
|
for event in &self.changes_receiver {
|
|
all_events.extend(event);
|
|
}
|
|
|
|
Ok(all_events)
|
|
}
|
|
|
|
fn take_watch_changes<M: MatchEvent>(&self, matcher: M) -> Vec<ChangeEvent> {
|
|
self.try_take_watch_changes(matcher, Duration::from_secs(10))
|
|
.expect("Expected watch changes but observed none")
|
|
}
|
|
|
|
fn try_take_watch_changes<M: MatchEvent>(
|
|
&self,
|
|
mut matcher: M,
|
|
timeout: Duration,
|
|
) -> Result<Vec<ChangeEvent>, Vec<ChangeEvent>> {
|
|
let watcher = self
|
|
.watcher
|
|
.as_ref()
|
|
.expect("Cannot call `try_take_watch_changes` after `stop_watch`");
|
|
|
|
let start = Instant::now();
|
|
let mut all_events = Vec::new();
|
|
|
|
loop {
|
|
let events = self
|
|
.changes_receiver
|
|
.recv_timeout(Duration::from_millis(100))
|
|
.unwrap_or_default();
|
|
|
|
if events
|
|
.iter()
|
|
.any(|event| matcher.match_event(event) || event.is_rescan())
|
|
{
|
|
all_events.extend(events);
|
|
break;
|
|
}
|
|
|
|
all_events.extend(events);
|
|
|
|
if start.elapsed() > timeout {
|
|
return Err(all_events);
|
|
}
|
|
}
|
|
|
|
while let Ok(event) = self
|
|
.changes_receiver
|
|
.recv_timeout(Duration::from_millis(10))
|
|
{
|
|
all_events.extend(event);
|
|
watcher.flush();
|
|
}
|
|
|
|
Ok(all_events)
|
|
}
|
|
|
|
fn apply_changes(&mut self, changes: Vec<ChangeEvent>) {
|
|
self.db.apply_changes(changes, None);
|
|
}
|
|
|
|
fn update_options(&mut self, options: Options) -> anyhow::Result<()> {
|
|
std::fs::write(
|
|
self.project_path("pyproject.toml").as_std_path(),
|
|
toml::to_string(&PyProject {
|
|
project: None,
|
|
tool: Some(Tool {
|
|
knot: Some(options),
|
|
}),
|
|
})
|
|
.context("Failed to serialize options")?,
|
|
)
|
|
.context("Failed to write configuration")?;
|
|
|
|
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(())
|
|
}
|
|
|
|
#[track_caller]
|
|
fn assert_indexed_project_files(&self, expected: impl IntoIterator<Item = File>) {
|
|
let mut expected: HashSet<_> = expected.into_iter().collect();
|
|
|
|
let actual = self.db().project().files(self.db());
|
|
for file in &actual {
|
|
assert!(
|
|
expected.remove(&file),
|
|
"Indexed project files contains '{}' which was not expected.",
|
|
file.path(self.db())
|
|
);
|
|
}
|
|
|
|
if !expected.is_empty() {
|
|
let paths: Vec<_> = expected
|
|
.iter()
|
|
.map(|file| file.path(self.db()).as_str())
|
|
.collect();
|
|
panic!(
|
|
"Indexed project files are missing the following files: {:?}",
|
|
paths.join(", ")
|
|
);
|
|
}
|
|
}
|
|
|
|
fn system_file(&self, path: impl AsRef<SystemPath>) -> Result<File, FileError> {
|
|
system_path_to_file(self.db(), path.as_ref())
|
|
}
|
|
}
|
|
|
|
trait MatchEvent {
|
|
fn match_event(&mut self, event: &ChangeEvent) -> bool;
|
|
}
|
|
|
|
fn event_for_file(name: &str) -> impl MatchEvent + '_ {
|
|
|event: &ChangeEvent| event.file_name() == Some(name)
|
|
}
|
|
|
|
impl<F> MatchEvent for F
|
|
where
|
|
F: FnMut(&ChangeEvent) -> bool,
|
|
{
|
|
fn match_event(&mut self, event: &ChangeEvent) -> bool {
|
|
(*self)(event)
|
|
}
|
|
}
|
|
|
|
trait Setup {
|
|
fn setup(self, context: &mut SetupContext) -> anyhow::Result<()>;
|
|
}
|
|
|
|
struct SetupContext<'a> {
|
|
system: &'a OsSystem,
|
|
root_path: &'a SystemPath,
|
|
options: Option<Options>,
|
|
included_paths: Option<Vec<SystemPathBuf>>,
|
|
}
|
|
|
|
impl<'a> SetupContext<'a> {
|
|
fn system(&self) -> &'a OsSystem {
|
|
self.system
|
|
}
|
|
|
|
fn join_project_path(&self, relative: impl AsRef<SystemPath>) -> SystemPathBuf {
|
|
self.project_path().join(relative)
|
|
}
|
|
|
|
fn project_path(&self) -> &SystemPath {
|
|
self.system.current_directory()
|
|
}
|
|
|
|
fn root_path(&self) -> &'a SystemPath {
|
|
self.root_path
|
|
}
|
|
|
|
fn join_root_path(&self, relative: impl AsRef<SystemPath>) -> SystemPathBuf {
|
|
self.root_path().join(relative)
|
|
}
|
|
|
|
fn write_project_file(
|
|
&self,
|
|
relative_path: impl AsRef<SystemPath>,
|
|
content: &str,
|
|
) -> anyhow::Result<()> {
|
|
let relative_path = relative_path.as_ref();
|
|
let absolute_path = self.join_project_path(relative_path);
|
|
Self::write_file_impl(absolute_path, content)
|
|
}
|
|
|
|
fn write_file(
|
|
&self,
|
|
relative_path: impl AsRef<SystemPath>,
|
|
content: &str,
|
|
) -> anyhow::Result<()> {
|
|
let relative_path = relative_path.as_ref();
|
|
let absolute_path = self.join_root_path(relative_path);
|
|
Self::write_file_impl(absolute_path, content)
|
|
}
|
|
|
|
fn write_file_impl(path: impl AsRef<SystemPath>, content: &str) -> anyhow::Result<()> {
|
|
let path = path.as_ref();
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create parent directory for file `{path}`"))?;
|
|
}
|
|
|
|
let mut file = std::fs::File::create(path.as_std_path())
|
|
.with_context(|| format!("Failed to open file `{path}`"))?;
|
|
file.write_all(content.as_bytes())
|
|
.with_context(|| format!("Failed to write to file `{path}`"))?;
|
|
file.sync_data()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn set_options(&mut self, options: Options) {
|
|
self.options = Some(options);
|
|
}
|
|
|
|
fn set_included_paths(&mut self, paths: Vec<SystemPathBuf>) {
|
|
self.included_paths = Some(paths);
|
|
}
|
|
}
|
|
|
|
impl<const N: usize, P> Setup for [(P, &'static str); N]
|
|
where
|
|
P: AsRef<SystemPath>,
|
|
{
|
|
fn setup(self, context: &mut SetupContext) -> anyhow::Result<()> {
|
|
for (relative_path, content) in self {
|
|
context.write_project_file(relative_path, content)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<F> Setup for F
|
|
where
|
|
F: FnOnce(&mut SetupContext) -> anyhow::Result<()>,
|
|
{
|
|
fn setup(self, context: &mut SetupContext) -> anyhow::Result<()> {
|
|
self(context)
|
|
}
|
|
}
|
|
|
|
fn setup<F>(setup_files: F) -> anyhow::Result<TestCase>
|
|
where
|
|
F: Setup,
|
|
{
|
|
let temp_dir = tempfile::tempdir()?;
|
|
|
|
let root_path = SystemPath::from_std_path(temp_dir.path()).ok_or_else(|| {
|
|
anyhow!(
|
|
"Temporary directory `{}` is not a valid UTF-8 path.",
|
|
temp_dir.path().display()
|
|
)
|
|
})?;
|
|
|
|
let root_path = SystemPathBuf::from_utf8_path_buf(
|
|
root_path
|
|
.as_utf8_path()
|
|
.canonicalize_utf8()
|
|
.with_context(|| "Failed to canonicalize root path.")?,
|
|
)
|
|
.simplified()
|
|
.to_path_buf();
|
|
|
|
let project_path = root_path.join("project");
|
|
|
|
std::fs::create_dir_all(project_path.as_std_path())
|
|
.with_context(|| format!("Failed to create project directory `{project_path}`"))?;
|
|
|
|
let system = OsSystem::new(&project_path);
|
|
let mut setup_context = SetupContext {
|
|
system: &system,
|
|
root_path: &root_path,
|
|
options: None,
|
|
included_paths: None,
|
|
};
|
|
|
|
setup_files
|
|
.setup(&mut setup_context)
|
|
.context("Failed to setup test files")?;
|
|
|
|
if let Some(options) = setup_context.options {
|
|
std::fs::write(
|
|
project_path.join("pyproject.toml").as_std_path(),
|
|
toml::to_string(&PyProject {
|
|
project: None,
|
|
tool: Some(Tool {
|
|
knot: Some(options),
|
|
}),
|
|
})
|
|
.context("Failed to serialize options")?,
|
|
)
|
|
.context("Failed to write configuration")?;
|
|
}
|
|
|
|
let included_paths = setup_context.included_paths;
|
|
|
|
let mut project = ProjectMetadata::discover(&project_path, &system)?;
|
|
project.apply_configuration_files(&system)?;
|
|
|
|
let program_settings = project.to_program_settings(&system);
|
|
|
|
for path in program_settings
|
|
.search_paths
|
|
.extra_paths
|
|
.iter()
|
|
.chain(program_settings.search_paths.custom_typeshed.as_ref())
|
|
{
|
|
std::fs::create_dir_all(path.as_std_path())
|
|
.with_context(|| format!("Failed to create search path `{path}`"))?;
|
|
}
|
|
|
|
let mut db = ProjectDatabase::new(project, system)?;
|
|
|
|
if let Some(included_paths) = included_paths {
|
|
db.project().set_included_paths(&mut db, included_paths);
|
|
}
|
|
|
|
let (sender, receiver) = crossbeam::channel::unbounded();
|
|
let watcher = directory_watcher(move |events| sender.send(events).unwrap())
|
|
.with_context(|| "Failed to create directory watcher")?;
|
|
|
|
let watcher = ProjectWatcher::new(watcher, &db);
|
|
assert!(!watcher.has_errored_paths());
|
|
|
|
let test_case = TestCase {
|
|
db,
|
|
changes_receiver: receiver,
|
|
watcher: Some(watcher),
|
|
_temp_dir: temp_dir,
|
|
root_dir: root_path,
|
|
};
|
|
|
|
// Sometimes the file watcher reports changes for events that happened before the watcher was started.
|
|
// Do a best effort at dropping these events.
|
|
let _ =
|
|
test_case.try_take_watch_changes(|_event: &ChangeEvent| true, Duration::from_millis(100));
|
|
|
|
Ok(test_case)
|
|
}
|
|
|
|
/// Updates the content of a file and ensures that the last modified file time is updated.
|
|
fn update_file(path: impl AsRef<SystemPath>, content: &str) -> anyhow::Result<()> {
|
|
let path = path.as_ref().as_std_path();
|
|
|
|
let metadata = path.metadata()?;
|
|
let last_modified_time = filetime::FileTime::from_last_modification_time(&metadata);
|
|
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.create(false)
|
|
.write(true)
|
|
.truncate(true)
|
|
.open(path)?;
|
|
file.write_all(content.as_bytes())?;
|
|
|
|
loop {
|
|
file.sync_all()?;
|
|
|
|
let modified_time = filetime::FileTime::from_last_modification_time(&path.metadata()?);
|
|
|
|
if modified_time != last_modified_time {
|
|
break Ok(());
|
|
}
|
|
|
|
std::thread::sleep(Duration::from_nanos(10));
|
|
|
|
filetime::set_file_handle_times(&file, None, Some(filetime::FileTime::now()))?;
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn new_file() -> anyhow::Result<()> {
|
|
let mut case = setup([("bar.py", "")])?;
|
|
let bar_path = case.project_path("bar.py");
|
|
let bar_file = case.system_file(&bar_path).unwrap();
|
|
let foo_path = case.project_path("foo.py");
|
|
|
|
assert_eq!(case.system_file(&foo_path), Err(FileError::NotFound));
|
|
case.assert_indexed_project_files([bar_file]);
|
|
|
|
std::fs::write(foo_path.as_std_path(), "print('Hello')")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
let foo = case.system_file(&foo_path).expect("foo.py to exist.");
|
|
|
|
case.assert_indexed_project_files([bar_file, foo]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn new_ignored_file() -> anyhow::Result<()> {
|
|
let mut case = setup([("bar.py", ""), (".ignore", "foo.py")])?;
|
|
let bar_path = case.project_path("bar.py");
|
|
let bar_file = case.system_file(&bar_path).unwrap();
|
|
let foo_path = case.project_path("foo.py");
|
|
|
|
assert_eq!(case.system_file(&foo_path), Err(FileError::NotFound));
|
|
case.assert_indexed_project_files([bar_file]);
|
|
|
|
std::fs::write(foo_path.as_std_path(), "print('Hello')")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(case.system_file(&foo_path).is_ok());
|
|
case.assert_indexed_project_files([bar_file]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn new_non_project_file() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
context.write_project_file("bar.py", "")?;
|
|
context.set_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
extra_paths: Some(vec![RelativePathBuf::cli(
|
|
context.join_root_path("site_packages"),
|
|
)]),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
});
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let bar_path = case.project_path("bar.py");
|
|
let bar_file = case.system_file(&bar_path).unwrap();
|
|
|
|
case.assert_indexed_project_files([bar_file]);
|
|
|
|
// Add a file to site packages
|
|
let black_path = case.root_path().join("site_packages/black.py");
|
|
std::fs::write(black_path.as_std_path(), "print('Hello')")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("black.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(case.system_file(&black_path).is_ok());
|
|
|
|
// The file should not have been added to the project files
|
|
case.assert_indexed_project_files([bar_file]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn new_files_with_explicit_included_paths() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
context.write_project_file("src/main.py", "")?;
|
|
context.write_project_file("src/sub/__init__.py", "")?;
|
|
context.write_project_file("src/test.py", "")?;
|
|
context.set_included_paths(vec![
|
|
context.join_project_path("src/main.py"),
|
|
context.join_project_path("src/sub"),
|
|
]);
|
|
Ok(())
|
|
})?;
|
|
|
|
let main_path = case.project_path("src/main.py");
|
|
let main_file = case.system_file(&main_path).unwrap();
|
|
|
|
let sub_init_path = case.project_path("src/sub/__init__.py");
|
|
let sub_init = case.system_file(&sub_init_path).unwrap();
|
|
|
|
case.assert_indexed_project_files([main_file, sub_init]);
|
|
|
|
// Write a new file to `sub` which is an included path
|
|
let sub_a_path = case.project_path("src/sub/a.py");
|
|
std::fs::write(sub_a_path.as_std_path(), "print('Hello')")?;
|
|
|
|
// and write a second file in the root directory -- this should not be included
|
|
let test2_path = case.project_path("src/test2.py");
|
|
std::fs::write(test2_path.as_std_path(), "print('Hello')")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("test2.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
let sub_a_file = case.system_file(&sub_a_path).expect("sub/a.py to exist");
|
|
|
|
case.assert_indexed_project_files([main_file, sub_init, sub_a_file]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn new_file_in_included_out_of_project_directory() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
context.write_project_file("src/main.py", "")?;
|
|
context.write_project_file("script.py", "")?;
|
|
context.write_file("outside_project/a.py", "")?;
|
|
|
|
context.set_included_paths(vec![
|
|
context.join_root_path("outside_project"),
|
|
context.join_project_path("src"),
|
|
]);
|
|
Ok(())
|
|
})?;
|
|
|
|
let main_path = case.project_path("src/main.py");
|
|
let main_file = case.system_file(&main_path).unwrap();
|
|
|
|
let outside_a_path = case.root_path().join("outside_project/a.py");
|
|
let outside_a = case.system_file(&outside_a_path).unwrap();
|
|
|
|
case.assert_indexed_project_files([outside_a, main_file]);
|
|
|
|
// Write a new file to `src` which should be watched
|
|
let src_a = case.project_path("src/a.py");
|
|
std::fs::write(src_a.as_std_path(), "print('Hello')")?;
|
|
|
|
// and write a second file to `outside_project` which should be watched too
|
|
let outside_b_path = case.root_path().join("outside_project/b.py");
|
|
std::fs::write(outside_b_path.as_std_path(), "print('Hello')")?;
|
|
|
|
// and a third file in the project's root that should not be included
|
|
let script2_path = case.project_path("script2.py");
|
|
std::fs::write(script2_path.as_std_path(), "print('Hello')")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("script2.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
let src_a_file = case.system_file(&src_a).unwrap();
|
|
let outside_b_file = case.system_file(&outside_b_path).unwrap();
|
|
|
|
// The file should not have been added to the project files
|
|
case.assert_indexed_project_files([main_file, outside_a, outside_b_file, src_a_file]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn changed_file() -> anyhow::Result<()> {
|
|
let foo_source = "print('Hello, world!')";
|
|
let mut case = setup([("foo.py", foo_source)])?;
|
|
let foo_path = case.project_path("foo.py");
|
|
|
|
let foo = case.system_file(&foo_path)?;
|
|
assert_eq!(source_text(case.db(), foo).as_str(), foo_source);
|
|
case.assert_indexed_project_files([foo]);
|
|
|
|
update_file(&foo_path, "print('Version 2')")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
assert!(!changes.is_empty());
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')");
|
|
case.assert_indexed_project_files([foo]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_file() -> anyhow::Result<()> {
|
|
let foo_source = "print('Hello, world!')";
|
|
let mut case = setup([("foo.py", foo_source)])?;
|
|
let foo_path = case.project_path("foo.py");
|
|
|
|
let foo = case.system_file(&foo_path)?;
|
|
|
|
assert!(foo.exists(case.db()));
|
|
case.assert_indexed_project_files([foo]);
|
|
|
|
std::fs::remove_file(foo_path.as_std_path())?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(!foo.exists(case.db()));
|
|
case.assert_indexed_project_files([]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Tests the case where a file is moved from inside a watched directory to a directory that is not watched.
|
|
///
|
|
/// This matches the behavior of deleting a file in VS code.
|
|
#[test]
|
|
fn move_file_to_trash() -> anyhow::Result<()> {
|
|
let foo_source = "print('Hello, world!')";
|
|
let mut case = setup([("foo.py", foo_source)])?;
|
|
let foo_path = case.project_path("foo.py");
|
|
|
|
let trash_path = case.root_path().join(".trash");
|
|
std::fs::create_dir_all(trash_path.as_std_path())?;
|
|
|
|
let foo = case.system_file(&foo_path)?;
|
|
|
|
assert!(foo.exists(case.db()));
|
|
case.assert_indexed_project_files([foo]);
|
|
|
|
std::fs::rename(
|
|
foo_path.as_std_path(),
|
|
trash_path.join("foo.py").as_std_path(),
|
|
)?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(!foo.exists(case.db()));
|
|
case.assert_indexed_project_files([]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Move a file from a non-project (non-watched) location into the project.
|
|
#[test]
|
|
fn move_file_to_project() -> anyhow::Result<()> {
|
|
let mut case = setup([("bar.py", "")])?;
|
|
let bar_path = case.project_path("bar.py");
|
|
let bar = case.system_file(&bar_path).unwrap();
|
|
|
|
let foo_path = case.root_path().join("foo.py");
|
|
std::fs::write(foo_path.as_std_path(), "")?;
|
|
|
|
let foo_in_project = case.project_path("foo.py");
|
|
|
|
assert!(case.system_file(&foo_path).is_ok());
|
|
case.assert_indexed_project_files([bar]);
|
|
|
|
std::fs::rename(foo_path.as_std_path(), foo_in_project.as_std_path())?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
let foo_in_project = case.system_file(&foo_in_project)?;
|
|
|
|
assert!(foo_in_project.exists(case.db()));
|
|
case.assert_indexed_project_files([bar, foo_in_project]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Rename a project file.
|
|
#[test]
|
|
fn rename_file() -> anyhow::Result<()> {
|
|
let mut case = setup([("foo.py", "")])?;
|
|
let foo_path = case.project_path("foo.py");
|
|
let bar_path = case.project_path("bar.py");
|
|
|
|
let foo = case.system_file(&foo_path)?;
|
|
|
|
case.assert_indexed_project_files([foo]);
|
|
|
|
std::fs::rename(foo_path.as_std_path(), bar_path.as_std_path())?;
|
|
|
|
let changes = case.stop_watch(event_for_file("bar.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(!foo.exists(case.db()));
|
|
|
|
let bar = case.system_file(&bar_path)?;
|
|
|
|
assert!(bar.exists(case.db()));
|
|
case.assert_indexed_project_files([bar]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn directory_moved_to_project() -> anyhow::Result<()> {
|
|
let mut case = setup([("bar.py", "import sub.a")])?;
|
|
let bar = case.system_file(case.project_path("bar.py")).unwrap();
|
|
|
|
let sub_original_path = case.root_path().join("sub");
|
|
let init_original_path = sub_original_path.join("__init__.py");
|
|
let a_original_path = sub_original_path.join("a.py");
|
|
|
|
std::fs::create_dir(sub_original_path.as_std_path())
|
|
.with_context(|| "Failed to create sub directory")?;
|
|
std::fs::write(init_original_path.as_std_path(), "")
|
|
.with_context(|| "Failed to create __init__.py")?;
|
|
std::fs::write(a_original_path.as_std_path(), "").with_context(|| "Failed to create a.py")?;
|
|
|
|
let sub_a_module = resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap(),
|
|
);
|
|
|
|
assert_eq!(sub_a_module, None);
|
|
case.assert_indexed_project_files([bar]);
|
|
|
|
let sub_new_path = case.project_path("sub");
|
|
std::fs::rename(sub_original_path.as_std_path(), sub_new_path.as_std_path())
|
|
.with_context(|| "Failed to move sub directory")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("sub"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
let init_file = case
|
|
.system_file(sub_new_path.join("__init__.py"))
|
|
.expect("__init__.py to exist");
|
|
let a_file = case
|
|
.system_file(sub_new_path.join("a.py"))
|
|
.expect("a.py to exist");
|
|
|
|
// `import sub.a` should now resolve
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap()
|
|
)
|
|
.is_some());
|
|
|
|
case.assert_indexed_project_files([bar, init_file, a_file]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn directory_moved_to_trash() -> anyhow::Result<()> {
|
|
let mut case = setup([
|
|
("bar.py", "import sub.a"),
|
|
("sub/__init__.py", ""),
|
|
("sub/a.py", ""),
|
|
])?;
|
|
let bar = case.system_file(case.project_path("bar.py")).unwrap();
|
|
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap()
|
|
)
|
|
.is_some());
|
|
|
|
let sub_path = case.project_path("sub");
|
|
let init_file = case
|
|
.system_file(sub_path.join("__init__.py"))
|
|
.expect("__init__.py to exist");
|
|
let a_file = case
|
|
.system_file(sub_path.join("a.py"))
|
|
.expect("a.py to exist");
|
|
|
|
case.assert_indexed_project_files([bar, init_file, a_file]);
|
|
|
|
std::fs::create_dir(case.root_path().join(".trash").as_std_path())?;
|
|
let trashed_sub = case.root_path().join(".trash/sub");
|
|
std::fs::rename(sub_path.as_std_path(), trashed_sub.as_std_path())
|
|
.with_context(|| "Failed to move the sub directory to the trash")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("sub"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
// `import sub.a` should no longer resolve
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap()
|
|
)
|
|
.is_none());
|
|
|
|
assert!(!init_file.exists(case.db()));
|
|
assert!(!a_file.exists(case.db()));
|
|
|
|
case.assert_indexed_project_files([bar]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn directory_renamed() -> anyhow::Result<()> {
|
|
let mut case = setup([
|
|
("bar.py", "import sub.a"),
|
|
("sub/__init__.py", ""),
|
|
("sub/a.py", ""),
|
|
])?;
|
|
|
|
let bar = case.system_file(case.project_path("bar.py")).unwrap();
|
|
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap()
|
|
)
|
|
.is_some());
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("foo.baz").unwrap()
|
|
)
|
|
.is_none());
|
|
|
|
let sub_path = case.project_path("sub");
|
|
let sub_init = case
|
|
.system_file(sub_path.join("__init__.py"))
|
|
.expect("__init__.py to exist");
|
|
let sub_a = case
|
|
.system_file(sub_path.join("a.py"))
|
|
.expect("a.py to exist");
|
|
|
|
case.assert_indexed_project_files([bar, sub_init, sub_a]);
|
|
|
|
let foo_baz = case.project_path("foo/baz");
|
|
|
|
std::fs::create_dir(case.project_path("foo").as_std_path())?;
|
|
std::fs::rename(sub_path.as_std_path(), foo_baz.as_std_path())
|
|
.with_context(|| "Failed to move the sub directory")?;
|
|
|
|
// Linux and windows only emit an event for the newly created root directory, but not for every new component.
|
|
let changes = case.stop_watch(event_for_file("sub"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
// `import sub.a` should no longer resolve
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap()
|
|
)
|
|
.is_none());
|
|
// `import foo.baz` should now resolve
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("foo.baz").unwrap()
|
|
)
|
|
.is_some());
|
|
|
|
// The old paths are no longer tracked
|
|
assert!(!sub_init.exists(case.db()));
|
|
assert!(!sub_a.exists(case.db()));
|
|
|
|
let foo_baz_init = case
|
|
.system_file(foo_baz.join("__init__.py"))
|
|
.expect("__init__.py to exist");
|
|
let foo_baz_a = case
|
|
.system_file(foo_baz.join("a.py"))
|
|
.expect("a.py to exist");
|
|
|
|
// The new paths are synced
|
|
|
|
assert!(foo_baz_init.exists(case.db()));
|
|
assert!(foo_baz_a.exists(case.db()));
|
|
|
|
case.assert_indexed_project_files([bar, foo_baz_init, foo_baz_a]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn directory_deleted() -> anyhow::Result<()> {
|
|
let mut case = setup([
|
|
("bar.py", "import sub.a"),
|
|
("sub/__init__.py", ""),
|
|
("sub/a.py", ""),
|
|
])?;
|
|
|
|
let bar = case.system_file(case.project_path("bar.py")).unwrap();
|
|
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap()
|
|
)
|
|
.is_some());
|
|
|
|
let sub_path = case.project_path("sub");
|
|
|
|
let init_file = case
|
|
.system_file(sub_path.join("__init__.py"))
|
|
.expect("__init__.py to exist");
|
|
let a_file = case
|
|
.system_file(sub_path.join("a.py"))
|
|
.expect("a.py to exist");
|
|
case.assert_indexed_project_files([bar, init_file, a_file]);
|
|
|
|
std::fs::remove_dir_all(sub_path.as_std_path())
|
|
.with_context(|| "Failed to remove the sub directory")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("sub"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
// `import sub.a` should no longer resolve
|
|
assert!(resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("sub.a").unwrap()
|
|
)
|
|
.is_none());
|
|
|
|
assert!(!init_file.exists(case.db()));
|
|
assert!(!a_file.exists(case.db()));
|
|
case.assert_indexed_project_files([bar]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn search_path() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
context.write_project_file("bar.py", "import sub.a")?;
|
|
|
|
context.set_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
extra_paths: Some(vec![RelativePathBuf::cli(
|
|
context.join_root_path("site_packages"),
|
|
)]),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
});
|
|
Ok(())
|
|
})?;
|
|
|
|
let site_packages = case.root_path().join("site_packages");
|
|
|
|
assert_eq!(
|
|
resolve_module(case.db(), &ModuleName::new("a").unwrap()),
|
|
None
|
|
);
|
|
|
|
std::fs::write(site_packages.join("a.py").as_std_path(), "class A: ...")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("a.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some());
|
|
case.assert_indexed_project_files([case.system_file(case.project_path("bar.py")).unwrap()]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn add_search_path() -> anyhow::Result<()> {
|
|
let mut case = setup([("bar.py", "import sub.a")])?;
|
|
|
|
let site_packages = case.project_path("site_packages");
|
|
std::fs::create_dir_all(site_packages.as_std_path())?;
|
|
|
|
assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_none());
|
|
|
|
// Register site-packages as a search path.
|
|
case.update_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
extra_paths: Some(vec![RelativePathBuf::cli("site_packages")]),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
})
|
|
.expect("Search path settings to be valid");
|
|
|
|
std::fs::write(site_packages.join("a.py").as_std_path(), "class A: ...")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("a.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn remove_search_path() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
context.write_project_file("bar.py", "import sub.a")?;
|
|
context.set_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
extra_paths: Some(vec![RelativePathBuf::cli(
|
|
context.join_root_path("site_packages"),
|
|
)]),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
});
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
// Remove site packages from the search path settings.
|
|
let site_packages = case.root_path().join("site_packages");
|
|
|
|
case.update_options(Options {
|
|
environment: None,
|
|
..Options::default()
|
|
})
|
|
.expect("Search path settings to be valid");
|
|
|
|
std::fs::write(site_packages.join("a.py").as_std_path(), "class A: ...")?;
|
|
|
|
let changes = case.try_stop_watch(|_: &ChangeEvent| true, Duration::from_millis(100));
|
|
|
|
assert_eq!(changes, Err(vec![]));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn change_python_version_and_platform() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
// `sys.last_exc` is a Python 3.12 only feature
|
|
// `os.getegid()` is Unix only
|
|
context.write_project_file(
|
|
"bar.py",
|
|
r#"
|
|
import sys
|
|
import os
|
|
print(sys.last_exc, os.getegid())
|
|
"#,
|
|
)?;
|
|
context.set_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
python_version: Some(RangedValue::cli(PythonVersion::PY311)),
|
|
python_platform: Some(RangedValue::cli(PythonPlatform::Identifier(
|
|
"win32".to_string(),
|
|
))),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
});
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let diagnostics = case.db.check().context("Failed to check project.")?;
|
|
|
|
assert_eq!(diagnostics.len(), 2);
|
|
assert_eq!(
|
|
diagnostics[0].message(),
|
|
"Type `<module 'sys'>` has no attribute `last_exc`"
|
|
);
|
|
assert_eq!(
|
|
diagnostics[1].message(),
|
|
"Type `<module 'os'>` has no attribute `getegid`"
|
|
);
|
|
|
|
// Change the python version
|
|
case.update_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
|
|
python_platform: Some(RangedValue::cli(PythonPlatform::Identifier(
|
|
"linux".to_string(),
|
|
))),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
})
|
|
.expect("Search path settings to be valid");
|
|
|
|
let diagnostics = case.db.check().context("Failed to check project.")?;
|
|
assert!(diagnostics.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn changed_versions_file() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
std::fs::write(
|
|
context.join_project_path("bar.py").as_std_path(),
|
|
"import sub.a",
|
|
)?;
|
|
std::fs::create_dir_all(context.join_root_path("typeshed/stdlib").as_std_path())?;
|
|
std::fs::write(
|
|
context
|
|
.join_root_path("typeshed/stdlib/VERSIONS")
|
|
.as_std_path(),
|
|
"",
|
|
)?;
|
|
std::fs::write(
|
|
context
|
|
.join_root_path("typeshed/stdlib/os.pyi")
|
|
.as_std_path(),
|
|
"# not important",
|
|
)?;
|
|
|
|
context.set_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
typeshed: Some(RelativePathBuf::cli(context.join_root_path("typeshed"))),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
});
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
// Unset the custom typeshed directory.
|
|
assert_eq!(
|
|
resolve_module(case.db(), &ModuleName::new("os").unwrap()),
|
|
None
|
|
);
|
|
|
|
std::fs::write(
|
|
case.root_path()
|
|
.join("typeshed/stdlib/VERSIONS")
|
|
.as_std_path(),
|
|
"os: 3.0-",
|
|
)?;
|
|
|
|
let changes = case.stop_watch(event_for_file("VERSIONS"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert!(resolve_module(case.db(), &ModuleName::new("os").unwrap()).is_some());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Watch a project that contains two files where one file is a hardlink to another.
|
|
///
|
|
/// Setup:
|
|
/// ```text
|
|
/// - project
|
|
/// |- foo.py
|
|
/// |- bar.py (hard link to foo.py)
|
|
/// ```
|
|
///
|
|
/// # Linux
|
|
/// `inotify` only emits a single change event for the file that was changed.
|
|
/// Other files that point to the same inode (hardlinks) won't get updated.
|
|
///
|
|
/// For reference: VS Code and PyCharm have the same behavior where the results for one of the
|
|
/// files are stale.
|
|
///
|
|
/// # Windows
|
|
/// I haven't found any documentation that states the notification behavior on Windows but what
|
|
/// we're seeing is that Windows only emits a single event, similar to Linux.
|
|
#[test]
|
|
fn hard_links_in_project() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
let foo_path = context.join_project_path("foo.py");
|
|
std::fs::write(foo_path.as_std_path(), "print('Version 1')")?;
|
|
|
|
// Create a hardlink to `foo`
|
|
let bar_path = context.join_project_path("bar.py");
|
|
std::fs::hard_link(foo_path.as_std_path(), bar_path.as_std_path())
|
|
.context("Failed to create hard link from foo.py -> bar.py")?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let foo_path = case.project_path("foo.py");
|
|
let foo = case.system_file(&foo_path).unwrap();
|
|
let bar_path = case.project_path("bar.py");
|
|
let bar = case.system_file(&bar_path).unwrap();
|
|
|
|
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 1')");
|
|
assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 1')");
|
|
case.assert_indexed_project_files([bar, foo]);
|
|
|
|
// Write to the hard link target.
|
|
update_file(foo_path, "print('Version 2')").context("Failed to update foo.py")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')");
|
|
|
|
// macOS is the only platform that emits events for every hardlink.
|
|
if cfg!(target_os = "macos") {
|
|
assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 2')");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Watch a project that contains one file that is a hardlink to a file outside the project.
|
|
///
|
|
/// Setup:
|
|
/// ```text
|
|
/// - foo.py
|
|
/// - project
|
|
/// |- bar.py (hard link to /foo.py)
|
|
/// ```
|
|
///
|
|
/// # Linux
|
|
/// inotiyf doesn't support observing changes to hard linked files.
|
|
///
|
|
/// > Note: when monitoring a directory, events are not generated for
|
|
/// > the files inside the directory when the events are performed via
|
|
/// > a pathname (i.e., a link) that lies outside the monitored
|
|
/// > directory. [source](https://man7.org/linux/man-pages/man7/inotify.7.html)
|
|
///
|
|
/// # Windows
|
|
/// > Retrieves information that describes the changes within the specified directory.
|
|
///
|
|
/// [source](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-readdirectorychangesw)
|
|
///
|
|
/// My interpretation of this is that Windows doesn't support observing changes made to
|
|
/// hard linked files outside the project.
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "linux",
|
|
ignore = "inotify doesn't support observing changes to hard linked files."
|
|
)]
|
|
#[cfg_attr(
|
|
target_os = "windows",
|
|
ignore = "windows doesn't support observing changes to hard linked files."
|
|
)]
|
|
fn hard_links_to_target_outside_project() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
let foo_path = context.join_root_path("foo.py");
|
|
std::fs::write(foo_path.as_std_path(), "print('Version 1')")?;
|
|
|
|
// Create a hardlink to `foo`
|
|
let bar_path = context.join_project_path("bar.py");
|
|
std::fs::hard_link(foo_path.as_std_path(), bar_path.as_std_path())
|
|
.context("Failed to create hard link from foo.py -> bar.py")?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let foo_path = case.root_path().join("foo.py");
|
|
let foo = case.system_file(&foo_path).unwrap();
|
|
let bar_path = case.project_path("bar.py");
|
|
let bar = case.system_file(&bar_path).unwrap();
|
|
|
|
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 1')");
|
|
assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 1')");
|
|
|
|
// Write to the hard link target.
|
|
update_file(foo_path, "print('Version 2')").context("Failed to update foo.py")?;
|
|
|
|
let changes = case.stop_watch(ChangeEvent::is_changed);
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 2')");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
mod unix {
|
|
//! Tests that make use of unix specific file-system features.
|
|
use super::*;
|
|
|
|
/// Changes the metadata of the only file in the project.
|
|
#[test]
|
|
fn changed_metadata() -> anyhow::Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let mut case = setup([("foo.py", "")])?;
|
|
let foo_path = case.project_path("foo.py");
|
|
|
|
let foo = case.system_file(&foo_path)?;
|
|
assert_eq!(
|
|
foo.permissions(case.db()),
|
|
Some(
|
|
std::fs::metadata(foo_path.as_std_path())
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
)
|
|
);
|
|
|
|
std::fs::set_permissions(
|
|
foo_path.as_std_path(),
|
|
std::fs::Permissions::from_mode(0o777),
|
|
)
|
|
.with_context(|| "Failed to set file permissions.")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("foo.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert_eq!(
|
|
foo.permissions(case.db()),
|
|
Some(
|
|
std::fs::metadata(foo_path.as_std_path())
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A project path is a symlink to a file outside the project.
|
|
///
|
|
/// Setup:
|
|
/// ```text
|
|
/// - bar
|
|
/// |- baz.py
|
|
///
|
|
/// - project
|
|
/// |- bar -> /bar
|
|
/// ```
|
|
///
|
|
/// # macOS
|
|
/// This test case isn't supported on macOS.
|
|
/// macOS uses `FSEvents` and `FSEvents` doesn't emit an event if a file in a symlinked directory is changed.
|
|
///
|
|
/// > Generally speaking, when working with file system event notifications, you will probably want to use lstat,
|
|
/// > because changes to the underlying file will not result in a change notification for the directory containing
|
|
/// > the symbolic link to that file. However, if you are working with a controlled file structure in
|
|
/// > which symbolic links always point within your watched tree, you might have reason to use stat.
|
|
///
|
|
/// [source](https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/FSEvents_ProgGuide/UsingtheFSEventsFramework/UsingtheFSEventsFramework.html#//apple_ref/doc/uid/TP40005289-CH4-SW4)
|
|
///
|
|
/// Pyright also does not support this case.
|
|
#[test]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
ignore = "FSEvents doesn't emit change events for symlinked directories outside of the watched paths."
|
|
)]
|
|
fn symlink_target_outside_watched_paths() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
// Set up the symlink target.
|
|
let link_target = context.join_root_path("bar");
|
|
std::fs::create_dir_all(link_target.as_std_path())
|
|
.context("Failed to create link target directory")?;
|
|
let baz_original = link_target.join("baz.py");
|
|
std::fs::write(baz_original.as_std_path(), "def baz(): ...")
|
|
.context("Failed to write link target file")?;
|
|
|
|
// Create a symlink inside the project
|
|
let bar = context.join_project_path("bar");
|
|
std::os::unix::fs::symlink(link_target.as_std_path(), bar.as_std_path())
|
|
.context("Failed to create symlink to bar package")?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let baz = resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("bar.baz").unwrap(),
|
|
)
|
|
.expect("Expected bar.baz to exist in site-packages.");
|
|
let baz_project = case.project_path("bar/baz.py");
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), baz.file()).as_str(),
|
|
"def baz(): ..."
|
|
);
|
|
assert_eq!(
|
|
baz.file().path(case.db()).as_system_path(),
|
|
Some(&*baz_project)
|
|
);
|
|
|
|
let baz_original = case.root_path().join("bar/baz.py");
|
|
|
|
// Write to the symlink target.
|
|
update_file(baz_original, "def baz(): print('Version 2')")
|
|
.context("Failed to update bar/baz.py")?;
|
|
|
|
let changes = case.take_watch_changes(event_for_file("baz.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), baz.file()).as_str(),
|
|
"def baz(): print('Version 2')"
|
|
);
|
|
|
|
// Write to the symlink source.
|
|
update_file(baz_project, "def baz(): print('Version 3')")
|
|
.context("Failed to update bar/baz.py")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("baz.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), baz.file()).as_str(),
|
|
"def baz(): print('Version 3')"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Project contains a symlink to another directory inside the project.
|
|
/// Changes to files in the symlinked directory should be reflected
|
|
/// to all files.
|
|
///
|
|
/// Setup:
|
|
/// ```text
|
|
/// - project
|
|
/// | - bar -> /project/patched/bar
|
|
/// |
|
|
/// | - patched
|
|
/// | |-- bar
|
|
/// | | |- baz.py
|
|
/// |
|
|
/// |-- foo.py
|
|
/// ```
|
|
#[test]
|
|
fn symlink_inside_project() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
// Set up the symlink target.
|
|
let link_target = context.join_project_path("patched/bar");
|
|
std::fs::create_dir_all(link_target.as_std_path())
|
|
.context("Failed to create link target directory")?;
|
|
let baz_original = link_target.join("baz.py");
|
|
std::fs::write(baz_original.as_std_path(), "def baz(): ...")
|
|
.context("Failed to write link target file")?;
|
|
|
|
// Create a symlink inside site-packages
|
|
let bar_in_project = context.join_project_path("bar");
|
|
std::os::unix::fs::symlink(link_target.as_std_path(), bar_in_project.as_std_path())
|
|
.context("Failed to create symlink to bar package")?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let baz = resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("bar.baz").unwrap(),
|
|
)
|
|
.expect("Expected bar.baz to exist in site-packages.");
|
|
let bar_baz = case.project_path("bar/baz.py");
|
|
|
|
let patched_bar_baz = case.project_path("patched/bar/baz.py");
|
|
let patched_bar_baz_file = case.system_file(&patched_bar_baz).unwrap();
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), patched_bar_baz_file).as_str(),
|
|
"def baz(): ..."
|
|
);
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), baz.file()).as_str(),
|
|
"def baz(): ..."
|
|
);
|
|
assert_eq!(baz.file().path(case.db()).as_system_path(), Some(&*bar_baz));
|
|
|
|
case.assert_indexed_project_files([patched_bar_baz_file]);
|
|
|
|
// Write to the symlink target.
|
|
update_file(&patched_bar_baz, "def baz(): print('Version 2')")
|
|
.context("Failed to update bar/baz.py")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("baz.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
// The file watcher is guaranteed to emit one event for the changed file, but it isn't specified
|
|
// if the event is emitted for the "original" or linked path because both paths are watched.
|
|
// The best we can assert here is that one of the files should have been updated.
|
|
//
|
|
// In a perfect world, the file watcher would emit two events, one for the original file and
|
|
// one for the symlink. I tried parcel/watcher, node's `fs.watch` and `chokidar` and
|
|
// only `chokidar seems to support it (used by Pyright).
|
|
//
|
|
// I further tested how good editor support is for symlinked files and it is not good ;)
|
|
// * VS Code doesn't update the file content if a file gets changed through a symlink
|
|
// * PyCharm doesn't update diagnostics if a symlinked module is changed (same as red knot).
|
|
//
|
|
// That's why I think it's fine to not support this case for now.
|
|
|
|
let patched_baz_text = source_text(case.db(), patched_bar_baz_file);
|
|
let did_update_patched_baz = patched_baz_text.as_str() == "def baz(): print('Version 2')";
|
|
|
|
let bar_baz_text = source_text(case.db(), baz.file());
|
|
let did_update_bar_baz = bar_baz_text.as_str() == "def baz(): print('Version 2')";
|
|
|
|
assert!(
|
|
did_update_patched_baz || did_update_bar_baz,
|
|
"Expected one of the files to be updated but neither file was updated.\nOriginal: {patched_baz_text}\nSymlinked: {bar_baz_text}",
|
|
patched_baz_text = patched_baz_text.as_str(),
|
|
bar_baz_text = bar_baz_text.as_str()
|
|
);
|
|
|
|
case.assert_indexed_project_files([patched_bar_baz_file]);
|
|
Ok(())
|
|
}
|
|
|
|
/// A module search path is a symlink.
|
|
///
|
|
/// Setup:
|
|
/// ```text
|
|
/// - site-packages
|
|
/// | - bar/baz.py
|
|
///
|
|
/// - project
|
|
/// |-- .venv/lib/python3.12/site-packages -> /site-packages
|
|
/// |
|
|
/// |-- foo.py
|
|
/// ```
|
|
#[test]
|
|
fn symlinked_module_search_path() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
// Set up the symlink target.
|
|
let site_packages = context.join_root_path("site-packages");
|
|
let bar = site_packages.join("bar");
|
|
std::fs::create_dir_all(bar.as_std_path()).context("Failed to create bar directory")?;
|
|
let baz_original = bar.join("baz.py");
|
|
std::fs::write(baz_original.as_std_path(), "def baz(): ...")
|
|
.context("Failed to write baz.py")?;
|
|
|
|
// Symlink the site packages in the venv to the global site packages
|
|
let venv_site_packages =
|
|
context.join_project_path(".venv/lib/python3.12/site-packages");
|
|
std::fs::create_dir_all(venv_site_packages.parent().unwrap())
|
|
.context("Failed to create .venv directory")?;
|
|
std::os::unix::fs::symlink(
|
|
site_packages.as_std_path(),
|
|
venv_site_packages.as_std_path(),
|
|
)
|
|
.context("Failed to create symlink to site-packages")?;
|
|
|
|
context.set_options(Options {
|
|
environment: Some(EnvironmentOptions {
|
|
extra_paths: Some(vec![RelativePathBuf::cli(
|
|
".venv/lib/python3.12/site-packages",
|
|
)]),
|
|
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
|
|
..EnvironmentOptions::default()
|
|
}),
|
|
..Options::default()
|
|
});
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let baz = resolve_module(
|
|
case.db().upcast(),
|
|
&ModuleName::new_static("bar.baz").unwrap(),
|
|
)
|
|
.expect("Expected bar.baz to exist in site-packages.");
|
|
let baz_site_packages_path =
|
|
case.project_path(".venv/lib/python3.12/site-packages/bar/baz.py");
|
|
let baz_site_packages = case.system_file(&baz_site_packages_path).unwrap();
|
|
let baz_original = case.root_path().join("site-packages/bar/baz.py");
|
|
let baz_original_file = case.system_file(&baz_original).unwrap();
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), baz_original_file).as_str(),
|
|
"def baz(): ..."
|
|
);
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), baz_site_packages).as_str(),
|
|
"def baz(): ..."
|
|
);
|
|
assert_eq!(
|
|
baz.file().path(case.db()).as_system_path(),
|
|
Some(&*baz_original)
|
|
);
|
|
|
|
case.assert_indexed_project_files([]);
|
|
|
|
// Write to the symlink target.
|
|
update_file(&baz_original, "def baz(): print('Version 2')")
|
|
.context("Failed to update bar/baz.py")?;
|
|
|
|
let changes = case.stop_watch(event_for_file("baz.py"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
assert_eq!(
|
|
source_text(case.db(), baz_original_file).as_str(),
|
|
"def baz(): print('Version 2')"
|
|
);
|
|
|
|
// It would be nice if this is supported but the underlying file system watchers
|
|
// only emit a single event. For reference
|
|
// * VS Code doesn't update the file content if a file gets changed through a symlink
|
|
// * PyCharm doesn't update diagnostics if a symlinked module is changed (same as red knot).
|
|
// We could add support for it by keeping a reverse map from `real_path` to symlinked path but
|
|
// it doesn't seem worth doing considering that as prominent tools like PyCharm don't support it.
|
|
// Pyright does support it, thanks to chokidar.
|
|
assert_ne!(
|
|
source_text(case.db(), baz_site_packages).as_str(),
|
|
"def baz(): print('Version 2')"
|
|
);
|
|
|
|
case.assert_indexed_project_files([]);
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nested_projects_delete_root() -> anyhow::Result<()> {
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
std::fs::write(
|
|
context.join_project_path("pyproject.toml").as_std_path(),
|
|
r#"
|
|
[project]
|
|
name = "inner"
|
|
|
|
[tool.knot]
|
|
"#,
|
|
)?;
|
|
|
|
std::fs::write(
|
|
context.join_root_path("pyproject.toml").as_std_path(),
|
|
r#"
|
|
[project]
|
|
name = "outer"
|
|
|
|
[tool.knot]
|
|
"#,
|
|
)?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
assert_eq!(case.db().project().root(case.db()), &*case.project_path(""));
|
|
|
|
std::fs::remove_file(case.project_path("pyproject.toml").as_std_path())?;
|
|
|
|
let changes = case.stop_watch(ChangeEvent::is_deleted);
|
|
|
|
case.apply_changes(changes);
|
|
|
|
// It should now pick up the outer project.
|
|
assert_eq!(case.db().project().root(case.db()), case.root_path());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn changes_to_user_configuration() -> anyhow::Result<()> {
|
|
let mut _config_dir_override: Option<UserConfigDirectoryOverrideGuard> = None;
|
|
|
|
let mut case = setup(|context: &mut SetupContext| {
|
|
std::fs::write(
|
|
context.join_project_path("pyproject.toml").as_std_path(),
|
|
r#"
|
|
[project]
|
|
name = "test"
|
|
"#,
|
|
)?;
|
|
|
|
std::fs::write(
|
|
context.join_project_path("foo.py").as_std_path(),
|
|
"a = 10 / 0",
|
|
)?;
|
|
|
|
let config_directory = context.join_root_path("home/.config");
|
|
std::fs::create_dir_all(config_directory.join("knot").as_std_path())?;
|
|
std::fs::write(
|
|
config_directory.join("knot/knot.toml").as_std_path(),
|
|
r#"
|
|
[rules]
|
|
division-by-zero = "ignore"
|
|
"#,
|
|
)?;
|
|
|
|
_config_dir_override = Some(
|
|
context
|
|
.system()
|
|
.with_user_config_directory(Some(config_directory)),
|
|
);
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
let foo = case
|
|
.system_file(case.project_path("foo.py"))
|
|
.expect("foo.py to exist");
|
|
let diagnostics = case
|
|
.db()
|
|
.check_file(foo)
|
|
.context("Failed to check project.")?;
|
|
|
|
assert!(
|
|
diagnostics.is_empty(),
|
|
"Expected no diagnostics but got: {diagnostics:#?}"
|
|
);
|
|
|
|
// Enable division-by-zero in the user configuration with warning severity
|
|
update_file(
|
|
case.root_path().join("home/.config/knot/knot.toml"),
|
|
r#"
|
|
[rules]
|
|
division-by-zero = "warn"
|
|
"#,
|
|
)?;
|
|
|
|
let changes = case.stop_watch(event_for_file("knot.toml"));
|
|
|
|
case.apply_changes(changes);
|
|
|
|
let diagnostics = case
|
|
.db()
|
|
.check_file(foo)
|
|
.context("Failed to check project.")?;
|
|
|
|
assert!(
|
|
diagnostics.len() == 1,
|
|
"Expected exactly one diagnostic but got: {diagnostics:#?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Tests that renaming a file from `lib.py` to `Lib.py` is correctly reflected.
|
|
///
|
|
/// This test currently fails on case-insensitive systems because `Files` is case-sensitive
|
|
/// but the `System::metadata` call isn't. This means that
|
|
/// Red Knot considers both `Lib.py` and `lib.py` to exist when only `lib.py` does
|
|
///
|
|
/// The incoming change events then are no-ops because they don't change either file's
|
|
/// status nor does it update their last modified time (renaming a file doesn't bump it's
|
|
/// last modified timestamp).
|
|
///
|
|
/// Fixing this requires to either make `Files` case-insensitive and store the
|
|
/// real-case path (if it differs) on `File` or make `Files` use a
|
|
/// case-sensitive `System::metadata` call. This does open the question if all
|
|
/// `System` calls should be case sensitive. This would be the most consistent
|
|
/// but might be hard to pull off.
|
|
///
|
|
/// What the right solution is also depends on if Red Knot itself should be case
|
|
/// sensitive or not. E.g. should `include="src"` be case sensitive on all systems
|
|
/// or only on case-sensitive systems?
|
|
///
|
|
/// Lastly, whatever solution we pick must also work well with VS Code which,
|
|
/// unfortunately ,doesn't propagate casing-only renames.
|
|
/// <https://github.com/rust-lang/rust-analyzer/issues/9581>
|
|
#[ignore]
|
|
#[test]
|
|
fn rename_files_casing_only() -> anyhow::Result<()> {
|
|
let mut case = setup([("lib.py", "class Foo: ...")])?;
|
|
|
|
assert!(
|
|
resolve_module(case.db(), &ModuleName::new("lib").unwrap()).is_some(),
|
|
"Expected `lib` module to exist."
|
|
);
|
|
assert_eq!(
|
|
resolve_module(case.db(), &ModuleName::new("Lib").unwrap()),
|
|
None,
|
|
"Expected `Lib` module not to exist"
|
|
);
|
|
|
|
// Now rename `lib.py` to `Lib.py`
|
|
if case.db().system().case_sensitivity().is_case_sensitive() {
|
|
std::fs::rename(
|
|
case.project_path("lib.py").as_std_path(),
|
|
case.project_path("Lib.py").as_std_path(),
|
|
)
|
|
.context("Failed to rename `lib.py` to `Lib.py`")?;
|
|
} else {
|
|
// On case-insensitive file systems, renaming a file to a different casing is a no-op.
|
|
// Rename to a different name first
|
|
std::fs::rename(
|
|
case.project_path("lib.py").as_std_path(),
|
|
case.project_path("temp.py").as_std_path(),
|
|
)
|
|
.context("Failed to rename `lib.py` to `temp.py`")?;
|
|
|
|
std::fs::rename(
|
|
case.project_path("temp.py").as_std_path(),
|
|
case.project_path("Lib.py").as_std_path(),
|
|
)
|
|
.context("Failed to rename `temp.py` to `Lib.py`")?;
|
|
}
|
|
|
|
let changes = case.stop_watch(event_for_file("Lib.py"));
|
|
case.apply_changes(changes);
|
|
|
|
// Resolving `lib` should now fail but `Lib` should now succeed
|
|
assert_eq!(
|
|
resolve_module(case.db(), &ModuleName::new("lib").unwrap()),
|
|
None,
|
|
"Expected `lib` module to no longer exist."
|
|
);
|
|
|
|
assert!(
|
|
resolve_module(case.db(), &ModuleName::new("Lib").unwrap()).is_some(),
|
|
"Expected `Lib` module to exist"
|
|
);
|
|
|
|
Ok(())
|
|
}
|