Workspace discovery (#14308)

This commit is contained in:
Micha Reiser 2024-11-15 19:20:15 +01:00 committed by GitHub
parent 2b58705cc1
commit 81e5830585
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1972 additions and 181 deletions

View file

@ -17,4 +17,7 @@ indent_size = 4
trim_trailing_whitespace = false trim_trailing_whitespace = false
[*.md] [*.md]
max_line_length = 100 max_line_length = 100
[*.toml]
indent_size = 4

34
Cargo.lock generated
View file

@ -170,6 +170,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.0" version = "0.22.0"
@ -829,6 +835,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]] [[package]]
name = "dyn-clone" name = "dyn-clone"
version = "1.0.17" version = "1.0.17"
@ -1358,6 +1370,7 @@ dependencies = [
"pest", "pest",
"pest_derive", "pest_derive",
"regex", "regex",
"ron",
"serde", "serde",
"similar", "similar",
"walkdir", "walkdir",
@ -2269,6 +2282,7 @@ dependencies = [
"ruff_text_size", "ruff_text_size",
"rustc-hash 2.0.0", "rustc-hash 2.0.0",
"salsa", "salsa",
"serde",
"smallvec", "smallvec",
"static_assertions", "static_assertions",
"tempfile", "tempfile",
@ -2354,7 +2368,9 @@ dependencies = [
"anyhow", "anyhow",
"crossbeam", "crossbeam",
"glob", "glob",
"insta",
"notify", "notify",
"pep440_rs 0.7.2",
"rayon", "rayon",
"red_knot_python_semantic", "red_knot_python_semantic",
"red_knot_vendored", "red_knot_vendored",
@ -2364,6 +2380,9 @@ dependencies = [
"ruff_text_size", "ruff_text_size",
"rustc-hash 2.0.0", "rustc-hash 2.0.0",
"salsa", "salsa",
"serde",
"thiserror 2.0.3",
"toml",
"tracing", "tracing",
] ]
@ -2455,6 +2474,17 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "ron"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a"
dependencies = [
"base64 0.13.1",
"bitflags 1.3.2",
"serde",
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.7.4" version = "0.7.4"
@ -2556,7 +2586,9 @@ dependencies = [
"camino", "camino",
"countme", "countme",
"dashmap 6.1.0", "dashmap 6.1.0",
"dunce",
"filetime", "filetime",
"glob",
"ignore", "ignore",
"insta", "insta",
"matchit", "matchit",
@ -3906,7 +3938,7 @@ version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a"
dependencies = [ dependencies = [
"base64", "base64 0.22.0",
"flate2", "flate2",
"log", "log",
"once_cell", "once_cell",

View file

@ -66,6 +66,7 @@ criterion = { version = "0.5.1", default-features = false }
crossbeam = { version = "0.8.4" } crossbeam = { version = "0.8.4" }
dashmap = { version = "6.0.1" } dashmap = { version = "6.0.1" }
dir-test = { version = "0.3.0" } dir-test = { version = "0.3.0" }
dunce = { version = "1.0.5" }
drop_bomb = { version = "0.1.5" } drop_bomb = { version = "0.1.5" }
env_logger = { version = "0.11.0" } env_logger = { version = "0.11.0" }
etcetera = { version = "0.8.0" } etcetera = { version = "0.8.0" }
@ -81,7 +82,7 @@ hashbrown = { version = "0.15.0", default-features = false, features = [
ignore = { version = "0.4.22" } ignore = { version = "0.4.22" }
imara-diff = { version = "0.1.5" } imara-diff = { version = "0.1.5" }
imperative = { version = "1.0.4" } imperative = { version = "1.0.4" }
indexmap = {version = "2.6.0" } indexmap = { version = "2.6.0" }
indicatif = { version = "0.17.8" } indicatif = { version = "0.17.8" }
indoc = { version = "2.0.4" } indoc = { version = "2.0.4" }
insta = { version = "1.35.1" } insta = { version = "1.35.1" }

View file

@ -1,6 +1,11 @@
[files] [files]
# https://github.com/crate-ci/typos/issues/868 # https://github.com/crate-ci/typos/issues/868
extend-exclude = ["crates/red_knot_vendored/vendor/**/*", "**/resources/**/*", "**/snapshots/**/*"] extend-exclude = [
"crates/red_knot_vendored/vendor/**/*",
"**/resources/**/*",
"**/snapshots/**/*",
"crates/red_knot_workspace/src/workspace/pyproject/package_name.rs"
]
[default.extend-words] [default.extend-words]
"arange" = "arange" # e.g. `numpy.arange` "arange" = "arange" # e.g. `numpy.arange`

View file

@ -34,6 +34,7 @@ tracing-tree = { workspace = true }
[dev-dependencies] [dev-dependencies]
filetime = { workspace = true } filetime = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
ruff_db = { workspace = true, features = ["testing"] }
[lints] [lints]
workspace = true workspace = true

View file

@ -183,10 +183,10 @@ fn run() -> anyhow::Result<ExitStatus> {
let system = OsSystem::new(cwd.clone()); let system = OsSystem::new(cwd.clone());
let cli_configuration = args.to_configuration(&cwd); let cli_configuration = args.to_configuration(&cwd);
let workspace_metadata = WorkspaceMetadata::from_path( let workspace_metadata = WorkspaceMetadata::discover(
system.current_directory(), system.current_directory(),
&system, &system,
Some(cli_configuration.clone()), Some(&cli_configuration),
)?; )?;
// TODO: Use the `program_settings` to compute the key for the database's persistent // TODO: Use the `program_settings` to compute the key for the database's persistent

View file

@ -6,7 +6,7 @@ use std::time::Duration;
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use red_knot_python_semantic::{resolve_module, ModuleName, Program, PythonVersion, SitePackages}; use red_knot_python_semantic::{resolve_module, ModuleName, Program, PythonVersion, SitePackages};
use red_knot_workspace::db::RootDatabase; use red_knot_workspace::db::{Db, RootDatabase};
use red_knot_workspace::watch; use red_knot_workspace::watch;
use red_knot_workspace::watch::{directory_watcher, WorkspaceWatcher}; use red_knot_workspace::watch::{directory_watcher, WorkspaceWatcher};
use red_knot_workspace::workspace::settings::{Configuration, SearchPathConfiguration}; use red_knot_workspace::workspace::settings::{Configuration, SearchPathConfiguration};
@ -14,6 +14,7 @@ use red_knot_workspace::workspace::WorkspaceMetadata;
use ruff_db::files::{system_path_to_file, File, FileError}; use ruff_db::files::{system_path_to_file, File, FileError};
use ruff_db::source::source_text; use ruff_db::source::source_text;
use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf}; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf};
use ruff_db::testing::setup_logging;
use ruff_db::Upcast; use ruff_db::Upcast;
struct TestCase { struct TestCase {
@ -69,7 +70,6 @@ impl TestCase {
Some(all_events) Some(all_events)
} }
#[cfg(unix)]
fn take_watch_changes(&self) -> Vec<watch::ChangeEvent> { fn take_watch_changes(&self) -> Vec<watch::ChangeEvent> {
self.try_take_watch_changes(Duration::from_secs(10)) self.try_take_watch_changes(Duration::from_secs(10))
.expect("Expected watch changes but observed none") .expect("Expected watch changes but observed none")
@ -110,8 +110,8 @@ impl TestCase {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let program = Program::get(self.db()); let program = Program::get(self.db());
self.configuration.search_paths = configuration.clone(); let new_settings = configuration.to_settings(self.db.workspace().root(&self.db));
let new_settings = configuration.into_settings(self.db.workspace().root(&self.db)); self.configuration.search_paths = configuration;
program.update_search_paths(&mut self.db, &new_settings)?; program.update_search_paths(&mut self.db, &new_settings)?;
@ -204,7 +204,9 @@ where
.as_utf8_path() .as_utf8_path()
.canonicalize_utf8() .canonicalize_utf8()
.with_context(|| "Failed to canonicalize root path.")?, .with_context(|| "Failed to canonicalize root path.")?,
); )
.simplified()
.to_path_buf();
let workspace_path = root_path.join("workspace"); let workspace_path = root_path.join("workspace");
@ -241,8 +243,7 @@ where
search_paths, search_paths,
}; };
let workspace = let workspace = WorkspaceMetadata::discover(&workspace_path, &system, Some(&configuration))?;
WorkspaceMetadata::from_path(&workspace_path, &system, Some(configuration.clone()))?;
let db = RootDatabase::new(workspace, system)?; let db = RootDatabase::new(workspace, system)?;
@ -1311,3 +1312,138 @@ mod unix {
Ok(()) Ok(())
} }
} }
#[test]
fn nested_packages_delete_root() -> anyhow::Result<()> {
let mut case = setup(|root: &SystemPath, workspace_root: &SystemPath| {
std::fs::write(
workspace_root.join("pyproject.toml").as_std_path(),
r#"
[project]
name = "inner"
"#,
)?;
std::fs::write(
root.join("pyproject.toml").as_std_path(),
r#"
[project]
name = "outer"
"#,
)?;
Ok(())
})?;
assert_eq!(
case.db().workspace().root(case.db()),
&*case.workspace_path("")
);
std::fs::remove_file(case.workspace_path("pyproject.toml").as_std_path())?;
let changes = case.stop_watch();
case.apply_changes(changes);
// It should now pick up the outer workspace.
assert_eq!(case.db().workspace().root(case.db()), case.root_path());
Ok(())
}
#[test]
fn added_package() -> anyhow::Result<()> {
let _ = setup_logging();
let mut case = setup([
(
"pyproject.toml",
r#"
[project]
name = "inner"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
(
"packages/a/pyproject.toml",
r#"
[project]
name = "a"
"#,
),
])?;
assert_eq!(case.db().workspace().packages(case.db()).len(), 2);
std::fs::create_dir(case.workspace_path("packages/b").as_std_path())
.context("failed to create folder for package 'b'")?;
// It seems that the file watcher won't pick up on file changes shortly after the folder
// was created... I suspect this is because most file watchers don't support recursive
// file watching. Instead, file-watching libraries manually implement recursive file watching
// by setting a watcher for each directory. But doing this obviously "lags" behind.
case.take_watch_changes();
std::fs::write(
case.workspace_path("packages/b/pyproject.toml")
.as_std_path(),
r#"
[project]
name = "b"
"#,
)
.context("failed to write pyproject.toml for package b")?;
let changes = case.stop_watch();
case.apply_changes(changes);
assert_eq!(case.db().workspace().packages(case.db()).len(), 3);
Ok(())
}
#[test]
fn removed_package() -> anyhow::Result<()> {
let mut case = setup([
(
"pyproject.toml",
r#"
[project]
name = "inner"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
(
"packages/a/pyproject.toml",
r#"
[project]
name = "a"
"#,
),
(
"packages/b/pyproject.toml",
r#"
[project]
name = "b"
"#,
),
])?;
assert_eq!(case.db().workspace().packages(case.db()).len(), 3);
std::fs::remove_dir_all(case.workspace_path("packages/b").as_std_path())
.context("failed to remove package 'b'")?;
let changes = case.stop_watch();
case.apply_changes(changes);
assert_eq!(case.db().workspace().packages(case.db()).len(), 2);
Ok(())
}

View file

@ -33,6 +33,7 @@ thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
hashbrown = { workspace = true } hashbrown = { workspace = true }
serde = { workspace = true, optional = true }
smallvec = { workspace = true } smallvec = { workspace = true }
static_assertions = { workspace = true } static_assertions = { workspace = true }
test-case = { workspace = true } test-case = { workspace = true }

View file

@ -54,6 +54,7 @@ impl Program {
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ProgramSettings { pub struct ProgramSettings {
pub target_version: PythonVersion, pub target_version: PythonVersion,
pub search_paths: SearchPathSettings, pub search_paths: SearchPathSettings,
@ -61,6 +62,7 @@ pub struct ProgramSettings {
/// Configures the search paths for module resolution. /// Configures the search paths for module resolution.
#[derive(Eq, PartialEq, Debug, Clone)] #[derive(Eq, PartialEq, Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SearchPathSettings { pub struct SearchPathSettings {
/// List of user-provided paths that should take first priority in the module resolution. /// List of user-provided paths that should take first priority in the module resolution.
/// Examples in other type checkers are mypy's MYPYPATH environment variable, /// Examples in other type checkers are mypy's MYPYPATH environment variable,
@ -91,6 +93,7 @@ impl SearchPathSettings {
} }
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum SitePackages { pub enum SitePackages {
Derived { Derived {
venv_path: SystemPathBuf, venv_path: SystemPathBuf,

View file

@ -5,6 +5,7 @@ use std::fmt;
/// Unlike the `TargetVersion` enums in the CLI crates, /// Unlike the `TargetVersion` enums in the CLI crates,
/// this does not necessarily represent a Python version that we actually support. /// this does not necessarily represent a Python version that we actually support.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct PythonVersion { pub struct PythonVersion {
pub major: u8, pub major: u8,
pub minor: u8, pub minor: u8,

View file

@ -732,7 +732,20 @@ mod tests {
let system = TestSystem::default(); let system = TestSystem::default();
assert!(matches!( assert!(matches!(
VirtualEnvironment::new("/.venv", &system), VirtualEnvironment::new("/.venv", &system),
Err(SitePackagesDiscoveryError::VenvDirIsNotADirectory(_)) Err(SitePackagesDiscoveryError::VenvDirCanonicalizationError(..))
));
}
#[test]
fn reject_venv_that_is_not_a_directory() {
let system = TestSystem::default();
system
.memory_file_system()
.write_file("/.venv", "")
.unwrap();
assert!(matches!(
VirtualEnvironment::new("/.venv", &system),
Err(SitePackagesDiscoveryError::VenvDirIsNotADirectory(..))
)); ));
} }

View file

@ -68,7 +68,7 @@ impl Session {
let system = LSPSystem::new(index.clone()); let system = LSPSystem::new(index.clone());
// TODO(dhruvmanila): Get the values from the client settings // TODO(dhruvmanila): Get the values from the client settings
let metadata = WorkspaceMetadata::from_path(system_path, &system, None)?; let metadata = WorkspaceMetadata::discover(system_path, &system, None)?;
// TODO(micha): Handle the case where the program settings are incorrect more gracefully. // TODO(micha): Handle the case where the program settings are incorrect more gracefully.
workspaces.insert(path, RootDatabase::new(metadata, system)?); workspaces.insert(path, RootDatabase::new(metadata, system)?);
} }

View file

@ -7,8 +7,8 @@ use lsp_types::Url;
use ruff_db::file_revision::FileRevision; use ruff_db::file_revision::FileRevision;
use ruff_db::system::walk_directory::WalkDirectoryBuilder; use ruff_db::system::walk_directory::WalkDirectoryBuilder;
use ruff_db::system::{ use ruff_db::system::{
DirectoryEntry, FileType, Metadata, OsSystem, Result, System, SystemPath, SystemPathBuf, DirectoryEntry, FileType, GlobError, Metadata, OsSystem, PatternError, Result, System,
SystemVirtualPath, SystemVirtualPathBuf, SystemPath, SystemPathBuf, SystemVirtualPath, SystemVirtualPathBuf,
}; };
use ruff_notebook::{Notebook, NotebookError}; use ruff_notebook::{Notebook, NotebookError};
@ -198,6 +198,16 @@ impl System for LSPSystem {
self.os_system.walk_directory(path) self.os_system.walk_directory(path)
} }
fn glob(
&self,
pattern: &str,
) -> std::result::Result<
Box<dyn Iterator<Item = std::result::Result<SystemPathBuf, GlobError>>>,
PatternError,
> {
self.os_system.glob(pattern)
}
fn as_any(&self) -> &dyn Any { fn as_any(&self) -> &dyn Any {
self self
} }

View file

@ -3,15 +3,15 @@ use std::any::Any;
use js_sys::Error; use js_sys::Error;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use red_knot_workspace::db::RootDatabase; use red_knot_workspace::db::{Db, RootDatabase};
use red_knot_workspace::workspace::settings::Configuration; use red_knot_workspace::workspace::settings::Configuration;
use red_knot_workspace::workspace::WorkspaceMetadata; use red_knot_workspace::workspace::WorkspaceMetadata;
use ruff_db::diagnostic::Diagnostic; use ruff_db::diagnostic::Diagnostic;
use ruff_db::files::{system_path_to_file, File}; use ruff_db::files::{system_path_to_file, File};
use ruff_db::system::walk_directory::WalkDirectoryBuilder; use ruff_db::system::walk_directory::WalkDirectoryBuilder;
use ruff_db::system::{ use ruff_db::system::{
DirectoryEntry, MemoryFileSystem, Metadata, System, SystemPath, SystemPathBuf, DirectoryEntry, GlobError, MemoryFileSystem, Metadata, PatternError, System, SystemPath,
SystemVirtualPath, SystemPathBuf, SystemVirtualPath,
}; };
use ruff_notebook::Notebook; use ruff_notebook::Notebook;
@ -42,10 +42,10 @@ impl Workspace {
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor)]
pub fn new(root: &str, settings: &Settings) -> Result<Workspace, Error> { pub fn new(root: &str, settings: &Settings) -> Result<Workspace, Error> {
let system = WasmSystem::new(SystemPath::new(root)); let system = WasmSystem::new(SystemPath::new(root));
let workspace = WorkspaceMetadata::from_path( let workspace = WorkspaceMetadata::discover(
SystemPath::new(root), SystemPath::new(root),
&system, &system,
Some(Configuration { Some(&Configuration {
target_version: Some(settings.target_version.into()), target_version: Some(settings.target_version.into()),
..Configuration::default() ..Configuration::default()
}), }),
@ -226,7 +226,7 @@ impl System for WasmSystem {
} }
fn canonicalize_path(&self, path: &SystemPath) -> ruff_db::system::Result<SystemPathBuf> { fn canonicalize_path(&self, path: &SystemPath) -> ruff_db::system::Result<SystemPathBuf> {
Ok(self.fs.canonicalize(path)) self.fs.canonicalize(path)
} }
fn read_to_string(&self, path: &SystemPath) -> ruff_db::system::Result<String> { fn read_to_string(&self, path: &SystemPath) -> ruff_db::system::Result<String> {
@ -272,6 +272,13 @@ impl System for WasmSystem {
self.fs.walk_directory(path) self.fs.walk_directory(path)
} }
fn glob(
&self,
pattern: &str,
) -> Result<Box<dyn Iterator<Item = Result<SystemPathBuf, GlobError>>>, PatternError> {
Ok(Box::new(self.fs.glob(pattern)?))
}
fn as_any(&self) -> &dyn Any { fn as_any(&self) -> &dyn Any {
self self
} }

View file

@ -15,22 +15,29 @@ license.workspace = true
red_knot_python_semantic = { workspace = true } red_knot_python_semantic = { workspace = true }
ruff_cache = { workspace = true } ruff_cache = { workspace = true }
ruff_db = { workspace = true, features = ["os", "cache"] } ruff_db = { workspace = true, features = ["os", "cache", "serde"] }
ruff_python_ast = { workspace = true } ruff_python_ast = { workspace = true, features = ["serde"] }
ruff_text_size = { workspace = true } ruff_text_size = { workspace = true }
red_knot_vendored = { workspace = true } red_knot_vendored = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
crossbeam = { workspace = true } crossbeam = { workspace = true }
glob = { workspace = true }
notify = { workspace = true } notify = { workspace = true }
pep440_rs = { workspace = true }
rayon = { workspace = true } rayon = { workspace = true }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
salsa = { workspace = true } salsa = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[dev-dependencies] [dev-dependencies]
red_knot_python_semantic = { workspace = true, features = ["serde"] }
ruff_db = { workspace = true, features = ["testing"] } ruff_db = { workspace = true, features = ["testing"] }
glob = { workspace = true } glob = { workspace = true }
insta = { workspace = true, features = ["redactions", "ron"] }
[features] [features]
default = ["zstd"] default = ["zstd"]

View file

@ -15,7 +15,9 @@ use ruff_db::{Db as SourceDb, Upcast};
mod changes; mod changes;
#[salsa::db] #[salsa::db]
pub trait Db: SemanticDb + Upcast<dyn SemanticDb> {} pub trait Db: SemanticDb + Upcast<dyn SemanticDb> {
fn workspace(&self) -> Workspace;
}
#[salsa::db] #[salsa::db]
pub struct RootDatabase { pub struct RootDatabase {
@ -45,11 +47,6 @@ impl RootDatabase {
Ok(db) Ok(db)
} }
pub fn workspace(&self) -> Workspace {
// SAFETY: The workspace is always initialized in `new`.
self.workspace.unwrap()
}
/// Checks all open files in the workspace and its dependencies. /// Checks all open files in the workspace and its dependencies.
pub fn check(&self) -> Result<Vec<Box<dyn Diagnostic>>, Cancelled> { pub fn check(&self) -> Result<Vec<Box<dyn Diagnostic>>, Cancelled> {
self.with_db(|db| db.workspace().check(db)) self.with_db(|db| db.workspace().check(db))
@ -153,7 +150,11 @@ impl salsa::Database for RootDatabase {
} }
#[salsa::db] #[salsa::db]
impl Db for RootDatabase {} impl Db for RootDatabase {
fn workspace(&self) -> Workspace {
self.workspace.unwrap()
}
}
#[cfg(test)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
@ -168,6 +169,7 @@ pub(crate) mod tests {
use ruff_db::{Db as SourceDb, Upcast}; use ruff_db::{Db as SourceDb, Upcast};
use crate::db::Db; use crate::db::Db;
use crate::workspace::{Workspace, WorkspaceMetadata};
#[salsa::db] #[salsa::db]
pub(crate) struct TestDb { pub(crate) struct TestDb {
@ -176,17 +178,23 @@ pub(crate) mod tests {
files: Files, files: Files,
system: TestSystem, system: TestSystem,
vendored: VendoredFileSystem, vendored: VendoredFileSystem,
workspace: Option<Workspace>,
} }
impl TestDb { impl TestDb {
pub(crate) fn new() -> Self { pub(crate) fn new(workspace: WorkspaceMetadata) -> Self {
Self { let mut db = Self {
storage: salsa::Storage::default(), storage: salsa::Storage::default(),
system: TestSystem::default(), system: TestSystem::default(),
vendored: red_knot_vendored::file_system().clone(), vendored: red_knot_vendored::file_system().clone(),
files: Files::default(), files: Files::default(),
events: Arc::default(), events: Arc::default(),
} workspace: None,
};
let workspace = Workspace::from_metadata(&db, workspace);
db.workspace = Some(workspace);
db
} }
} }
@ -254,7 +262,11 @@ pub(crate) mod tests {
} }
#[salsa::db] #[salsa::db]
impl Db for TestDb {} impl Db for TestDb {
fn workspace(&self) -> Workspace {
self.workspace.unwrap()
}
}
#[salsa::db] #[salsa::db]
impl salsa::Database for TestDb { impl salsa::Database for TestDb {

View file

@ -1,16 +1,15 @@
use crate::db::{Db, RootDatabase};
use crate::watch;
use crate::watch::{ChangeEvent, CreatedKind, DeletedKind};
use crate::workspace::settings::Configuration;
use crate::workspace::{Workspace, WorkspaceMetadata};
use red_knot_python_semantic::Program; use red_knot_python_semantic::Program;
use ruff_db::files::{system_path_to_file, File, Files}; use ruff_db::files::{system_path_to_file, File, Files};
use ruff_db::system::walk_directory::WalkState; use ruff_db::system::walk_directory::WalkState;
use ruff_db::system::SystemPath; use ruff_db::system::SystemPath;
use ruff_db::Db; use ruff_db::Db as _;
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use crate::db::RootDatabase;
use crate::watch;
use crate::watch::{CreatedKind, DeletedKind};
use crate::workspace::settings::Configuration;
use crate::workspace::WorkspaceMetadata;
impl RootDatabase { impl RootDatabase {
#[tracing::instrument(level = "debug", skip(self, changes, base_configuration))] #[tracing::instrument(level = "debug", skip(self, changes, base_configuration))]
pub fn apply_changes( pub fn apply_changes(
@ -18,7 +17,7 @@ impl RootDatabase {
changes: Vec<watch::ChangeEvent>, changes: Vec<watch::ChangeEvent>,
base_configuration: Option<&Configuration>, base_configuration: Option<&Configuration>,
) { ) {
let workspace = self.workspace(); let mut workspace = self.workspace();
let workspace_path = workspace.root(self).to_path_buf(); let workspace_path = workspace.root(self).to_path_buf();
let program = Program::get(self); let program = Program::get(self);
let custom_stdlib_versions_path = program let custom_stdlib_versions_path = program
@ -58,6 +57,12 @@ impl RootDatabase {
// Changes to ignore files or settings can change the workspace structure or add/remove files // Changes to ignore files or settings can change the workspace structure or add/remove files
// from packages. // from packages.
if let Some(package) = workspace.package(self, path) { if let Some(package) = workspace.package(self, path) {
if package.root(self) == workspace.root(self)
|| matches!(change, ChangeEvent::Deleted { .. })
{
workspace_change = true;
}
changed_packages.insert(package); changed_packages.insert(package);
} else { } else {
workspace_change = true; workspace_change = true;
@ -151,18 +156,22 @@ impl RootDatabase {
} }
if workspace_change { if workspace_change {
match WorkspaceMetadata::from_path( match WorkspaceMetadata::discover(&workspace_path, self.system(), base_configuration) {
&workspace_path,
self.system(),
base_configuration.cloned(),
) {
Ok(metadata) => { Ok(metadata) => {
tracing::debug!("Reloading workspace after structural change"); if metadata.root() == workspace.root(self) {
// TODO: Handle changes in the program settings. tracing::debug!("Reloading workspace after structural change");
workspace.reload(self, metadata); // TODO: Handle changes in the program settings.
workspace.reload(self, metadata);
} else {
tracing::debug!("Replace workspace after structural change");
workspace = Workspace::from_metadata(self, metadata);
self.workspace = Some(workspace);
}
} }
Err(error) => { Err(error) => {
tracing::error!("Failed to load workspace, keep old workspace: {error}"); tracing::error!(
"Failed to load workspace, keeping old workspace configuration: {error}"
);
} }
} }
@ -227,6 +236,3 @@ impl RootDatabase {
} }
} }
} }
#[cfg(test)]
mod tests {}

View file

@ -6,9 +6,9 @@ use tracing::info;
use red_knot_python_semantic::system_module_search_paths; use red_knot_python_semantic::system_module_search_paths;
use ruff_cache::{CacheKey, CacheKeyHasher}; use ruff_cache::{CacheKey, CacheKeyHasher};
use ruff_db::system::{SystemPath, SystemPathBuf}; use ruff_db::system::{SystemPath, SystemPathBuf};
use ruff_db::Upcast; use ruff_db::{Db as _, Upcast};
use crate::db::RootDatabase; use crate::db::{Db, RootDatabase};
use crate::watch::Watcher; use crate::watch::Watcher;
/// Wrapper around a [`Watcher`] that watches the relevant paths of a workspace. /// Wrapper around a [`Watcher`] that watches the relevant paths of a workspace.
@ -68,10 +68,9 @@ impl WorkspaceWatcher {
self.has_errored_paths = false; self.has_errored_paths = false;
let workspace_path = workspace_path let workspace_path = db
.as_utf8_path() .system()
.canonicalize_utf8() .canonicalize_path(&workspace_path)
.map(SystemPathBuf::from_utf8_path_buf)
.unwrap_or(workspace_path); .unwrap_or(workspace_path);
// Find the non-overlapping module search paths and filter out paths that are already covered by the workspace. // Find the non-overlapping module search paths and filter out paths that are already covered by the workspace.

View file

@ -1,26 +1,28 @@
use rustc_hash::{FxBuildHasher, FxHashSet};
use salsa::{Durability, Setter as _};
use std::borrow::Cow;
use std::{collections::BTreeMap, sync::Arc};
use crate::db::Db; use crate::db::Db;
use crate::db::RootDatabase; use crate::db::RootDatabase;
use crate::workspace::files::{Index, Indexed, IndexedIter, PackageFiles}; use crate::workspace::files::{Index, Indexed, IndexedIter, PackageFiles};
pub use metadata::{PackageMetadata, WorkspaceMetadata}; pub use metadata::{PackageMetadata, WorkspaceDiscoveryError, WorkspaceMetadata};
use red_knot_python_semantic::types::check_types; use red_knot_python_semantic::types::check_types;
use red_knot_python_semantic::SearchPathSettings; use red_knot_python_semantic::SearchPathSettings;
use ruff_db::diagnostic::{Diagnostic, ParseDiagnostic, Severity}; use ruff_db::diagnostic::{Diagnostic, ParseDiagnostic, Severity};
use ruff_db::parsed::parsed_module; use ruff_db::parsed::parsed_module;
use ruff_db::source::{source_text, SourceTextError}; use ruff_db::source::{source_text, SourceTextError};
use ruff_db::system::FileType;
use ruff_db::{ use ruff_db::{
files::{system_path_to_file, File}, files::{system_path_to_file, File},
system::{walk_directory::WalkState, SystemPath, SystemPathBuf}, system::{walk_directory::WalkState, SystemPath, SystemPathBuf},
}; };
use ruff_python_ast::{name::Name, PySourceType}; use ruff_python_ast::{name::Name, PySourceType};
use ruff_text_size::TextRange; use ruff_text_size::TextRange;
use rustc_hash::{FxBuildHasher, FxHashSet};
use salsa::{Durability, Setter as _};
use std::borrow::Cow;
use std::iter::FusedIterator;
use std::{collections::BTreeMap, sync::Arc};
mod files; mod files;
mod metadata; mod metadata;
mod pyproject;
pub mod settings; pub mod settings;
/// The project workspace as a Salsa ingredient. /// The project workspace as a Salsa ingredient.
@ -81,7 +83,7 @@ pub struct Workspace {
/// The (first-party) packages in this workspace. /// The (first-party) packages in this workspace.
#[return_ref] #[return_ref]
package_tree: BTreeMap<SystemPathBuf, Package>, package_tree: PackageTree,
/// The unresolved search path configuration. /// The unresolved search path configuration.
#[return_ref] #[return_ref]
@ -106,7 +108,6 @@ pub struct Package {
} }
impl Workspace { impl Workspace {
/// Discovers the closest workspace at `path` and returns its metadata.
pub fn from_metadata(db: &dyn Db, metadata: WorkspaceMetadata) -> Self { pub fn from_metadata(db: &dyn Db, metadata: WorkspaceMetadata) -> Self {
let mut packages = BTreeMap::new(); let mut packages = BTreeMap::new();
@ -114,10 +115,12 @@ impl Workspace {
packages.insert(package.root.clone(), Package::from_metadata(db, package)); packages.insert(package.root.clone(), Package::from_metadata(db, package));
} }
let program_settings = metadata.settings.program;
Workspace::builder( Workspace::builder(
metadata.root, metadata.root,
packages, PackageTree(packages),
metadata.settings.program.search_paths, program_settings.search_paths,
) )
.durability(Durability::MEDIUM) .durability(Durability::MEDIUM)
.open_fileset_durability(Durability::LOW) .open_fileset_durability(Durability::LOW)
@ -128,15 +131,11 @@ impl Workspace {
self.root_buf(db) self.root_buf(db)
} }
pub fn packages(self, db: &dyn Db) -> impl Iterator<Item = Package> + '_ {
self.package_tree(db).values().copied()
}
pub fn reload(self, db: &mut dyn Db, metadata: WorkspaceMetadata) { pub fn reload(self, db: &mut dyn Db, metadata: WorkspaceMetadata) {
tracing::debug!("Reloading workspace"); tracing::debug!("Reloading workspace");
assert_eq!(self.root(db), metadata.root()); assert_eq!(self.root(db), metadata.root());
let mut old_packages = self.package_tree(db).clone(); let mut old_packages = self.package_tree(db).0.clone();
let mut new_packages = BTreeMap::new(); let mut new_packages = BTreeMap::new();
for package_metadata in metadata.packages { for package_metadata in metadata.packages {
@ -157,13 +156,13 @@ impl Workspace {
.to(metadata.settings.program.search_paths); .to(metadata.settings.program.search_paths);
} }
self.set_package_tree(db).to(new_packages); self.set_package_tree(db).to(PackageTree(new_packages));
} }
pub fn update_package(self, db: &mut dyn Db, metadata: PackageMetadata) -> anyhow::Result<()> { pub fn update_package(self, db: &mut dyn Db, metadata: PackageMetadata) -> anyhow::Result<()> {
let path = metadata.root().to_path_buf(); let path = metadata.root().to_path_buf();
if let Some(package) = self.package_tree(db).get(&path).copied() { if let Some(package) = self.package_tree(db).get(&path) {
package.update(db, metadata); package.update(db, metadata);
Ok(()) Ok(())
} else { } else {
@ -171,20 +170,17 @@ impl Workspace {
} }
} }
pub fn packages(self, db: &dyn Db) -> &PackageTree {
self.package_tree(db)
}
/// Returns the closest package to which the first-party `path` belongs. /// Returns the closest package to which the first-party `path` belongs.
/// ///
/// Returns `None` if the `path` is outside of any package or if `file` isn't a first-party file /// Returns `None` if the `path` is outside of any package or if `file` isn't a first-party file
/// (e.g. third-party dependencies or `excluded`). /// (e.g. third-party dependencies or `excluded`).
pub fn package(self, db: &dyn Db, path: &SystemPath) -> Option<Package> { pub fn package(self, db: &dyn Db, path: impl AsRef<SystemPath>) -> Option<Package> {
let packages = self.package_tree(db); let packages = self.package_tree(db);
packages.get(path.as_ref())
let (package_path, package) = packages.range(..=path.to_path_buf()).next_back()?;
if path.starts_with(package_path) {
Some(*package)
} else {
None
}
} }
/// Checks all open files in the workspace and its dependencies. /// Checks all open files in the workspace and its dependencies.
@ -342,7 +338,7 @@ impl Package {
let _entered = let _entered =
tracing::debug_span!("index_package_files", package = %self.name(db)).entered(); tracing::debug_span!("index_package_files", package = %self.name(db)).entered();
let files = discover_package_files(db, self.root(db)); let files = discover_package_files(db, self);
tracing::info!("Found {} files in package `{}`", files.len(), self.name(db)); tracing::info!("Found {} files in package `{}`", files.len(), self.name(db));
vacant.set(files) vacant.set(files)
} }
@ -407,23 +403,33 @@ pub(super) fn check_file(db: &dyn Db, file: File) -> Vec<Box<dyn Diagnostic>> {
diagnostics diagnostics
} }
fn discover_package_files(db: &dyn Db, path: &SystemPath) -> FxHashSet<File> { fn discover_package_files(db: &dyn Db, package: Package) -> FxHashSet<File> {
let paths = std::sync::Mutex::new(Vec::new()); let paths = std::sync::Mutex::new(Vec::new());
let packages = db.workspace().packages(db);
db.system().walk_directory(path).run(|| { db.system().walk_directory(package.root(db)).run(|| {
Box::new(|entry| { Box::new(|entry| {
match entry { match entry {
Ok(entry) => { Ok(entry) => {
// Skip over any non python files to avoid creating too many entries in `Files`. // Skip over any non python files to avoid creating too many entries in `Files`.
if entry.file_type().is_file() match entry.file_type() {
&& entry FileType::File => {
.path() if entry
.extension() .path()
.and_then(PySourceType::try_from_extension) .extension()
.is_some() .and_then(PySourceType::try_from_extension)
{ .is_some()
let mut paths = paths.lock().unwrap(); {
paths.push(entry.into_path()); let mut paths = paths.lock().unwrap();
paths.push(entry.into_path());
}
}
FileType::Directory | FileType::Symlink => {
// Don't traverse into nested packages (the workspace-package is an ancestor of all other packages)
if packages.get(entry.path()) != Some(package) {
return WalkState::Skip;
}
}
} }
} }
Err(error) => { Err(error) => {
@ -464,6 +470,7 @@ impl<'a> WorkspaceFiles<'a> {
WorkspaceFiles::PackageFiles( WorkspaceFiles::PackageFiles(
workspace workspace
.packages(db) .packages(db)
.iter()
.map(|package| package.files(db)) .map(|package| package.files(db))
.collect(), .collect(),
) )
@ -545,20 +552,78 @@ impl Diagnostic for IOErrorDiagnostic {
} }
} }
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct PackageTree(BTreeMap<SystemPathBuf, Package>);
impl PackageTree {
pub fn get(&self, path: &SystemPath) -> Option<Package> {
let (package_path, package) = self.0.range(..=path.to_path_buf()).next_back()?;
if path.starts_with(package_path) {
Some(*package)
} else {
None
}
}
// The package table should never be empty, that's why `is_empty` makes little sense
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.0.len()
}
pub fn iter(&self) -> PackageTreeIter {
PackageTreeIter(self.0.values())
}
}
impl<'a> IntoIterator for &'a PackageTree {
type Item = Package;
type IntoIter = PackageTreeIter<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
pub struct PackageTreeIter<'a>(std::collections::btree_map::Values<'a, SystemPathBuf, Package>);
impl Iterator for PackageTreeIter<'_> {
type Item = Package;
fn next(&mut self) -> Option<Self::Item> {
self.0.next().copied()
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
fn last(mut self) -> Option<Self::Item> {
self.0.next_back().copied()
}
}
impl ExactSizeIterator for PackageTreeIter<'_> {}
impl FusedIterator for PackageTreeIter<'_> {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::db::tests::TestDb; use crate::db::tests::TestDb;
use crate::workspace::check_file; use crate::workspace::{check_file, WorkspaceMetadata};
use red_knot_python_semantic::types::check_types; use red_knot_python_semantic::types::check_types;
use ruff_db::diagnostic::Diagnostic; use ruff_db::diagnostic::Diagnostic;
use ruff_db::files::system_path_to_file; use ruff_db::files::system_path_to_file;
use ruff_db::source::source_text; use ruff_db::source::source_text;
use ruff_db::system::{DbWithTestSystem, SystemPath}; use ruff_db::system::{DbWithTestSystem, SystemPath, SystemPathBuf};
use ruff_db::testing::assert_function_query_was_not_run; use ruff_db::testing::assert_function_query_was_not_run;
use ruff_python_ast::name::Name;
#[test] #[test]
fn check_file_skips_type_checking_when_file_cant_be_read() -> ruff_db::system::Result<()> { fn check_file_skips_type_checking_when_file_cant_be_read() -> ruff_db::system::Result<()> {
let mut db = TestDb::new(); let workspace =
WorkspaceMetadata::single_package(Name::new_static("test"), SystemPathBuf::from("/"));
let mut db = TestDb::new(workspace);
let path = SystemPath::new("test.py"); let path = SystemPath::new("test.py");
db.write_file(path, "x = 10")?; db.write_file(path, "x = 10")?;

View file

@ -232,21 +232,28 @@ impl Drop for IndexedMut<'_> {
mod tests { mod tests {
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use crate::db::tests::TestDb;
use crate::db::Db;
use crate::workspace::files::Index;
use crate::workspace::WorkspaceMetadata;
use ruff_db::files::system_path_to_file; use ruff_db::files::system_path_to_file;
use ruff_db::system::{DbWithTestSystem, SystemPathBuf}; use ruff_db::system::{DbWithTestSystem, SystemPathBuf};
use ruff_python_ast::name::Name; use ruff_python_ast::name::Name;
use crate::db::tests::TestDb;
use crate::workspace::files::Index;
use crate::workspace::Package;
#[test] #[test]
fn re_entrance() -> anyhow::Result<()> { fn re_entrance() -> anyhow::Result<()> {
let mut db = TestDb::new(); let metadata = WorkspaceMetadata::single_package(
Name::new_static("test"),
SystemPathBuf::from("/test"),
);
let mut db = TestDb::new(metadata);
db.write_file("test.py", "")?; db.write_file("test.py", "")?;
let package = Package::new(&db, Name::new("test"), SystemPathBuf::from("/test")); let package = db
.workspace()
.package(&db, "/test")
.expect("test package to exist");
let file = system_path_to_file(&db, "test.py").unwrap(); let file = system_path_to_file(&db, "test.py").unwrap();

View file

@ -1,67 +1,191 @@
use crate::workspace::settings::{Configuration, WorkspaceSettings}; use ruff_db::system::{GlobError, System, SystemPath, SystemPathBuf};
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_python_ast::name::Name; use ruff_python_ast::name::Name;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use thiserror::Error;
#[derive(Debug)] use crate::workspace::pyproject::{PyProject, PyProjectError, Workspace};
use crate::workspace::settings::{Configuration, WorkspaceSettings};
#[derive(Debug, PartialEq, Eq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct WorkspaceMetadata { pub struct WorkspaceMetadata {
pub(super) root: SystemPathBuf, pub(super) root: SystemPathBuf,
/// The (first-party) packages in this workspace. /// The (first-party) packages in this workspace.
pub(super) packages: Vec<PackageMetadata>, pub(super) packages: Vec<PackageMetadata>,
/// The resolved settings for this workspace.
pub(super) settings: WorkspaceSettings, pub(super) settings: WorkspaceSettings,
} }
/// A first-party package in a workspace. /// A first-party package in a workspace.
#[derive(Debug)] #[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct PackageMetadata { pub struct PackageMetadata {
pub(super) name: Name, pub(super) name: Name,
/// The path to the root directory of the package. /// The path to the root directory of the package.
pub(super) root: SystemPathBuf, pub(super) root: SystemPathBuf,
// TODO: Add the loaded package configuration (not the nested ruff settings)
pub(super) configuration: Configuration,
} }
impl WorkspaceMetadata { impl WorkspaceMetadata {
/// Creates a workspace that consists of a single package located at `root`.
pub fn single_package(name: Name, root: SystemPathBuf) -> Self {
let package = PackageMetadata {
name,
root: root.clone(),
configuration: Configuration::default(),
};
let packages = vec![package];
let settings = packages[0]
.configuration
.to_workspace_settings(&root, &packages);
Self {
root,
packages,
settings,
}
}
/// Discovers the closest workspace at `path` and returns its metadata. /// Discovers the closest workspace at `path` and returns its metadata.
pub fn from_path( ///
/// 1. Traverse upwards in the `path`'s ancestor chain and find the first `pyproject.toml`.
/// 1. If the `pyproject.toml` contains no `knot.workspace` table, then keep traversing the `path`'s ancestor
/// chain until we find one or reach the root.
/// 1. If we've found a workspace, then resolve the workspace's members and assert that the closest
/// package (the first found package without a `knot.workspace` table is a member. If not, create
/// a single package workspace for the closest package.
/// 1. If there's no `pyrpoject.toml` with a `knot.workspace` table, then create a single-package workspace.
/// 1. If no ancestor directory contains any `pyproject.toml`, create an ad-hoc workspace for `path`
/// that consists of a single package and uses the default settings.
pub fn discover(
path: &SystemPath, path: &SystemPath,
system: &dyn System, system: &dyn System,
base_configuration: Option<Configuration>, base_configuration: Option<&Configuration>,
) -> anyhow::Result<WorkspaceMetadata> { ) -> Result<WorkspaceMetadata, WorkspaceDiscoveryError> {
assert!( tracing::debug!("Searching for a workspace in '{path}'");
system.is_directory(path),
"Workspace root path must be a directory"
);
tracing::debug!("Searching for workspace in '{path}'");
let root = path.to_path_buf(); if !system.is_directory(path) {
return Err(WorkspaceDiscoveryError::NotADirectory(path.to_path_buf()));
// TODO: Discover package name from `pyproject.toml`.
let package_name: Name = path.file_name().unwrap_or("<root>").into();
let package = PackageMetadata {
name: package_name,
root: root.clone(),
};
// TODO: Load the configuration from disk.
let mut configuration = Configuration::default();
if let Some(base_configuration) = base_configuration {
configuration.extend(base_configuration);
} }
// TODO: Respect the package configurations when resolving settings (e.g. for the target version). let mut closest_package: Option<PackageMetadata> = None;
let settings = configuration.into_workspace_settings(&root);
let workspace = WorkspaceMetadata { for ancestor in path.ancestors() {
root, let pyproject_path = ancestor.join("pyproject.toml");
packages: vec![package], if let Ok(pyproject_str) = system.read_to_string(&pyproject_path) {
settings, let pyproject = PyProject::from_str(&pyproject_str).map_err(|error| {
WorkspaceDiscoveryError::InvalidPyProject {
path: pyproject_path,
source: Box::new(error),
}
})?;
let workspace_table = pyproject.workspace().cloned();
let package = PackageMetadata::from_pyproject(
pyproject,
ancestor.to_path_buf(),
base_configuration,
);
if let Some(workspace_table) = workspace_table {
let workspace_root = ancestor;
tracing::debug!("Found workspace at '{}'", workspace_root);
match collect_packages(
package,
&workspace_table,
closest_package,
base_configuration,
system,
)? {
CollectedPackagesOrStandalone::Packages(mut packages) => {
let mut by_name =
FxHashMap::with_capacity_and_hasher(packages.len(), FxBuildHasher);
let mut workspace_package = None;
for package in &packages {
if let Some(conflicting) = by_name.insert(package.name(), package) {
return Err(WorkspaceDiscoveryError::DuplicatePackageNames {
name: package.name().clone(),
first: conflicting.root().to_path_buf(),
second: package.root().to_path_buf(),
});
}
if package.root() == workspace_root {
workspace_package = Some(package);
} else if !package.root().starts_with(workspace_root) {
return Err(WorkspaceDiscoveryError::PackageOutsideWorkspace {
package_name: package.name().clone(),
package_root: package.root().to_path_buf(),
workspace_root: workspace_root.to_path_buf(),
});
}
}
let workspace_package = workspace_package
.expect("workspace package to be part of the workspace's packages");
let settings = workspace_package
.configuration
.to_workspace_settings(workspace_root, &packages);
packages.sort_unstable_by(|a, b| a.root().cmp(b.root()));
return Ok(Self {
root: workspace_root.to_path_buf(),
packages,
settings,
});
}
CollectedPackagesOrStandalone::Standalone(package) => {
closest_package = Some(package);
break;
}
}
}
// Not a workspace itself, keep looking for an enclosing workspace.
if closest_package.is_none() {
closest_package = Some(package);
}
}
}
// No workspace found, but maybe a pyproject.toml was found.
let package = if let Some(enclosing_package) = closest_package {
tracing::debug!("Single package workspace at '{}'", enclosing_package.root());
enclosing_package
} else {
tracing::debug!("The ancestor directories contain no `pyproject.toml`. Falling back to a virtual project.");
// Create a package with a default configuration
PackageMetadata {
name: path.file_name().unwrap_or("root").into(),
root: path.to_path_buf(),
// TODO create the configuration from the pyproject toml
configuration: base_configuration.cloned().unwrap_or_default(),
}
}; };
Ok(workspace) let root = package.root().to_path_buf();
let packages = vec![package];
let settings = packages[0]
.configuration
.to_workspace_settings(&root, &packages);
Ok(Self {
root,
packages,
settings,
})
} }
pub fn root(&self) -> &SystemPath { pub fn root(&self) -> &SystemPath {
@ -78,6 +202,30 @@ impl WorkspaceMetadata {
} }
impl PackageMetadata { impl PackageMetadata {
pub(crate) fn from_pyproject(
pyproject: PyProject,
root: SystemPathBuf,
base_configuration: Option<&Configuration>,
) -> Self {
let name = pyproject.project.and_then(|project| project.name);
let name = name
.map(|name| Name::new(&*name))
.unwrap_or_else(|| Name::new(root.file_name().unwrap_or("root")));
// TODO: load configuration from pyrpoject.toml
let mut configuration = Configuration::default();
if let Some(base_configuration) = base_configuration {
configuration.extend(base_configuration.clone());
}
PackageMetadata {
name,
root,
configuration,
}
}
pub fn name(&self) -> &Name { pub fn name(&self) -> &Name {
&self.name &self.name
} }
@ -86,3 +234,577 @@ impl PackageMetadata {
&self.root &self.root
} }
} }
fn collect_packages(
workspace_package: PackageMetadata,
workspace_table: &Workspace,
closest_package: Option<PackageMetadata>,
base_configuration: Option<&Configuration>,
system: &dyn System,
) -> Result<CollectedPackagesOrStandalone, WorkspaceDiscoveryError> {
let workspace_root = workspace_package.root().to_path_buf();
let mut member_paths = FxHashSet::default();
for glob in workspace_table.members() {
let full_glob = workspace_package.root().join(glob);
let matches = system.glob(full_glob.as_str()).map_err(|error| {
WorkspaceDiscoveryError::InvalidMembersPattern {
raw_glob: glob.clone(),
source: error,
}
})?;
for result in matches {
let path = result?;
let normalized = SystemPath::absolute(path, &workspace_root);
// Skip over non-directory entry. E.g.finder might end up creating a `.DS_STORE` file
// that ends up matching `/projects/*`.
if system.is_directory(&normalized) {
member_paths.insert(normalized);
} else {
tracing::debug!("Ignoring non-directory workspace member '{normalized}'");
}
}
}
// The workspace root is always a member. Don't re-add it
let mut packages = vec![workspace_package];
member_paths.remove(&workspace_root);
// Add the package that is closest to the current working directory except
// if that package isn't a workspace member, then fallback to creating a single
// package workspace.
if let Some(closest_package) = closest_package {
// the closest `pyproject.toml` isn't a member of this workspace because it is
// explicitly included or simply not listed.
// Create a standalone workspace.
if !member_paths.remove(closest_package.root())
|| workspace_table.is_excluded(closest_package.root(), &workspace_root)?
{
tracing::debug!(
"Ignoring workspace '{workspace_root}' because package '{package}' is not a member",
package = closest_package.name()
);
return Ok(CollectedPackagesOrStandalone::Standalone(closest_package));
}
tracing::debug!("adding package '{}'", closest_package.name());
packages.push(closest_package);
}
// Add all remaining member paths
for member_path in member_paths {
if workspace_table.is_excluded(&member_path, workspace_root.as_path())? {
tracing::debug!("Ignoring excluded member '{member_path}'");
continue;
}
let pyproject_path = member_path.join("pyproject.toml");
let pyproject_str = match system.read_to_string(&pyproject_path) {
Ok(pyproject_str) => pyproject_str,
Err(error) => {
if error.kind() == std::io::ErrorKind::NotFound
&& member_path
.file_name()
.is_some_and(|name| name.starts_with('.'))
{
tracing::debug!(
"Ignore member '{member_path}' because it has no pyproject.toml and is hidden",
);
continue;
}
return Err(WorkspaceDiscoveryError::MemberFailedToReadPyProject {
package_root: member_path,
source: error,
});
}
};
let pyproject = PyProject::from_str(&pyproject_str).map_err(|error| {
WorkspaceDiscoveryError::InvalidPyProject {
source: Box::new(error),
path: pyproject_path,
}
})?;
if pyproject.workspace().is_some() {
return Err(WorkspaceDiscoveryError::NestedWorkspaces {
package_root: member_path,
});
}
let package = PackageMetadata::from_pyproject(pyproject, member_path, base_configuration);
tracing::debug!(
"Adding package '{}' at '{}'",
package.name(),
package.root()
);
packages.push(package);
}
Ok(CollectedPackagesOrStandalone::Packages(packages))
}
enum CollectedPackagesOrStandalone {
Packages(Vec<PackageMetadata>),
Standalone(PackageMetadata),
}
#[derive(Debug, Error)]
pub enum WorkspaceDiscoveryError {
#[error("workspace path '{0}' is not a directory")]
NotADirectory(SystemPathBuf),
#[error("nested workspaces aren't supported but the package located at '{package_root}' defines a `knot.workspace` table")]
NestedWorkspaces { package_root: SystemPathBuf },
#[error("the workspace contains two packages named '{name}': '{first}' and '{second}'")]
DuplicatePackageNames {
name: Name,
first: SystemPathBuf,
second: SystemPathBuf,
},
#[error("the package '{package_name}' located at '{package_root}' is outside the workspace's root directory '{workspace_root}'")]
PackageOutsideWorkspace {
workspace_root: SystemPathBuf,
package_name: Name,
package_root: SystemPathBuf,
},
#[error(
"failed to read the `pyproject.toml` for the package located at '{package_root}': {source}"
)]
MemberFailedToReadPyProject {
package_root: SystemPathBuf,
source: std::io::Error,
},
#[error("{path} is not a valid `pyproject.toml`: {source}")]
InvalidPyProject {
source: Box<PyProjectError>,
path: SystemPathBuf,
},
#[error("invalid glob '{raw_glob}' in `tool.knot.workspace.members`: {source}")]
InvalidMembersPattern {
source: glob::PatternError,
raw_glob: String,
},
#[error("failed to match member glob: {error}")]
FailedToMatchGlob {
#[from]
error: GlobError,
},
}
#[cfg(test)]
mod tests {
//! Integration tests for workspace discovery
use crate::snapshot_workspace;
use anyhow::Context;
use insta::assert_ron_snapshot;
use ruff_db::system::{SystemPathBuf, TestSystem};
use crate::workspace::{WorkspaceDiscoveryError, WorkspaceMetadata};
#[test]
fn package_without_pyproject() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([(root.join("foo.py"), ""), (root.join("bar.py"), "")])
.context("Failed to write files")?;
let workspace = WorkspaceMetadata::discover(&root, &system, None)
.context("Failed to discover workspace")?;
assert_eq!(workspace.root(), &*root);
snapshot_workspace!(workspace);
Ok(())
}
#[test]
fn single_package() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "backend"
"#,
),
(root.join("db/__init__.py"), ""),
])
.context("Failed to write files")?;
let workspace = WorkspaceMetadata::discover(&root, &system, None)
.context("Failed to discover workspace")?;
assert_eq!(workspace.root(), &*root);
snapshot_workspace!(workspace);
// Discovering the same package from a subdirectory should give the same result
let from_src = WorkspaceMetadata::discover(&root.join("db"), &system, None)
.context("Failed to discover workspace from src sub-directory")?;
assert_eq!(from_src, workspace);
Ok(())
}
#[test]
fn workspace_members() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["packages/*"]
exclude = ["packages/excluded"]
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "member-a"
"#,
),
(
root.join("packages/x/pyproject.toml"),
r#"
[project]
name = "member-x"
"#,
),
])
.context("Failed to write files")?;
let workspace = WorkspaceMetadata::discover(&root, &system, None)
.context("Failed to discover workspace")?;
assert_eq!(workspace.root(), &*root);
snapshot_workspace!(workspace);
// Discovering the same package from a member should give the same result
let from_src = WorkspaceMetadata::discover(&root.join("packages/a"), &system, None)
.context("Failed to discover workspace from src sub-directory")?;
assert_eq!(from_src, workspace);
Ok(())
}
#[test]
fn workspace_excluded() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["packages/*"]
exclude = ["packages/excluded"]
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "member-a"
"#,
),
(
root.join("packages/excluded/pyproject.toml"),
r#"
[project]
name = "member-x"
"#,
),
])
.context("Failed to write files")?;
let workspace = WorkspaceMetadata::discover(&root, &system, None)
.context("Failed to discover workspace")?;
assert_eq!(workspace.root(), &*root);
snapshot_workspace!(workspace);
// Discovering the `workspace` for `excluded` should discover a single-package workspace
let excluded_workspace =
WorkspaceMetadata::discover(&root.join("packages/excluded"), &system, None)
.context("Failed to discover workspace from src sub-directory")?;
assert_ne!(excluded_workspace, workspace);
Ok(())
}
#[test]
fn workspace_non_unique_member_names() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "a"
"#,
),
(
root.join("packages/b/pyproject.toml"),
r#"
[project]
name = "a"
"#,
),
])
.context("Failed to write files")?;
let error = WorkspaceMetadata::discover(&root, &system, None).expect_err(
"Discovery should error because the workspace contains two packages with the same names.",
);
assert_error_eq(&error, "the workspace contains two packages named 'a': '/app/packages/a' and '/app/packages/b'");
Ok(())
}
#[test]
fn nested_workspaces() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
(
root.join("packages/a/pyproject.toml"),
r#"
[project]
name = "nested-workspace"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
])
.context("Failed to write files")?;
let error = WorkspaceMetadata::discover(&root, &system, None).expect_err(
"Discovery should error because the workspace has a package that itself is a workspace",
);
assert_error_eq(&error, "nested workspaces aren't supported but the package located at '/app/packages/a' defines a `knot.workspace` table");
Ok(())
}
#[test]
fn member_missing_pyproject_toml() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
(root.join("packages/a/test.py"), ""),
])
.context("Failed to write files")?;
let error = WorkspaceMetadata::discover(&root, &system, None)
.expect_err("Discovery should error because member `a` has no `pypyroject.toml`");
assert_error_eq(&error, "failed to read the `pyproject.toml` for the package located at '/app/packages/a': No such file or directory");
Ok(())
}
/// Folders that match the members pattern but don't have a pyproject.toml
/// aren't valid members and discovery fails. However, don't fail
/// if the folder name indicates that it is a hidden folder that might
/// have been crated by another tool
#[test]
fn member_pattern_matching_hidden_folder() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
(root.join("packages/.hidden/a.py"), ""),
])
.context("Failed to write files")?;
let workspace = WorkspaceMetadata::discover(&root, &system, None)?;
snapshot_workspace!(workspace);
Ok(())
}
#[test]
fn member_pattern_matching_file() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["packages/*"]
"#,
),
(root.join("packages/.DS_STORE"), ""),
])
.context("Failed to write files")?;
let workspace = WorkspaceMetadata::discover(&root, &system, None)?;
snapshot_workspace!(&workspace);
Ok(())
}
#[test]
fn workspace_root_not_an_ancestor_of_member() -> anyhow::Result<()> {
let system = TestSystem::default();
let root = SystemPathBuf::from("/app");
system
.memory_file_system()
.write_files([
(
root.join("pyproject.toml"),
r#"
[project]
name = "workspace-root"
[tool.knot.workspace]
members = ["../packages/*"]
"#,
),
(
root.join("../packages/a/pyproject.toml"),
r#"
[project]
name = "a"
"#,
),
])
.context("Failed to write files")?;
let error = WorkspaceMetadata::discover(&root, &system, None).expect_err(
"Discovery should error because member `a` is outside the workspace's directory`",
);
assert_error_eq(&error, "the package 'a' located at '/packages/a' is outside the workspace's root directory '/app'");
Ok(())
}
#[track_caller]
fn assert_error_eq(error: &WorkspaceDiscoveryError, message: &str) {
assert_eq!(error.to_string().replace('\\', "/"), message);
}
/// Snapshots a workspace but with all paths using unix separators.
#[macro_export]
macro_rules! snapshot_workspace {
($workspace:expr) => {{
assert_ron_snapshot!($workspace,{
".root" => insta::dynamic_redaction(|content, _content_path| {
content.as_str().unwrap().replace("\\", "/")
}),
".packages[].root" => insta::dynamic_redaction(|content, _content_path| {
content.as_str().unwrap().replace("\\", "/")
}),
});
}};
}
}

View file

@ -0,0 +1,108 @@
mod package_name;
use pep440_rs::{Version, VersionSpecifiers};
use serde::Deserialize;
use thiserror::Error;
use crate::workspace::metadata::WorkspaceDiscoveryError;
pub(crate) use package_name::PackageName;
use ruff_db::system::SystemPath;
/// A `pyproject.toml` as specified in PEP 517.
#[derive(Deserialize, Debug, Default, Clone)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct PyProject {
/// PEP 621-compliant project metadata.
pub project: Option<Project>,
/// Tool-specific metadata.
pub tool: Option<Tool>,
}
impl PyProject {
pub(crate) fn workspace(&self) -> Option<&Workspace> {
self.tool
.as_ref()
.and_then(|tool| tool.knot.as_ref())
.and_then(|knot| knot.workspace.as_ref())
}
}
#[derive(Error, Debug)]
pub enum PyProjectError {
#[error(transparent)]
TomlSyntax(#[from] toml::de::Error),
}
impl PyProject {
pub(crate) fn from_str(content: &str) -> Result<Self, PyProjectError> {
toml::from_str(content).map_err(PyProjectError::TomlSyntax)
}
}
/// PEP 621 project metadata (`project`).
///
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[cfg_attr(test, derive(serde::Serialize))]
#[serde(rename_all = "kebab-case")]
pub(crate) struct Project {
/// The name of the project
///
/// Note: Intentionally option to be more permissive during deserialization.
/// `PackageMetadata::from_pyproject` reports missing names.
pub name: Option<PackageName>,
/// The version of the project
pub version: Option<Version>,
/// The Python versions this project is compatible with.
pub requires_python: Option<VersionSpecifiers>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub(crate) struct Tool {
pub knot: Option<Knot>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct Knot {
pub(crate) workspace: Option<Workspace>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct Workspace {
pub(crate) members: Option<Vec<String>>,
pub(crate) exclude: Option<Vec<String>>,
}
impl Workspace {
pub(crate) fn members(&self) -> &[String] {
self.members.as_deref().unwrap_or_default()
}
pub(crate) fn exclude(&self) -> &[String] {
self.exclude.as_deref().unwrap_or_default()
}
pub(crate) fn is_excluded(
&self,
path: &SystemPath,
workspace_root: &SystemPath,
) -> Result<bool, WorkspaceDiscoveryError> {
for exclude in self.exclude() {
let full_glob =
glob::Pattern::new(workspace_root.join(exclude).as_str()).map_err(|error| {
WorkspaceDiscoveryError::InvalidMembersPattern {
raw_glob: exclude.clone(),
source: error,
}
})?;
if full_glob.matches_path(path.as_std_path()) {
return Ok(true);
}
}
Ok(false)
}
}

View file

@ -0,0 +1,140 @@
use serde::{Deserialize, Deserializer, Serialize};
use std::ops::Deref;
use thiserror::Error;
/// The normalized name of a package.
///
/// Converts the name to lowercase and collapses runs of `-`, `_`, and `.` down to a single `-`.
/// For example, `---`, `.`, and `__` are all converted to a single `-`.
///
/// See: <https://packaging.python.org/en/latest/specifications/name-normalization/>
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub(crate) struct PackageName(String);
impl PackageName {
/// Create a validated, normalized package name.
pub(crate) fn new(name: String) -> Result<Self, InvalidPackageNameError> {
if name.is_empty() {
return Err(InvalidPackageNameError::Empty);
}
if name.starts_with(['-', '_', '.']) {
return Err(InvalidPackageNameError::NonAlphanumericStart(
name.chars().next().unwrap(),
));
}
if name.ends_with(['-', '_', '.']) {
return Err(InvalidPackageNameError::NonAlphanumericEnd(
name.chars().last().unwrap(),
));
}
let Some(start) = name.find(|c: char| {
!c.is_ascii() || c.is_ascii_uppercase() || matches!(c, '-' | '_' | '.')
}) else {
return Ok(Self(name));
};
let (already_normalized, maybe_normalized) = name.split_at(start);
let mut normalized = String::with_capacity(name.len());
normalized.push_str(already_normalized);
let mut last = None;
for c in maybe_normalized.chars() {
if !c.is_ascii() {
return Err(InvalidPackageNameError::InvalidCharacter(c));
}
if c.is_ascii_uppercase() {
normalized.push(c.to_ascii_lowercase());
} else if matches!(c, '-' | '_' | '.') {
if matches!(last, Some('-' | '_' | '.')) {
// Only keep a single instance of `-`, `_` and `.`
} else {
normalized.push('-');
}
} else {
normalized.push(c);
}
last = Some(c);
}
Ok(Self(normalized))
}
/// Returns the underlying package name.
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl From<PackageName> for String {
fn from(value: PackageName) -> Self {
value.0
}
}
impl<'de> Deserialize<'de> for PackageName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
impl std::fmt::Display for PackageName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl Deref for PackageName {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
#[derive(Error, Debug)]
pub(crate) enum InvalidPackageNameError {
#[error("name must start with letter or number but it starts with '{0}'")]
NonAlphanumericStart(char),
#[error("name must end with letter or number but it ends with '{0}'")]
NonAlphanumericEnd(char),
#[error("valid name consists only of ASCII letters and numbers, period, underscore and hyphen but name contains '{0}'"
)]
InvalidCharacter(char),
#[error("name must not be empty")]
Empty,
}
#[cfg(test)]
mod tests {
use super::PackageName;
#[test]
fn normalize() {
let inputs = [
"friendly-bard",
"Friendly-Bard",
"FRIENDLY-BARD",
"friendly.bard",
"friendly_bard",
"friendly--bard",
"friendly-.bard",
"FrIeNdLy-._.-bArD",
];
for input in inputs {
assert_eq!(
PackageName::new(input.to_string()).unwrap(),
PackageName::new("friendly-bard".to_string()).unwrap(),
);
}
}
}

View file

@ -1,10 +1,12 @@
use crate::workspace::PackageMetadata;
use red_knot_python_semantic::{ProgramSettings, PythonVersion, SearchPathSettings, SitePackages}; use red_knot_python_semantic::{ProgramSettings, PythonVersion, SearchPathSettings, SitePackages};
use ruff_db::system::{SystemPath, SystemPathBuf}; use ruff_db::system::{SystemPath, SystemPathBuf};
/// The resolved configurations. /// The resolved configurations.
/// ///
/// The main difference to [`Configuration`] is that default values are filled in. /// The main difference to [`Configuration`] is that default values are filled in.
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct WorkspaceSettings { pub struct WorkspaceSettings {
pub(super) program: ProgramSettings, pub(super) program: ProgramSettings,
} }
@ -16,7 +18,8 @@ impl WorkspaceSettings {
} }
/// The configuration for the workspace or a package. /// The configuration for the workspace or a package.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct Configuration { pub struct Configuration {
pub target_version: Option<PythonVersion>, pub target_version: Option<PythonVersion>,
pub search_paths: SearchPathConfiguration, pub search_paths: SearchPathConfiguration,
@ -29,17 +32,22 @@ impl Configuration {
self.search_paths.extend(with.search_paths); self.search_paths.extend(with.search_paths);
} }
pub fn into_workspace_settings(self, workspace_root: &SystemPath) -> WorkspaceSettings { pub fn to_workspace_settings(
&self,
workspace_root: &SystemPath,
_packages: &[PackageMetadata],
) -> WorkspaceSettings {
WorkspaceSettings { WorkspaceSettings {
program: ProgramSettings { program: ProgramSettings {
target_version: self.target_version.unwrap_or_default(), target_version: self.target_version.unwrap_or_default(),
search_paths: self.search_paths.into_settings(workspace_root), search_paths: self.search_paths.to_settings(workspace_root),
}, },
} }
} }
} }
#[derive(Debug, Default, Clone, Eq, PartialEq)] #[derive(Debug, Default, Clone, Eq, PartialEq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct SearchPathConfiguration { pub struct SearchPathConfiguration {
/// List of user-provided paths that should take first priority in the module resolution. /// List of user-provided paths that should take first priority in the module resolution.
/// Examples in other type checkers are mypy's MYPYPATH environment variable, /// Examples in other type checkers are mypy's MYPYPATH environment variable,
@ -59,15 +67,19 @@ pub struct SearchPathConfiguration {
} }
impl SearchPathConfiguration { impl SearchPathConfiguration {
pub fn into_settings(self, workspace_root: &SystemPath) -> SearchPathSettings { pub fn to_settings(&self, workspace_root: &SystemPath) -> SearchPathSettings {
let site_packages = self.site_packages.unwrap_or(SitePackages::Known(vec![])); let site_packages = self
.site_packages
.clone()
.unwrap_or(SitePackages::Known(vec![]));
SearchPathSettings { SearchPathSettings {
extra_paths: self.extra_paths.unwrap_or_default(), extra_paths: self.extra_paths.clone().unwrap_or_default(),
src_root: self src_root: self
.clone()
.src_root .src_root
.unwrap_or_else(|| workspace_root.to_path_buf()), .unwrap_or_else(|| workspace_root.to_path_buf()),
custom_typeshed: self.custom_typeshed, custom_typeshed: self.custom_typeshed.clone(),
site_packages, site_packages,
} }
} }

View file

@ -0,0 +1,36 @@
---
source: crates/red_knot_workspace/src/workspace/metadata.rs
expression: "&workspace"
---
WorkspaceMetadata(
root: "/app",
packages: [
PackageMetadata(
name: Name("workspace-root"),
root: "/app",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
],
settings: WorkspaceSettings(
program: ProgramSettings(
target_version: PythonVersion(
major: 3,
minor: 8,
),
search_paths: SearchPathSettings(
extra_paths: [],
src_root: "/app",
custom_typeshed: None,
site_packages: Known([]),
),
),
),
)

View file

@ -0,0 +1,36 @@
---
source: crates/red_knot_workspace/src/workspace/metadata.rs
expression: workspace
---
WorkspaceMetadata(
root: "/app",
packages: [
PackageMetadata(
name: Name("workspace-root"),
root: "/app",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
],
settings: WorkspaceSettings(
program: ProgramSettings(
target_version: PythonVersion(
major: 3,
minor: 8,
),
search_paths: SearchPathSettings(
extra_paths: [],
src_root: "/app",
custom_typeshed: None,
site_packages: Known([]),
),
),
),
)

View file

@ -0,0 +1,36 @@
---
source: crates/red_knot_workspace/src/workspace/metadata.rs
expression: workspace
---
WorkspaceMetadata(
root: "/app",
packages: [
PackageMetadata(
name: Name("app"),
root: "/app",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
],
settings: WorkspaceSettings(
program: ProgramSettings(
target_version: PythonVersion(
major: 3,
minor: 8,
),
search_paths: SearchPathSettings(
extra_paths: [],
src_root: "/app",
custom_typeshed: None,
site_packages: Known([]),
),
),
),
)

View file

@ -0,0 +1,36 @@
---
source: crates/red_knot_workspace/src/workspace/metadata.rs
expression: workspace
---
WorkspaceMetadata(
root: "/app",
packages: [
PackageMetadata(
name: Name("backend"),
root: "/app",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
],
settings: WorkspaceSettings(
program: ProgramSettings(
target_version: PythonVersion(
major: 3,
minor: 8,
),
search_paths: SearchPathSettings(
extra_paths: [],
src_root: "/app",
custom_typeshed: None,
site_packages: Known([]),
),
),
),
)

View file

@ -0,0 +1,49 @@
---
source: crates/red_knot_workspace/src/workspace/metadata.rs
expression: workspace
---
WorkspaceMetadata(
root: "/app",
packages: [
PackageMetadata(
name: Name("workspace-root"),
root: "/app",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
PackageMetadata(
name: Name("member-a"),
root: "/app/packages/a",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
],
settings: WorkspaceSettings(
program: ProgramSettings(
target_version: PythonVersion(
major: 3,
minor: 8,
),
search_paths: SearchPathSettings(
extra_paths: [],
src_root: "/app",
custom_typeshed: None,
site_packages: Known([]),
),
),
),
)

View file

@ -0,0 +1,62 @@
---
source: crates/red_knot_workspace/src/workspace/metadata.rs
expression: workspace
---
WorkspaceMetadata(
root: "/app",
packages: [
PackageMetadata(
name: Name("workspace-root"),
root: "/app",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
PackageMetadata(
name: Name("member-a"),
root: "/app/packages/a",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
PackageMetadata(
name: Name("member-x"),
root: "/app/packages/x",
configuration: Configuration(
target_version: None,
search_paths: SearchPathConfiguration(
extra_paths: None,
src_root: None,
custom_typeshed: None,
site_packages: None,
),
),
),
],
settings: WorkspaceSettings(
program: ProgramSettings(
target_version: PythonVersion(
major: 3,
minor: 8,
),
search_paths: SearchPathSettings(
extra_paths: [],
src_root: "/app",
custom_typeshed: None,
site_packages: Known([]),
),
),
),
)

View file

@ -9,7 +9,7 @@ use ruff_python_ast::visitor::source_order::SourceOrderVisitor;
use ruff_python_ast::{self as ast, Alias, Expr, Parameter, ParameterWithDefault, Stmt}; use ruff_python_ast::{self as ast, Alias, Expr, Parameter, ParameterWithDefault, Stmt};
fn setup_db(workspace_root: &SystemPath, system: TestSystem) -> anyhow::Result<RootDatabase> { fn setup_db(workspace_root: &SystemPath, system: TestSystem) -> anyhow::Result<RootDatabase> {
let workspace = WorkspaceMetadata::from_path(workspace_root, &system, None)?; let workspace = WorkspaceMetadata::discover(workspace_root, &system, None)?;
RootDatabase::new(workspace, system) RootDatabase::new(workspace, system)
} }

View file

@ -2,7 +2,7 @@
use rayon::ThreadPoolBuilder; use rayon::ThreadPoolBuilder;
use red_knot_python_semantic::PythonVersion; use red_knot_python_semantic::PythonVersion;
use red_knot_workspace::db::RootDatabase; use red_knot_workspace::db::{Db, RootDatabase};
use red_knot_workspace::watch::{ChangeEvent, ChangedKind}; use red_knot_workspace::watch::{ChangeEvent, ChangedKind};
use red_knot_workspace::workspace::settings::Configuration; use red_knot_workspace::workspace::settings::Configuration;
use red_knot_workspace::workspace::WorkspaceMetadata; use red_knot_workspace::workspace::WorkspaceMetadata;
@ -75,10 +75,10 @@ fn setup_case() -> Case {
.unwrap(); .unwrap();
let src_root = SystemPath::new("/src"); let src_root = SystemPath::new("/src");
let metadata = WorkspaceMetadata::from_path( let metadata = WorkspaceMetadata::discover(
src_root, src_root,
&system, &system,
Some(Configuration { Some(&Configuration {
target_version: Some(PythonVersion::PY312), target_version: Some(PythonVersion::PY312),
..Configuration::default() ..Configuration::default()
}), }),

View file

@ -22,7 +22,9 @@ ruff_text_size = { workspace = true }
camino = { workspace = true } camino = { workspace = true }
countme = { workspace = true } countme = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
dunce = { workspace = true }
filetime = { workspace = true } filetime = { workspace = true }
glob = { workspace = true }
ignore = { workspace = true, optional = true } ignore = { workspace = true, optional = true }
matchit = { workspace = true } matchit = { workspace = true }
salsa = { workspace = true } salsa = { workspace = true }

View file

@ -1,9 +1,12 @@
use std::fmt::Debug; pub use glob::PatternError;
pub use memory_fs::MemoryFileSystem; pub use memory_fs::MemoryFileSystem;
#[cfg(feature = "os")] #[cfg(feature = "os")]
pub use os::OsSystem; pub use os::OsSystem;
use ruff_notebook::{Notebook, NotebookError}; use ruff_notebook::{Notebook, NotebookError};
use std::error::Error;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::{fmt, io};
pub use test::{DbWithTestSystem, TestSystem}; pub use test::{DbWithTestSystem, TestSystem};
use walk_directory::WalkDirectoryBuilder; use walk_directory::WalkDirectoryBuilder;
@ -51,6 +54,10 @@ pub trait System: Debug {
/// * `path` does not exist. /// * `path` does not exist.
/// * A non-final component in `path` is not a directory. /// * A non-final component in `path` is not a directory.
/// * the symlink target path is not valid Unicode. /// * the symlink target path is not valid Unicode.
///
/// ## Windows long-paths
/// Unlike `std::fs::canonicalize`, this function does remove UNC prefixes if possible.
/// See [dunce::canonicalize] for more information.
fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf>; fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf>;
/// Reads the content of the file at `path` into a [`String`]. /// Reads the content of the file at `path` into a [`String`].
@ -126,6 +133,19 @@ pub trait System: Debug {
/// yields a single entry for that file. /// yields a single entry for that file.
fn walk_directory(&self, path: &SystemPath) -> WalkDirectoryBuilder; fn walk_directory(&self, path: &SystemPath) -> WalkDirectoryBuilder;
/// Return an iterator that produces all the `Path`s that match the given
/// pattern using default match options, which may be absolute or relative to
/// the current working directory.
///
/// This may return an error if the pattern is invalid.
fn glob(
&self,
pattern: &str,
) -> std::result::Result<
Box<dyn Iterator<Item = std::result::Result<SystemPathBuf, GlobError>>>,
PatternError,
>;
fn as_any(&self) -> &dyn std::any::Any; fn as_any(&self) -> &dyn std::any::Any;
fn as_any_mut(&mut self) -> &mut dyn std::any::Any; fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
@ -204,3 +224,59 @@ impl DirectoryEntry {
self.file_type self.file_type
} }
} }
/// A glob iteration error.
///
/// This is typically returned when a particular path cannot be read
/// to determine if its contents match the glob pattern. This is possible
/// if the program lacks the appropriate permissions, for example.
#[derive(Debug)]
pub struct GlobError {
path: PathBuf,
error: GlobErrorKind,
}
impl GlobError {
/// The Path that the error corresponds to.
pub fn path(&self) -> &Path {
&self.path
}
pub fn kind(&self) -> &GlobErrorKind {
&self.error
}
}
impl Error for GlobError {}
impl fmt::Display for GlobError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.error {
GlobErrorKind::IOError(error) => {
write!(
f,
"attempting to read `{}` resulted in an error: {error}",
self.path.display(),
)
}
GlobErrorKind::NonUtf8Path => {
write!(f, "`{}` is not a valid UTF-8 path", self.path.display(),)
}
}
}
}
impl From<glob::GlobError> for GlobError {
fn from(value: glob::GlobError) -> Self {
Self {
path: value.path().to_path_buf(),
error: GlobErrorKind::IOError(value.into_error()),
}
}
}
#[derive(Debug)]
pub enum GlobErrorKind {
IOError(io::Error),
NonUtf8Path,
}

View file

@ -9,13 +9,13 @@ use rustc_hash::FxHashMap;
use ruff_notebook::{Notebook, NotebookError}; use ruff_notebook::{Notebook, NotebookError};
use crate::system::{ use crate::system::{
walk_directory, DirectoryEntry, FileType, Metadata, Result, SystemPath, SystemPathBuf, walk_directory, DirectoryEntry, FileType, GlobError, GlobErrorKind, Metadata, Result,
SystemVirtualPath, SystemVirtualPathBuf, SystemPath, SystemPathBuf, SystemVirtualPath, SystemVirtualPathBuf,
}; };
use super::walk_directory::{ use super::walk_directory::{
DirectoryWalker, WalkDirectoryBuilder, WalkDirectoryConfiguration, WalkDirectoryVisitor, DirectoryWalker, ErrorKind, WalkDirectoryBuilder, WalkDirectoryConfiguration,
WalkDirectoryVisitorBuilder, WalkState, WalkDirectoryVisitor, WalkDirectoryVisitorBuilder, WalkState,
}; };
/// File system that stores all content in memory. /// File system that stores all content in memory.
@ -94,8 +94,11 @@ impl MemoryFileSystem {
metadata(self, path.as_ref()) metadata(self, path.as_ref())
} }
pub fn canonicalize(&self, path: impl AsRef<SystemPath>) -> SystemPathBuf { pub fn canonicalize(&self, path: impl AsRef<SystemPath>) -> Result<SystemPathBuf> {
SystemPathBuf::from_utf8_path_buf(self.normalize_path(path)) let path = path.as_ref();
// Mimic the behavior of a real FS where canonicalize errors if the `path` doesn't exist
self.metadata(path)?;
Ok(SystemPathBuf::from_utf8_path_buf(self.normalize_path(path)))
} }
pub fn is_file(&self, path: impl AsRef<SystemPath>) -> bool { pub fn is_file(&self, path: impl AsRef<SystemPath>) -> bool {
@ -230,6 +233,46 @@ impl MemoryFileSystem {
WalkDirectoryBuilder::new(path, MemoryWalker { fs: self.clone() }) WalkDirectoryBuilder::new(path, MemoryWalker { fs: self.clone() })
} }
pub fn glob(
&self,
pattern: &str,
) -> std::result::Result<
impl Iterator<Item = std::result::Result<SystemPathBuf, GlobError>>,
glob::PatternError,
> {
// Very naive implementation that iterates over all files and collects all that match the given pattern.
let normalized = self.normalize_path(pattern);
let pattern = glob::Pattern::new(normalized.as_str())?;
let matches = std::sync::Mutex::new(Vec::new());
self.walk_directory("/").standard_filters(false).run(|| {
Box::new(|entry| {
match entry {
Ok(entry) => {
if pattern.matches_path(entry.path().as_std_path()) {
matches.lock().unwrap().push(Ok(entry.into_path()));
}
}
Err(error) => match error.kind {
ErrorKind::Loop { .. } => {
unreachable!("Loops aren't possible in the memory file system because it doesn't support symlinks.")
}
ErrorKind::Io { err, path } => {
matches.lock().unwrap().push(Err(GlobError { path: path.expect("walk_directory to always set a path").into_std_path_buf(), error: GlobErrorKind::IOError(err)}));
}
ErrorKind::NonUtf8Path { path } => {
matches.lock().unwrap().push(Err(GlobError { path, error: GlobErrorKind::NonUtf8Path}));
}
},
}
WalkState::Continue
})
});
Ok(matches.into_inner().unwrap().into_iter())
}
pub fn remove_file(&self, path: impl AsRef<SystemPath>) -> Result<()> { pub fn remove_file(&self, path: impl AsRef<SystemPath>) -> Result<()> {
fn remove_file(fs: &MemoryFileSystem, path: &SystemPath) -> Result<()> { fn remove_file(fs: &MemoryFileSystem, path: &SystemPath) -> Result<()> {
let mut by_path = fs.inner.by_path.write().unwrap(); let mut by_path = fs.inner.by_path.write().unwrap();
@ -629,7 +672,7 @@ impl DirectoryWalker for MemoryWalker {
visitor.visit(Err(walk_directory::Error { visitor.visit(Err(walk_directory::Error {
depth: Some(depth), depth: Some(depth),
kind: walk_directory::ErrorKind::Io { kind: walk_directory::ErrorKind::Io {
path: None, path: Some(path.clone()),
err: error, err: error,
}, },
})); }));
@ -676,6 +719,7 @@ fn now() -> FileTime {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::io::ErrorKind; use std::io::ErrorKind;
use std::time::Duration; use std::time::Duration;
use crate::system::walk_directory::tests::DirectoryEntryToString; use crate::system::walk_directory::tests::DirectoryEntryToString;
@ -1149,4 +1193,26 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn glob() -> std::io::Result<()> {
let root = SystemPath::new("/src");
let fs = MemoryFileSystem::with_current_directory(root);
fs.write_files([
(root.join("foo.py"), "print('foo')"),
(root.join("a/bar.py"), "print('bar')"),
(root.join("a/.baz.py"), "print('baz')"),
])?;
let mut matches = fs.glob("/src/a/**").unwrap().flatten().collect::<Vec<_>>();
matches.sort_unstable();
assert_eq!(matches, vec![root.join("a/.baz.py"), root.join("a/bar.py")]);
let matches = fs.glob("**/bar.py").unwrap().flatten().collect::<Vec<_>>();
assert_eq!(matches, vec![root.join("a/bar.py")]);
Ok(())
}
} }

View file

@ -6,8 +6,8 @@ use filetime::FileTime;
use ruff_notebook::{Notebook, NotebookError}; use ruff_notebook::{Notebook, NotebookError};
use crate::system::{ use crate::system::{
DirectoryEntry, FileType, Metadata, Result, System, SystemPath, SystemPathBuf, DirectoryEntry, FileType, GlobError, GlobErrorKind, Metadata, Result, System, SystemPath,
SystemVirtualPath, SystemPathBuf, SystemVirtualPath,
}; };
use super::walk_directory::{ use super::walk_directory::{
@ -64,9 +64,11 @@ impl System for OsSystem {
} }
fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf> { fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf> {
path.as_utf8_path() path.as_utf8_path().canonicalize_utf8().map(|path| {
.canonicalize_utf8() SystemPathBuf::from_utf8_path_buf(path)
.map(SystemPathBuf::from_utf8_path_buf) .simplified()
.to_path_buf()
})
} }
fn read_to_string(&self, path: &SystemPath) -> Result<String> { fn read_to_string(&self, path: &SystemPath) -> Result<String> {
@ -104,6 +106,30 @@ impl System for OsSystem {
WalkDirectoryBuilder::new(path, OsDirectoryWalker {}) WalkDirectoryBuilder::new(path, OsDirectoryWalker {})
} }
fn glob(
&self,
pattern: &str,
) -> std::result::Result<
Box<dyn Iterator<Item = std::result::Result<SystemPathBuf, GlobError>>>,
glob::PatternError,
> {
glob::glob(pattern).map(|inner| {
let iterator = inner.map(|result| {
let path = result?;
let system_path = SystemPathBuf::from_path_buf(path).map_err(|path| GlobError {
path,
error: GlobErrorKind::NonUtf8Path,
})?;
Ok(system_path)
});
let boxed: Box<dyn Iterator<Item = _>> = Box::new(iterator);
boxed
})
}
fn as_any(&self) -> &dyn Any { fn as_any(&self) -> &dyn Any {
self self
} }

View file

@ -1,12 +1,8 @@
// TODO support untitled files for the LSP use case. Wrap a `str` and `String`
// The main question is how `as_std_path` would work for untitled files, that can only exist in the LSP case
// but there's no compile time guarantee that a [`OsSystem`] never gets an untitled file path.
use camino::{Utf8Path, Utf8PathBuf}; use camino::{Utf8Path, Utf8PathBuf};
use std::borrow::Borrow; use std::borrow::Borrow;
use std::fmt::Formatter; use std::fmt::Formatter;
use std::ops::Deref; use std::ops::Deref;
use std::path::{Path, StripPrefixError}; use std::path::{Path, PathBuf, StripPrefixError};
/// A slice of a path on [`System`](super::System) (akin to [`str`]). /// A slice of a path on [`System`](super::System) (akin to [`str`]).
/// ///
@ -23,6 +19,32 @@ impl SystemPath {
unsafe { &*(path as *const Utf8Path as *const SystemPath) } unsafe { &*(path as *const Utf8Path as *const SystemPath) }
} }
/// Takes any path, and when possible, converts Windows UNC paths to regular paths.
/// If the path can't be converted, it's returned unmodified.
///
/// On non-Windows this is no-op.
///
/// `\\?\C:\Windows` will be converted to `C:\Windows`,
/// but `\\?\C:\COM` will be left as-is (due to a reserved filename).
///
/// Use this to pass arbitrary paths to programs that may not be UNC-aware.
///
/// It's generally safe to pass UNC paths to legacy programs, because
/// these paths contain a reserved prefix, so will gracefully fail
/// if used with legacy APIs that don't support UNC.
///
/// This function does not perform any I/O.
///
/// Currently paths with unpaired surrogates aren't converted even if they
/// could be, due to limitations of Rust's `OsStr` API.
///
/// To check if a path remained as UNC, use `path.as_os_str().as_encoded_bytes().starts_with(b"\\\\")`.
#[inline]
pub fn simplified(&self) -> &SystemPath {
// SAFETY: simplified only trims the path, that means the returned path must be a valid UTF-8 path.
SystemPath::from_std_path(dunce::simplified(self.as_std_path())).unwrap()
}
/// Extracts the file extension, if possible. /// Extracts the file extension, if possible.
/// ///
/// The extension is: /// The extension is:
@ -123,6 +145,39 @@ impl SystemPath {
self.0.parent().map(SystemPath::new) self.0.parent().map(SystemPath::new)
} }
/// Produces an iterator over `SystemPath` and its ancestors.
///
/// The iterator will yield the `SystemPath` that is returned if the [`parent`] method is used zero
/// or more times. That means, the iterator will yield `&self`, `&self.parent().unwrap()`,
/// `&self.parent().unwrap().parent().unwrap()` and so on. If the [`parent`] method returns
/// [`None`], the iterator will do likewise. The iterator will always yield at least one value,
/// namely `&self`.
///
/// # Examples
///
/// ```
/// use ruff_db::system::SystemPath;
///
/// let mut ancestors = SystemPath::new("/foo/bar").ancestors();
/// assert_eq!(ancestors.next(), Some(SystemPath::new("/foo/bar")));
/// assert_eq!(ancestors.next(), Some(SystemPath::new("/foo")));
/// assert_eq!(ancestors.next(), Some(SystemPath::new("/")));
/// assert_eq!(ancestors.next(), None);
///
/// let mut ancestors = SystemPath::new("../foo/bar").ancestors();
/// assert_eq!(ancestors.next(), Some(SystemPath::new("../foo/bar")));
/// assert_eq!(ancestors.next(), Some(SystemPath::new("../foo")));
/// assert_eq!(ancestors.next(), Some(SystemPath::new("..")));
/// assert_eq!(ancestors.next(), Some(SystemPath::new("")));
/// assert_eq!(ancestors.next(), None);
/// ```
///
/// [`parent`]: SystemPath::parent
#[inline]
pub fn ancestors(&self) -> impl Iterator<Item = &SystemPath> {
self.0.ancestors().map(SystemPath::new)
}
/// Produces an iterator over the [`camino::Utf8Component`]s of the path. /// Produces an iterator over the [`camino::Utf8Component`]s of the path.
/// ///
/// When parsing the path, there is a small amount of normalization: /// When parsing the path, there is a small amount of normalization:
@ -473,6 +528,10 @@ impl SystemPathBuf {
self.0 self.0
} }
pub fn into_std_path_buf(self) -> PathBuf {
self.0.into_std_path_buf()
}
#[inline] #[inline]
pub fn as_path(&self) -> &SystemPath { pub fn as_path(&self) -> &SystemPath {
SystemPath::new(&self.0) SystemPath::new(&self.0)

View file

@ -1,14 +1,14 @@
use glob::PatternError;
use ruff_notebook::{Notebook, NotebookError};
use ruff_python_trivia::textwrap;
use std::any::Any; use std::any::Any;
use std::panic::RefUnwindSafe; use std::panic::RefUnwindSafe;
use std::sync::Arc; use std::sync::Arc;
use ruff_notebook::{Notebook, NotebookError};
use ruff_python_trivia::textwrap;
use crate::files::File; use crate::files::File;
use crate::system::{ use crate::system::{
DirectoryEntry, MemoryFileSystem, Metadata, Result, System, SystemPath, SystemPathBuf, DirectoryEntry, GlobError, MemoryFileSystem, Metadata, Result, System, SystemPath,
SystemVirtualPath, SystemPathBuf, SystemVirtualPath,
}; };
use crate::Db; use crate::Db;
@ -121,6 +121,22 @@ impl System for TestSystem {
} }
} }
fn glob(
&self,
pattern: &str,
) -> std::result::Result<
Box<dyn Iterator<Item = std::result::Result<SystemPathBuf, GlobError>>>,
PatternError,
> {
match &self.inner {
TestSystemInner::Stub(fs) => {
let iterator = fs.glob(pattern)?;
Ok(Box::new(iterator))
}
TestSystemInner::System(system) => system.glob(pattern),
}
}
fn as_any(&self) -> &dyn Any { fn as_any(&self) -> &dyn Any {
self self
} }
@ -142,7 +158,7 @@ impl System for TestSystem {
fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf> { fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf> {
match &self.inner { match &self.inner {
TestSystemInner::System(fs) => fs.canonicalize_path(path), TestSystemInner::System(fs) => fs.canonicalize_path(path),
TestSystemInner::Stub(fs) => Ok(fs.canonicalize(path)), TestSystemInner::Stub(fs) => fs.canonicalize(path),
} }
} }
} }