diff --git a/crates/ruff/src/commands/analyze_graph.rs b/crates/ruff/src/commands/analyze_graph.rs index f040979240..33cc96d6b1 100644 --- a/crates/ruff/src/commands/analyze_graph.rs +++ b/crates/ruff/src/commands/analyze_graph.rs @@ -8,7 +8,7 @@ use ruff_db::system::{SystemPath, SystemPathBuf}; use ruff_graph::{Direction, ImportMap, ModuleDb, ModuleImports}; use ruff_linter::{warn_user, warn_user_once}; use ruff_python_ast::{PySourceType, SourceType}; -use ruff_workspace::resolver::{python_files_in_path, ResolvedFile}; +use ruff_workspace::resolver::{match_exclusion, python_files_in_path, ResolvedFile}; use rustc_hash::FxHashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -74,19 +74,30 @@ pub(crate) fn analyze_graph( continue; }; - let path = resolved_file.into_path(); + let path = resolved_file.path(); let package = path .parent() .and_then(|parent| package_roots.get(parent)) .and_then(Clone::clone); // Resolve the per-file settings. - let settings = resolver.resolve(&path); + let settings = resolver.resolve(path); let string_imports = settings.analyze.detect_string_imports; - let include_dependencies = settings.analyze.include_dependencies.get(&path).cloned(); + let include_dependencies = settings.analyze.include_dependencies.get(path).cloned(); + + // Skip excluded files. + if (settings.file_resolver.force_exclude || !resolved_file.is_root()) + && match_exclusion( + resolved_file.path(), + resolved_file.file_name(), + &settings.analyze.exclude, + ) + { + continue; + } // Ignore non-Python files. - let source_type = match settings.analyze.extension.get(&path) { + let source_type = match settings.analyze.extension.get(path) { None => match SourceType::from(&path) { SourceType::Python(source_type) => source_type, SourceType::Toml(_) => { @@ -106,7 +117,7 @@ pub(crate) fn analyze_graph( warn!("Failed to convert package to system path"); continue; }; - let Ok(path) = SystemPathBuf::from_path_buf(path) else { + let Ok(path) = SystemPathBuf::from_path_buf(resolved_file.into_path()) else { warn!("Failed to convert path to system path"); continue; }; @@ -118,7 +129,7 @@ pub(crate) fn analyze_graph( scope.spawn(move |_| { // Identify any imports via static analysis. let mut imports = - ModuleImports::detect(&path, package.as_deref(), string_imports, &db) + ModuleImports::detect(&db, &path, package.as_deref(), string_imports) .unwrap_or_else(|err| { warn!("Failed to generate import map for {path}: {err}"); ModuleImports::default() diff --git a/crates/ruff/tests/analyze_graph.rs b/crates/ruff/tests/analyze_graph.rs index 33c0b12366..02fb5dfa4f 100644 --- a/crates/ruff/tests/analyze_graph.rs +++ b/crates/ruff/tests/analyze_graph.rs @@ -261,3 +261,44 @@ fn globs() -> Result<()> { Ok(()) } + +#[test] +fn exclude() -> Result<()> { + let tempdir = TempDir::new()?; + let root = ChildPath::new(tempdir.path()); + + root.child("ruff.toml").write_str(indoc::indoc! {r#" + [analyze] + exclude = ["ruff/c.py"] + "#})?; + + root.child("ruff").child("__init__.py").write_str("")?; + root.child("ruff") + .child("a.py") + .write_str(indoc::indoc! {r#" + import ruff.b + "#})?; + root.child("ruff").child("b.py").write_str("")?; + root.child("ruff").child("c.py").write_str("")?; + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().current_dir(&root), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py" + ], + "ruff/b.py": [] + } + + ----- stderr ----- + "###); + }); + + Ok(()) +} diff --git a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap index e5ce1e0541..41be1cd7e7 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap @@ -389,6 +389,7 @@ formatter.docstring_code_format = disabled formatter.docstring_code_line_width = dynamic # Analyze Settings +analyze.exclude = [] analyze.preview = disabled analyze.detect_string_imports = false analyze.extension = ExtensionMapping({}) diff --git a/crates/ruff_graph/src/lib.rs b/crates/ruff_graph/src/lib.rs index 30989a31cb..2b2761b117 100644 --- a/crates/ruff_graph/src/lib.rs +++ b/crates/ruff_graph/src/lib.rs @@ -23,10 +23,10 @@ pub struct ModuleImports(BTreeSet); impl ModuleImports { /// Detect the [`ModuleImports`] for a given Python file. pub fn detect( + db: &ModuleDb, path: &SystemPath, package: Option<&SystemPath>, string_imports: bool, - db: &ModuleDb, ) -> Result { // Read and parse the source code. let file = system_path_to_file(db, path)?; diff --git a/crates/ruff_graph/src/settings.rs b/crates/ruff_graph/src/settings.rs index 03025b1dc6..6cc7365208 100644 --- a/crates/ruff_graph/src/settings.rs +++ b/crates/ruff_graph/src/settings.rs @@ -1,5 +1,5 @@ use ruff_linter::display_settings; -use ruff_linter::settings::types::{ExtensionMapping, PreviewMode}; +use ruff_linter::settings::types::{ExtensionMapping, FilePatternSet, PreviewMode}; use ruff_macros::CacheKey; use std::collections::BTreeMap; use std::fmt; @@ -7,6 +7,7 @@ use std::path::PathBuf; #[derive(Debug, Default, Clone, CacheKey)] pub struct AnalyzeSettings { + pub exclude: FilePatternSet, pub preview: PreviewMode, pub detect_string_imports: bool, pub include_dependencies: BTreeMap)>, @@ -20,6 +21,7 @@ impl fmt::Display for AnalyzeSettings { formatter = f, namespace = "analyze", fields = [ + self.exclude, self.preview, self.detect_string_imports, self.extension | debug, diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 4657784b68..86e556fb03 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -215,6 +215,7 @@ impl Configuration { let analyze_defaults = AnalyzeSettings::default(); let analyze = AnalyzeSettings { + exclude: FilePatternSet::try_from_iter(analyze.exclude.unwrap_or_default())?, preview: analyze_preview, extension: self.extension.clone().unwrap_or_default(), detect_string_imports: analyze @@ -1218,7 +1219,9 @@ impl FormatConfiguration { #[derive(Clone, Debug, Default)] pub struct AnalyzeConfiguration { + pub exclude: Option>, pub preview: Option, + pub direction: Option, pub detect_string_imports: Option, pub include_dependencies: Option)>>, @@ -1228,6 +1231,15 @@ impl AnalyzeConfiguration { #[allow(clippy::needless_pass_by_value)] pub fn from_options(options: AnalyzeOptions, project_root: &Path) -> Result { Ok(Self { + exclude: options.exclude.map(|paths| { + paths + .into_iter() + .map(|pattern| { + let absolute = fs::normalize_path_to(&pattern, project_root); + FilePattern::User(pattern, absolute) + }) + .collect() + }), preview: options.preview.map(PreviewMode::from), direction: options.direction, detect_string_imports: options.detect_string_imports, @@ -1246,6 +1258,7 @@ impl AnalyzeConfiguration { #[allow(clippy::needless_pass_by_value)] pub fn combine(self, config: Self) -> Self { Self { + exclude: self.exclude.or(config.exclude), preview: self.preview.or(config.preview), direction: self.direction.or(config.direction), detect_string_imports: self.detect_string_imports.or(config.detect_string_imports), diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index dc8f4dd9a0..6c9ac41502 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -3320,6 +3320,27 @@ pub struct FormatOptions { #[serde(deny_unknown_fields, rename_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct AnalyzeOptions { + /// A list of file patterns to exclude from analysis in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)). + /// + /// Exclusions are based on globs, and can be either: + /// + /// - Single-path patterns, like `.mypy_cache` (to exclude any directory + /// named `.mypy_cache` in the tree), `foo.py` (to exclude any file named + /// `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). + /// - Relative patterns, like `directory/foo.py` (to exclude that specific + /// file) or `directory/*.py` (to exclude any Python files in + /// `directory`). Note that these paths are relative to the project root + /// (e.g., the directory containing your `pyproject.toml`). + /// + /// For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax). + #[option( + default = r#"[]"#, + value_type = "list[str]", + example = r#" + exclude = ["generated"] + "# + )] + pub exclude: Option>, /// Whether to enable preview mode. When preview mode is enabled, Ruff will expose unstable /// commands. #[option( diff --git a/ruff.schema.json b/ruff.schema.json index c4adb82957..e2e6d59366 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -779,6 +779,16 @@ } ] }, + "exclude": { + "description": "A list of file patterns to exclude from analysis in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "include-dependencies": { "description": "A map from file path to the list of file paths or globs that should be considered dependencies of that file, regardless of whether relevant imports are detected.", "type": [