diff --git a/Cargo.lock b/Cargo.lock index 7f7fc7f254..4e59303cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2928,6 +2928,7 @@ dependencies = [ "filetime", "globwalk", "ignore", + "indexmap", "indoc", "insta", "insta-cmd", diff --git a/crates/ruff/Cargo.toml b/crates/ruff/Cargo.toml index d0769d2cff..1de422b306 100644 --- a/crates/ruff/Cargo.toml +++ b/crates/ruff/Cargo.toml @@ -48,6 +48,7 @@ colored = { workspace = true } filetime = { workspace = true } globwalk = { workspace = true } ignore = { workspace = true } +indexmap = { workspace = true } is-macro = { workspace = true } itertools = { workspace = true } jiff = { workspace = true } diff --git a/crates/ruff/src/commands/analyze_graph.rs b/crates/ruff/src/commands/analyze_graph.rs index 26a9f38133..ba9ccf8c6e 100644 --- a/crates/ruff/src/commands/analyze_graph.rs +++ b/crates/ruff/src/commands/analyze_graph.rs @@ -2,6 +2,7 @@ use crate::args::{AnalyzeGraphArgs, ConfigArguments}; use crate::resolve::resolve; use crate::{ExitStatus, resolve_default_files}; use anyhow::Result; +use indexmap::IndexSet; use log::{debug, warn}; use path_absolutize::CWD; use ruff_db::system::{SystemPath, SystemPathBuf}; @@ -11,7 +12,7 @@ use ruff_linter::source_kind::SourceKind; use ruff_linter::{warn_user, warn_user_once}; use ruff_python_ast::{PySourceType, SourceType}; use ruff_workspace::resolver::{ResolvedFile, match_exclusion, python_files_in_path}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxBuildHasher, FxHashMap}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -59,17 +60,34 @@ pub(crate) fn analyze_graph( }) .collect::>(); - // Create a database from the source roots. - let src_roots = package_roots - .values() - .filter_map(|package| package.as_deref()) - .filter_map(|package| package.parent()) - .map(Path::to_path_buf) - .filter_map(|path| SystemPathBuf::from_path_buf(path).ok()) - .collect(); + // Create a database from the source roots, combining configured `src` paths with detected + // package roots. Configured paths are added first so they take precedence, and duplicates + // are removed. + let mut src_roots: IndexSet = IndexSet::default(); + + // Add configured `src` paths first (for precedence), filtering to only include existing + // directories. + src_roots.extend( + pyproject_config + .settings + .linter + .src + .iter() + .filter(|path| path.is_dir()) + .filter_map(|path| SystemPathBuf::from_path_buf(path.clone()).ok()), + ); + + // Add detected package roots. + src_roots.extend( + package_roots + .values() + .filter_map(|package| package.as_deref()) + .filter_map(|path| path.parent()) + .filter_map(|path| SystemPathBuf::from_path_buf(path.to_path_buf()).ok()), + ); let db = ModuleDb::from_src_roots( - src_roots, + src_roots.into_iter().collect(), pyproject_config .settings .analyze diff --git a/crates/ruff/tests/analyze_graph.rs b/crates/ruff/tests/analyze_graph.rs index 483e745d73..509561feef 100644 --- a/crates/ruff/tests/analyze_graph.rs +++ b/crates/ruff/tests/analyze_graph.rs @@ -714,6 +714,121 @@ fn notebook_basic() -> Result<()> { Ok(()) } +/// Test that the `src` configuration option is respected. +/// +/// This is useful for monorepos where there are multiple source directories that need to be +/// included in the module resolution search path. +#[test] +fn src_option() -> Result<()> { + let tempdir = TempDir::new()?; + let root = ChildPath::new(tempdir.path()); + + // Create a lib directory with a package. + root.child("lib") + .child("mylib") + .child("__init__.py") + .write_str("def helper(): pass")?; + + // Create an app directory with a file that imports from mylib. + root.child("app").child("__init__.py").write_str("")?; + root.child("app") + .child("main.py") + .write_str("from mylib import helper")?; + + // Without src configured, the import from mylib won't resolve. + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().arg("app").current_dir(&root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "app/__init__.py": [], + "app/main.py": [] + } + + ----- stderr ----- + "#); + }); + + // With src = ["lib"], the import should resolve. + root.child("ruff.toml").write_str(indoc::indoc! {r#" + src = ["lib"] + "#})?; + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().arg("app").current_dir(&root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "app/__init__.py": [], + "app/main.py": [ + "lib/mylib/__init__.py" + ] + } + + ----- stderr ----- + "#); + }); + + Ok(()) +} + +/// Test that glob patterns in `src` are expanded. +#[test] +fn src_glob_expansion() -> Result<()> { + let tempdir = TempDir::new()?; + let root = ChildPath::new(tempdir.path()); + + // Create multiple lib directories with packages. + root.child("libs") + .child("lib_a") + .child("pkg_a") + .child("__init__.py") + .write_str("def func_a(): pass")?; + root.child("libs") + .child("lib_b") + .child("pkg_b") + .child("__init__.py") + .write_str("def func_b(): pass")?; + + // Create an app that imports from both packages. + root.child("app").child("__init__.py").write_str("")?; + root.child("app") + .child("main.py") + .write_str("from pkg_a import func_a\nfrom pkg_b import func_b")?; + + // Use a glob pattern to include all lib directories. + root.child("ruff.toml").write_str(indoc::indoc! {r#" + src = ["libs/*"] + "#})?; + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().arg("app").current_dir(&root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "app/__init__.py": [], + "app/main.py": [ + "libs/lib_a/pkg_a/__init__.py", + "libs/lib_b/pkg_b/__init__.py" + ] + } + + ----- stderr ----- + "#); + }); + + Ok(()) +} + #[test] fn notebook_with_magic() -> Result<()> { let tempdir = TempDir::new()?;