From 499200fde1b4e8ba0aba3ea2b93d9e3e44e072d7 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Mon, 16 Oct 2023 21:11:08 +0900 Subject: [PATCH] Add `[format|lint].exclude` options Allow excluding files from the formatter and linter. --- crates/flake8_to_ruff/src/converter.rs | 97 ++++++--- crates/ruff_cli/src/args.rs | 10 + crates/ruff_cli/src/commands/add_noqa.rs | 13 +- crates/ruff_cli/src/commands/check.rs | 193 ++++++++++-------- crates/ruff_cli/src/commands/check_stdin.rs | 10 +- crates/ruff_cli/src/commands/format.rs | 38 +++- crates/ruff_cli/src/commands/format_stdin.rs | 10 +- crates/ruff_cli/src/commands/show_files.rs | 11 +- crates/ruff_cli/src/commands/show_settings.rs | 13 +- crates/ruff_cli/src/diagnostics.rs | 11 +- crates/ruff_cli/tests/format.rs | 104 +++++++++- crates/ruff_cli/tests/lint.rs | 121 ++++++++++- crates/ruff_dev/src/format_dev.rs | 23 +-- crates/ruff_linter/src/settings/mod.rs | 4 +- crates/ruff_python_formatter/src/builders.rs | 4 +- crates/ruff_wasm/src/lib.rs | 19 +- crates/ruff_workspace/src/configuration.rs | 130 +++++++----- crates/ruff_workspace/src/options.rs | 66 +++++- crates/ruff_workspace/src/pyproject.rs | 14 +- crates/ruff_workspace/src/resolver.rs | 153 +++++++++----- crates/ruff_workspace/src/settings.rs | 2 + ruff.schema.json | 24 ++- 22 files changed, 774 insertions(+), 296 deletions(-) diff --git a/crates/flake8_to_ruff/src/converter.rs b/crates/flake8_to_ruff/src/converter.rs index e61984463d292..af1fd3c4373b3 100644 --- a/crates/flake8_to_ruff/src/converter.rs +++ b/crates/flake8_to_ruff/src/converter.rs @@ -17,8 +17,8 @@ use ruff_linter::settings::DEFAULT_SELECTORS; use ruff_linter::warn_user; use ruff_workspace::options::{ Flake8AnnotationsOptions, Flake8BugbearOptions, Flake8BuiltinsOptions, Flake8ErrMsgOptions, - Flake8PytestStyleOptions, Flake8QuotesOptions, Flake8TidyImportsOptions, LintOptions, - McCabeOptions, Options, Pep8NamingOptions, PydocstyleOptions, + Flake8PytestStyleOptions, Flake8QuotesOptions, Flake8TidyImportsOptions, LintCommonOptions, + LintOptions, McCabeOptions, Options, Pep8NamingOptions, PydocstyleOptions, }; use ruff_workspace::pyproject::Pyproject; @@ -99,7 +99,7 @@ pub(crate) fn convert( // Parse each supported option. let mut options = Options::default(); - let mut lint_options = LintOptions::default(); + let mut lint_options = LintCommonOptions::default(); let mut flake8_annotations = Flake8AnnotationsOptions::default(); let mut flake8_bugbear = Flake8BugbearOptions::default(); let mut flake8_builtins = Flake8BuiltinsOptions::default(); @@ -433,8 +433,11 @@ pub(crate) fn convert( } } - if lint_options != LintOptions::default() { - options.lint = Some(lint_options); + if lint_options != LintCommonOptions::default() { + options.lint = Some(LintOptions { + common: lint_options, + ..LintOptions::default() + }); } // Create the pyproject.toml. @@ -465,7 +468,9 @@ mod tests { use ruff_linter::rules::flake8_quotes; use ruff_linter::rules::pydocstyle::settings::Convention; use ruff_linter::settings::types::PythonVersion; - use ruff_workspace::options::{Flake8QuotesOptions, LintOptions, Options, PydocstyleOptions}; + use ruff_workspace::options::{ + Flake8QuotesOptions, LintCommonOptions, LintOptions, Options, PydocstyleOptions, + }; use ruff_workspace::pyproject::Pyproject; use crate::converter::DEFAULT_SELECTORS; @@ -475,8 +480,8 @@ mod tests { use super::super::plugin::Plugin; use super::convert; - fn lint_default_options(plugins: impl IntoIterator) -> LintOptions { - LintOptions { + fn lint_default_options(plugins: impl IntoIterator) -> LintCommonOptions { + LintCommonOptions { ignore: Some(vec![]), select: Some( DEFAULT_SELECTORS @@ -486,7 +491,7 @@ mod tests { .sorted_by_key(RuleSelector::prefix_and_code) .collect(), ), - ..LintOptions::default() + ..LintCommonOptions::default() } } @@ -498,7 +503,10 @@ mod tests { None, ); let expected = Pyproject::new(Options { - lint: Some(lint_default_options([])), + lint: Some(LintOptions { + common: lint_default_options([]), + ..LintOptions::default() + }), ..Options::default() }); assert_eq!(actual, expected); @@ -516,7 +524,10 @@ mod tests { ); let expected = Pyproject::new(Options { line_length: Some(LineLength::try_from(100).unwrap()), - lint: Some(lint_default_options([])), + lint: Some(LintOptions { + common: lint_default_options([]), + ..LintOptions::default() + }), ..Options::default() }); assert_eq!(actual, expected); @@ -534,7 +545,10 @@ mod tests { ); let expected = Pyproject::new(Options { line_length: Some(LineLength::try_from(100).unwrap()), - lint: Some(lint_default_options([])), + lint: Some(LintOptions { + common: lint_default_options([]), + ..LintOptions::default() + }), ..Options::default() }); assert_eq!(actual, expected); @@ -551,7 +565,10 @@ mod tests { Some(vec![]), ); let expected = Pyproject::new(Options { - lint: Some(lint_default_options([])), + lint: Some(LintOptions { + common: lint_default_options([]), + ..LintOptions::default() + }), ..Options::default() }); assert_eq!(actual, expected); @@ -569,13 +586,16 @@ mod tests { ); let expected = Pyproject::new(Options { lint: Some(LintOptions { - flake8_quotes: Some(Flake8QuotesOptions { - inline_quotes: Some(flake8_quotes::settings::Quote::Single), - multiline_quotes: None, - docstring_quotes: None, - avoid_escape: None, - }), - ..lint_default_options([]) + common: LintCommonOptions { + flake8_quotes: Some(Flake8QuotesOptions { + inline_quotes: Some(flake8_quotes::settings::Quote::Single), + multiline_quotes: None, + docstring_quotes: None, + avoid_escape: None, + }), + ..lint_default_options([]) + }, + ..LintOptions::default() }), ..Options::default() }); @@ -597,12 +617,15 @@ mod tests { ); let expected = Pyproject::new(Options { lint: Some(LintOptions { - pydocstyle: Some(PydocstyleOptions { - convention: Some(Convention::Numpy), - ignore_decorators: None, - property_decorators: None, - }), - ..lint_default_options([Linter::Pydocstyle.into()]) + common: LintCommonOptions { + pydocstyle: Some(PydocstyleOptions { + convention: Some(Convention::Numpy), + ignore_decorators: None, + property_decorators: None, + }), + ..lint_default_options([Linter::Pydocstyle.into()]) + }, + ..LintOptions::default() }), ..Options::default() }); @@ -621,13 +644,16 @@ mod tests { ); let expected = Pyproject::new(Options { lint: Some(LintOptions { - flake8_quotes: Some(Flake8QuotesOptions { - inline_quotes: Some(flake8_quotes::settings::Quote::Single), - multiline_quotes: None, - docstring_quotes: None, - avoid_escape: None, - }), - ..lint_default_options([Linter::Flake8Quotes.into()]) + common: LintCommonOptions { + flake8_quotes: Some(Flake8QuotesOptions { + inline_quotes: Some(flake8_quotes::settings::Quote::Single), + multiline_quotes: None, + docstring_quotes: None, + avoid_escape: None, + }), + ..lint_default_options([Linter::Flake8Quotes.into()]) + }, + ..LintOptions::default() }), ..Options::default() }); @@ -648,7 +674,10 @@ mod tests { ); let expected = Pyproject::new(Options { target_version: Some(PythonVersion::Py38), - lint: Some(lint_default_options([])), + lint: Some(LintOptions { + common: lint_default_options([]), + ..LintOptions::default() + }), ..Options::default() }); assert_eq!(actual, expected); diff --git a/crates/ruff_cli/src/args.rs b/crates/ruff_cli/src/args.rs index 9732919997f14..d65595bc61e89 100644 --- a/crates/ruff_cli/src/args.rs +++ b/crates/ruff_cli/src/args.rs @@ -366,6 +366,15 @@ pub struct FormatCommand { respect_gitignore: bool, #[clap(long, overrides_with("respect_gitignore"), hide = true)] no_respect_gitignore: bool, + /// List of paths, used to omit files and/or directories from analysis. + #[arg( + long, + value_delimiter = ',', + value_name = "FILE_PATTERN", + help_heading = "File selection" + )] + pub exclude: Option>, + /// Enforce exclusions, even for paths passed to Ruff directly on the command-line. /// Use `--no-force-exclude` to disable. #[arg( @@ -522,6 +531,7 @@ impl FormatCommand { self.respect_gitignore, self.no_respect_gitignore, ), + exclude: self.exclude, preview: resolve_bool_arg(self.preview, self.no_preview).map(PreviewMode::from), force_exclude: resolve_bool_arg(self.force_exclude, self.no_force_exclude), // Unsupported on the formatter CLI, but required on `Overrides`. diff --git a/crates/ruff_cli/src/commands/add_noqa.rs b/crates/ruff_cli/src/commands/add_noqa.rs index 1fd8f5425f979..541e40f2b78cf 100644 --- a/crates/ruff_cli/src/commands/add_noqa.rs +++ b/crates/ruff_cli/src/commands/add_noqa.rs @@ -10,7 +10,7 @@ use ruff_linter::linter::add_noqa_to_path; use ruff_linter::source_kind::SourceKind; use ruff_linter::warn_user_once; use ruff_python_ast::{PySourceType, SourceType}; -use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig}; +use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig, ResolvedFile}; use crate::args::CliOverrides; @@ -36,7 +36,7 @@ pub(crate) fn add_noqa( &paths .iter() .flatten() - .map(ignore::DirEntry::path) + .map(ResolvedFile::path) .collect::>(), pyproject_config, ); @@ -45,14 +45,15 @@ pub(crate) fn add_noqa( let modifications: usize = paths .par_iter() .flatten() - .filter_map(|entry| { - let path = entry.path(); + .filter_map(|resolved_file| { let SourceType::Python(source_type @ (PySourceType::Python | PySourceType::Stub)) = - SourceType::from(path) + SourceType::from(resolved_file.path()) else { return None; }; - let package = path + let path = resolved_file.path(); + let package = resolved_file + .path() .parent() .and_then(|parent| package_roots.get(parent)) .and_then(|package| *package); diff --git a/crates/ruff_cli/src/commands/check.rs b/crates/ruff_cli/src/commands/check.rs index a6ecb66d5d2f7..55e607e2df911 100644 --- a/crates/ruff_cli/src/commands/check.rs +++ b/crates/ruff_cli/src/commands/check.rs @@ -22,7 +22,10 @@ use ruff_linter::{fs, warn_user_once, IOError}; use ruff_python_ast::imports::ImportMap; use ruff_source_file::SourceFileBuilder; use ruff_text_size::{TextRange, TextSize}; -use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig, PyprojectDiscoveryStrategy}; +use ruff_workspace::resolver::{ + match_exclusion, python_files_in_path, PyprojectConfig, PyprojectDiscoveryStrategy, + ResolvedFile, +}; use crate::args::CliOverrides; use crate::cache::{self, Cache}; @@ -42,8 +45,7 @@ pub(crate) fn check( // Collect all the Python files to check. let start = Instant::now(); let (paths, resolver) = python_files_in_path(files, pyproject_config, overrides)?; - let duration = start.elapsed(); - debug!("Identified files to lint in: {:?}", duration); + debug!("Identified files to lint in: {:?}", start.elapsed()); if paths.is_empty() { warn_user_once!("No Python files found under the given path(s)"); @@ -77,7 +79,7 @@ pub(crate) fn check( &paths .iter() .flatten() - .map(ignore::DirEntry::path) + .map(ResolvedFile::path) .collect::>(), pyproject_config, ); @@ -98,95 +100,114 @@ pub(crate) fn check( }); let start = Instant::now(); - let mut diagnostics: Diagnostics = paths - .par_iter() - .map(|entry| { - match entry { - Ok(entry) => { - let path = entry.path(); - let package = path - .parent() - .and_then(|parent| package_roots.get(parent)) - .and_then(|package| *package); - - let settings = resolver.resolve(path, pyproject_config); - - let cache_root = package.unwrap_or_else(|| path.parent().unwrap_or(path)); - let cache = caches.as_ref().and_then(|caches| { - if let Some(cache) = caches.get(&cache_root) { - Some(cache) - } else { - debug!("No cache found for {}", cache_root.display()); - None - } - }); - - lint_path( - path, - package, - &settings.linter, - cache, - noqa, - fix_mode, - unsafe_fixes, + let diagnostics_per_file = paths.par_iter().filter_map(|resolved_file| { + let result = match resolved_file { + Ok(resolved_file) => { + let path = resolved_file.path(); + let package = path + .parent() + .and_then(|parent| package_roots.get(parent)) + .and_then(|package| *package); + + let settings = resolver.resolve(path, pyproject_config); + + if !resolved_file.is_root() + && match_exclusion( + resolved_file.path(), + resolved_file.file_name(), + &settings.linter.exclude, ) - .map_err(|e| { - (Some(path.to_owned()), { - let mut error = e.to_string(); - for cause in e.chain() { - write!(&mut error, "\n Cause: {cause}").unwrap(); - } - error - }) - }) + { + return None; } - Err(e) => Err(( - if let Error::WithPath { path, .. } = e { - Some(path.clone()) + + let cache_root = package.unwrap_or_else(|| path.parent().unwrap_or(path)); + let cache = caches.as_ref().and_then(|caches| { + if let Some(cache) = caches.get(&cache_root) { + Some(cache) } else { + debug!("No cache found for {}", cache_root.display()); None - }, - e.io_error() - .map_or_else(|| e.to_string(), io::Error::to_string), - )), - } - .unwrap_or_else(|(path, message)| { - if let Some(path) = &path { - let settings = resolver.resolve(path, pyproject_config); - if settings.linter.rules.enabled(Rule::IOError) { - let dummy = - SourceFileBuilder::new(path.to_string_lossy().as_ref(), "").finish(); - - Diagnostics::new( - vec![Message::from_diagnostic( - Diagnostic::new(IOError { message }, TextRange::default()), - dummy, - TextSize::default(), - )], - ImportMap::default(), - FxHashMap::default(), - ) - } else { - warn!( - "{}{}{} {message}", - "Failed to lint ".bold(), - fs::relativize_path(path).bold(), - ":".bold() - ); - Diagnostics::default() } + }); + + lint_path( + path, + package, + &settings.linter, + cache, + noqa, + fix_mode, + unsafe_fixes, + ) + .map_err(|e| { + (Some(path.to_path_buf()), { + let mut error = e.to_string(); + for cause in e.chain() { + write!(&mut error, "\n Cause: {cause}").unwrap(); + } + error + }) + }) + } + Err(e) => Err(( + if let Error::WithPath { path, .. } = e { + Some(path.clone()) } else { - warn!("{} {message}", "Encountered error:".bold()); + None + }, + e.io_error() + .map_or_else(|| e.to_string(), io::Error::to_string), + )), + }; + + Some(result.unwrap_or_else(|(path, message)| { + if let Some(path) = &path { + let settings = resolver.resolve(path, pyproject_config); + if settings.linter.rules.enabled(Rule::IOError) { + let dummy = + SourceFileBuilder::new(path.to_string_lossy().as_ref(), "").finish(); + + Diagnostics::new( + vec![Message::from_diagnostic( + Diagnostic::new(IOError { message }, TextRange::default()), + dummy, + TextSize::default(), + )], + ImportMap::default(), + FxHashMap::default(), + ) + } else { + warn!( + "{}{}{} {message}", + "Failed to lint ".bold(), + fs::relativize_path(path).bold(), + ":".bold() + ); Diagnostics::default() } - }) - }) - .reduce(Diagnostics::default, |mut acc, item| { - acc += item; - acc - }); + } else { + warn!("{} {message}", "Encountered error:".bold()); + Diagnostics::default() + } + })) + }); + + // Aggregate the diagnostics of all checked files and count the checked files. + // This can't be a regular for loop because we use `par_iter`. + let (mut all_diagnostics, checked_files) = diagnostics_per_file + .fold( + || (Diagnostics::default(), 0u64), + |(all_diagnostics, checked_files), file_diagnostics| { + (all_diagnostics + file_diagnostics, checked_files + 1) + }, + ) + .reduce( + || (Diagnostics::default(), 0u64), + |a, b| (a.0 + b.0, a.1 + b.1), + ); - diagnostics.messages.sort(); + all_diagnostics.messages.sort(); // Store the caches. if let Some(caches) = caches { @@ -196,9 +217,9 @@ pub(crate) fn check( } let duration = start.elapsed(); - debug!("Checked {:?} files in: {:?}", paths.len(), duration); + debug!("Checked {:?} files in: {:?}", checked_files, duration); - Ok(diagnostics) + Ok(all_diagnostics) } /// Wraps [`lint_path`](crate::diagnostics::lint_path) in a [`catch_unwind`](std::panic::catch_unwind) and emits diff --git a/crates/ruff_cli/src/commands/check_stdin.rs b/crates/ruff_cli/src/commands/check_stdin.rs index ab15f61439f43..67f01cd0e8d3a 100644 --- a/crates/ruff_cli/src/commands/check_stdin.rs +++ b/crates/ruff_cli/src/commands/check_stdin.rs @@ -4,7 +4,7 @@ use anyhow::Result; use ruff_linter::packaging; use ruff_linter::settings::flags; -use ruff_workspace::resolver::{python_file_at_path, PyprojectConfig}; +use ruff_workspace::resolver::{match_exclusion, python_file_at_path, PyprojectConfig}; use crate::args::CliOverrides; use crate::diagnostics::{lint_stdin, Diagnostics}; @@ -22,6 +22,14 @@ pub(crate) fn check_stdin( if !python_file_at_path(filename, pyproject_config, overrides)? { return Ok(Diagnostics::default()); } + + let lint_settings = &pyproject_config.settings.linter; + if filename + .file_name() + .is_some_and(|name| match_exclusion(filename, name, &lint_settings.exclude)) + { + return Ok(Diagnostics::default()); + } } let package_root = filename.and_then(Path::parent).and_then(|path| { packaging::detect_package_root(path, &pyproject_config.settings.linter.namespace_packages) diff --git a/crates/ruff_cli/src/commands/format.rs b/crates/ruff_cli/src/commands/format.rs index bd6ff4327ba8c..486d9d2bbc6a0 100644 --- a/crates/ruff_cli/src/commands/format.rs +++ b/crates/ruff_cli/src/commands/format.rs @@ -20,7 +20,7 @@ use ruff_linter::warn_user_once; use ruff_python_ast::{PySourceType, SourceType}; use ruff_python_formatter::{format_module_source, FormatModuleError}; use ruff_text_size::{TextLen, TextRange, TextSize}; -use ruff_workspace::resolver::python_files_in_path; +use ruff_workspace::resolver::{match_exclusion, python_files_in_path}; use ruff_workspace::FormatterSettings; use crate::args::{CliOverrides, FormatArguments}; @@ -61,26 +61,42 @@ pub(crate) fn format( } let start = Instant::now(); - let (results, errors): (Vec<_>, Vec<_>) = paths + let (mut results, errors): (Vec<_>, Vec<_>) = paths .into_par_iter() .filter_map(|entry| { match entry { - Ok(entry) => { - let path = entry.into_path(); - + Ok(resolved_file) => { + let path = resolved_file.path(); let SourceType::Python(source_type) = SourceType::from(&path) else { // Ignore any non-Python files. return None; }; - let resolved_settings = resolver.resolve(&path, &pyproject_config); + let resolved_settings = resolver.resolve(path, &pyproject_config); + + // Ignore files that are excluded from formatting + if !resolved_file.is_root() + && match_exclusion( + path, + resolved_file.file_name(), + &resolved_settings.formatter.exclude, + ) + { + return None; + } Some( match catch_unwind(|| { - format_path(&path, &resolved_settings.formatter, source_type, mode) + format_path(path, &resolved_settings.formatter, source_type, mode) }) { - Ok(inner) => inner.map(|result| FormatPathResult { path, result }), - Err(error) => Err(FormatCommandError::Panic(Some(path), error)), + Ok(inner) => inner.map(|result| FormatPathResult { + path: resolved_file.into_path(), + result, + }), + Err(error) => Err(FormatCommandError::Panic( + Some(resolved_file.into_path()), + error, + )), }, ) } @@ -104,6 +120,8 @@ pub(crate) fn format( error!("{error}"); } + results.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + let summary = FormatSummary::new(results.as_slice(), mode); // Report on the formatting changes. @@ -137,7 +155,7 @@ pub(crate) fn format( } /// Format the file at the given [`Path`]. -#[tracing::instrument(skip_all, fields(path = %path.display()))] +#[tracing::instrument(level="debug", skip_all, fields(path = %path.display()))] fn format_path( path: &Path, settings: &FormatterSettings, diff --git a/crates/ruff_cli/src/commands/format_stdin.rs b/crates/ruff_cli/src/commands/format_stdin.rs index 060e92491f3f6..1709998740d85 100644 --- a/crates/ruff_cli/src/commands/format_stdin.rs +++ b/crates/ruff_cli/src/commands/format_stdin.rs @@ -6,7 +6,7 @@ use log::error; use ruff_linter::source_kind::SourceKind; use ruff_python_ast::{PySourceType, SourceType}; -use ruff_workspace::resolver::python_file_at_path; +use ruff_workspace::resolver::{match_exclusion, python_file_at_path}; use ruff_workspace::FormatterSettings; use crate::args::{CliOverrides, FormatArguments}; @@ -33,6 +33,14 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &CliOverrides) -> R if !python_file_at_path(filename, &pyproject_config, overrides)? { return Ok(ExitStatus::Success); } + + let format_settings = &pyproject_config.settings.formatter; + if filename + .file_name() + .is_some_and(|name| match_exclusion(filename, name, &format_settings.exclude)) + { + return Ok(ExitStatus::Success); + } } let path = cli.stdin_filename.as_deref(); diff --git a/crates/ruff_cli/src/commands/show_files.rs b/crates/ruff_cli/src/commands/show_files.rs index f92998e57a855..201c97f75de20 100644 --- a/crates/ruff_cli/src/commands/show_files.rs +++ b/crates/ruff_cli/src/commands/show_files.rs @@ -5,7 +5,7 @@ use anyhow::Result; use itertools::Itertools; use ruff_linter::warn_user_once; -use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig}; +use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig, ResolvedFile}; use crate::args::CliOverrides; @@ -25,12 +25,13 @@ pub(crate) fn show_files( } // Print the list of files. - for entry in paths - .iter() + for path in paths + .into_iter() .flatten() - .sorted_by(|a, b| a.path().cmp(b.path())) + .map(ResolvedFile::into_path) + .sorted_unstable() { - writeln!(writer, "{}", entry.path().to_string_lossy())?; + writeln!(writer, "{}", path.to_string_lossy())?; } Ok(()) diff --git a/crates/ruff_cli/src/commands/show_settings.rs b/crates/ruff_cli/src/commands/show_settings.rs index 163981a523150..baeed55a08f36 100644 --- a/crates/ruff_cli/src/commands/show_settings.rs +++ b/crates/ruff_cli/src/commands/show_settings.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use anyhow::{bail, Result}; use itertools::Itertools; -use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig}; +use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig, ResolvedFile}; use crate::args::CliOverrides; @@ -19,16 +19,17 @@ pub(crate) fn show_settings( let (paths, resolver) = python_files_in_path(files, pyproject_config, overrides)?; // Print the list of files. - let Some(entry) = paths - .iter() + let Some(path) = paths + .into_iter() .flatten() - .sorted_by(|a, b| a.path().cmp(b.path())) + .map(ResolvedFile::into_path) + .sorted_unstable() .next() else { bail!("No files found under the given path"); }; - let path = entry.path(); - let settings = resolver.resolve(path, pyproject_config); + + let settings = resolver.resolve(&path, pyproject_config); writeln!(writer, "Resolved settings for: {path:?}")?; if let Some(settings_path) = pyproject_config.path.as_ref() { diff --git a/crates/ruff_cli/src/diagnostics.rs b/crates/ruff_cli/src/diagnostics.rs index c4ea03c55ba81..91b8be7bd58f4 100644 --- a/crates/ruff_cli/src/diagnostics.rs +++ b/crates/ruff_cli/src/diagnostics.rs @@ -2,7 +2,7 @@ use std::fs::File; use std::io; -use std::ops::AddAssign; +use std::ops::{Add, AddAssign}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::Path; @@ -142,6 +142,15 @@ impl Diagnostics { } } +impl Add for Diagnostics { + type Output = Diagnostics; + + fn add(mut self, other: Self) -> Self::Output { + self += other; + self + } +} + impl AddAssign for Diagnostics { fn add_assign(&mut self, other: Self) { self.messages.extend(other.messages); diff --git a/crates/ruff_cli/tests/format.rs b/crates/ruff_cli/tests/format.rs index fc740e610b87f..83225ed57bb52 100644 --- a/crates/ruff_cli/tests/format.rs +++ b/crates/ruff_cli/tests/format.rs @@ -13,7 +13,7 @@ const BIN_NAME: &str = "ruff"; #[test] fn default_options() { assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) - .args(["format", "--isolated"]) + .args(["format", "--isolated", "--stdin-filename", "test.py"]) .arg("-") .pass_stdin(r#" def foo(arg1, arg2,): @@ -88,6 +88,108 @@ if condition: Ok(()) } +#[test] +fn exclude() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +extend-exclude = ["out"] + +[format] +exclude = ["test.py", "generated.py"] +"#, + )?; + + fs::write( + tempdir.path().join("main.py"), + r#" +from test import say_hy + +if __name__ == "__main__": + say_hy("dear Ruff contributor") +"#, + )?; + + // Excluded file but passed to the CLI directly, should be formatted + let test_path = tempdir.path().join("test.py"); + fs::write( + &test_path, + r#" +def say_hy(name: str): + print(f"Hy {name}")"#, + )?; + + fs::write( + tempdir.path().join("generated.py"), + r#"NUMBERS = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +] +OTHER = "OTHER" +"#, + )?; + + let out_dir = tempdir.path().join("out"); + fs::create_dir(&out_dir)?; + + fs::write(out_dir.join("a.py"), "a = a")?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .current_dir(tempdir.path()) + .args(["format", "--check", "--config"]) + .arg(ruff_toml.file_name().unwrap()) + // Explicitly pass test.py, should be formatted regardless of it being excluded by format.exclude + .arg(test_path.file_name().unwrap()) + // Format all other files in the directory, should respect the `exclude` and `format.exclude` options + .arg("."), @r###" + success: false + exit_code: 1 + ----- stdout ----- + Would reformat: main.py + Would reformat: test.py + 2 files would be reformatted + + ----- stderr ----- + warning: `ruff format` is not yet stable, and subject to change in future versions. + "###); + Ok(()) +} + +#[test] +fn exclude_stdin() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +extend-select = ["B", "Q"] + +[format] +exclude = ["generated.py"] +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .current_dir(tempdir.path()) + .args(["format", "--config", &ruff_toml.file_name().unwrap().to_string_lossy(), "--stdin-filename", "generated.py", "-"]) + .pass_stdin(r#" +from test import say_hy + +if __name__ == '__main__': + say_hy("dear Ruff contributor") +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `ruff format` is not yet stable, and subject to change in future versions. + "###); + Ok(()) +} + #[test] fn format_option_inheritance() -> Result<()> { let tempdir = TempDir::new()?; diff --git a/crates/ruff_cli/tests/lint.rs b/crates/ruff_cli/tests/lint.rs index bef1cb7f26175..35e2226f9fea4 100644 --- a/crates/ruff_cli/tests/lint.rs +++ b/crates/ruff_cli/tests/lint.rs @@ -31,14 +31,15 @@ inline-quotes = "single" .args(STDIN_BASE_OPTIONS) .arg("--config") .arg(&ruff_toml) + .args(["--stdin-filename", "test.py"]) .arg("-") .pass_stdin(r#"a = "abcba".strip("aba")"#), @r###" success: false exit_code: 1 ----- stdout ----- - -:1:5: Q000 [*] Double quotes found but single quotes preferred - -:1:5: B005 Using `.strip()` with multi-character strings is misleading - -:1:19: Q000 [*] Double quotes found but single quotes preferred + test.py:1:5: Q000 [*] Double quotes found but single quotes preferred + test.py:1:5: B005 Using `.strip()` with multi-character strings is misleading + test.py:1:19: Q000 [*] Double quotes found but single quotes preferred Found 3 errors. [*] 2 fixable with the `--fix` option. @@ -155,3 +156,117 @@ inline-quotes = "single" "###); Ok(()) } + +#[test] +fn exclude() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +extend-select = ["B", "Q"] +extend-exclude = ["out"] + +[lint] +exclude = ["test.py", "generated.py"] + +[lint.flake8-quotes] +inline-quotes = "single" +"#, + )?; + + fs::write( + tempdir.path().join("main.py"), + r#" +from test import say_hy + +if __name__ == "__main__": + say_hy("dear Ruff contributor") +"#, + )?; + + // Excluded file but passed to the CLI directly, should be linted + let test_path = tempdir.path().join("test.py"); + fs::write( + &test_path, + r#" +def say_hy(name: str): + print(f"Hy {name}")"#, + )?; + + fs::write( + tempdir.path().join("generated.py"), + r#"NUMBERS = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 +] +OTHER = "OTHER" +"#, + )?; + + let out_dir = tempdir.path().join("out"); + fs::create_dir(&out_dir)?; + + fs::write(out_dir.join("a.py"), r#"a = "a""#)?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .current_dir(tempdir.path()) + .arg("check") + .args(STDIN_BASE_OPTIONS) + .args(["--config", &ruff_toml.file_name().unwrap().to_string_lossy()]) + // Explicitly pass test.py, should be linted regardless of it being excluded by lint.exclude + .arg(test_path.file_name().unwrap()) + // Lint all other files in the directory, should respect the `exclude` and `lint.exclude` options + .arg("."), @r###" + success: false + exit_code: 1 + ----- stdout ----- + main.py:4:16: Q000 [*] Double quotes found but single quotes preferred + main.py:5:12: Q000 [*] Double quotes found but single quotes preferred + test.py:3:15: Q000 [*] Double quotes found but single quotes preferred + Found 3 errors. + [*] 3 fixable with the `--fix` option. + + ----- stderr ----- + "###); + Ok(()) +} + +#[test] +fn exclude_stdin() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +extend-select = ["B", "Q"] + +[lint] +exclude = ["generated.py"] + +[lint.flake8-quotes] +inline-quotes = "single" +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .current_dir(tempdir.path()) + .arg("check") + .args(STDIN_BASE_OPTIONS) + .args(["--config", &ruff_toml.file_name().unwrap().to_string_lossy()]) + .args(["--stdin-filename", "generated.py"]) + .arg("-") + .pass_stdin(r#" +from test import say_hy + +if __name__ == "__main__": + say_hy("dear Ruff contributor") +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + Ok(()) +} diff --git a/crates/ruff_dev/src/format_dev.rs b/crates/ruff_dev/src/format_dev.rs index ad465cf3b3be9..191442278fcd7 100644 --- a/crates/ruff_dev/src/format_dev.rs +++ b/crates/ruff_dev/src/format_dev.rs @@ -11,7 +11,6 @@ use std::{fmt, fs, io, iter}; use anyhow::{bail, format_err, Context, Error}; use clap::{CommandFactory, FromArgMatches}; -use ignore::DirEntry; use imara_diff::intern::InternedInput; use imara_diff::sink::Counter; use imara_diff::{diff, Algorithm}; @@ -36,14 +35,14 @@ use ruff_linter::settings::types::{FilePattern, FilePatternSet}; use ruff_python_formatter::{ format_module_source, FormatModuleError, MagicTrailingComma, PyFormatOptions, }; -use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig, Resolver}; +use ruff_workspace::resolver::{python_files_in_path, PyprojectConfig, ResolvedFile, Resolver}; /// Find files that ruff would check so we can format them. Adapted from `ruff_cli`. #[allow(clippy::type_complexity)] fn ruff_check_paths( dirs: &[PathBuf], ) -> anyhow::Result<( - Vec>, + Vec>, Resolver, PyprojectConfig, )> { @@ -467,9 +466,9 @@ fn format_dev_project( let iter = { paths.into_par_iter() }; #[cfg(feature = "singlethreaded")] let iter = { paths.into_iter() }; - iter.map(|dir_entry| { + iter.map(|path| { let result = format_dir_entry( - dir_entry, + path, stability_check, write, &black_options, @@ -527,24 +526,20 @@ fn format_dev_project( /// Error handling in between walkdir and `format_dev_file` fn format_dir_entry( - dir_entry: Result, + resolved_file: Result, stability_check: bool, write: bool, options: &BlackOptions, resolver: &Resolver, pyproject_config: &PyprojectConfig, ) -> anyhow::Result<(Result, PathBuf), Error> { - let dir_entry = match dir_entry.context("Iterating the files in the repository failed") { - Ok(dir_entry) => dir_entry, - Err(err) => return Err(err), - }; - let file = dir_entry.path().to_path_buf(); + let resolved_file = resolved_file.context("Iterating the files in the repository failed")?; // For some reason it does not filter in the beginning - if dir_entry.file_name() == "pyproject.toml" { - return Ok((Ok(Statistics::default()), file)); + if resolved_file.file_name() == "pyproject.toml" { + return Ok((Ok(Statistics::default()), resolved_file.into_path())); } - let path = dir_entry.path().to_path_buf(); + let path = resolved_file.into_path(); let mut options = options.to_py_format_options(&path); let settings = resolver.resolve(&path, pyproject_config); diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index 54ae2b0cf1c13..699288a4e0767 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -23,7 +23,7 @@ use crate::rules::{ flake8_tidy_imports, flake8_type_checking, flake8_unused_arguments, isort, mccabe, pep8_naming, pycodestyle, pydocstyle, pyflakes, pylint, pyupgrade, }; -use crate::settings::types::{PerFileIgnore, PythonVersion}; +use crate::settings::types::{FilePatternSet, PerFileIgnore, PythonVersion}; use crate::{codes, RuleSelector}; use super::line_width::{LineLength, TabSize}; @@ -38,6 +38,7 @@ pub mod types; #[derive(Debug, CacheKey)] pub struct LinterSettings { + pub exclude: FilePatternSet, pub project_root: PathBuf, pub rules: RuleTable, @@ -131,6 +132,7 @@ impl LinterSettings { pub fn new(project_root: &Path) -> Self { Self { + exclude: FilePatternSet::default(), target_version: PythonVersion::default(), project_root: project_root.to_path_buf(), rules: DEFAULT_SELECTORS diff --git a/crates/ruff_python_formatter/src/builders.rs b/crates/ruff_python_formatter/src/builders.rs index fa4e30ea79e1b..581fdc5194a66 100644 --- a/crates/ruff_python_formatter/src/builders.rs +++ b/crates/ruff_python_formatter/src/builders.rs @@ -150,7 +150,7 @@ impl<'fmt, 'ast, 'buf> JoinCommaSeparatedBuilder<'fmt, 'ast, 'buf> { N: Ranged, Separator: Format>, { - self.result = self.result.and_then(|_| { + self.result = self.result.and_then(|()| { if self.entries.is_one_or_more() { write!(self.fmt, [token(","), separator])?; } @@ -190,7 +190,7 @@ impl<'fmt, 'ast, 'buf> JoinCommaSeparatedBuilder<'fmt, 'ast, 'buf> { } pub(crate) fn finish(&mut self) -> FormatResult<()> { - self.result.and_then(|_| { + self.result.and_then(|()| { if let Some(last_end) = self.entries.position() { let magic_trailing_comma = has_magic_trailing_comma( TextRange::new(last_end, self.sequence_end), diff --git a/crates/ruff_wasm/src/lib.rs b/crates/ruff_wasm/src/lib.rs index eb4973eb50b82..f2fcc8055263f 100644 --- a/crates/ruff_wasm/src/lib.rs +++ b/crates/ruff_wasm/src/lib.rs @@ -22,7 +22,7 @@ use ruff_python_trivia::CommentRanges; use ruff_source_file::{Locator, SourceLocation}; use ruff_text_size::Ranged; use ruff_workspace::configuration::Configuration; -use ruff_workspace::options::{FormatOptions, LintOptions, Options}; +use ruff_workspace::options::{FormatOptions, LintCommonOptions, LintOptions, Options}; use ruff_workspace::Settings; #[wasm_bindgen(typescript_custom_section)] @@ -130,13 +130,16 @@ impl Workspace { target_version: Some(PythonVersion::default()), lint: Some(LintOptions { - allowed_confusables: Some(Vec::default()), - dummy_variable_rgx: Some(DUMMY_VARIABLE_RGX.as_str().to_string()), - ignore: Some(Vec::default()), - select: Some(DEFAULT_SELECTORS.to_vec()), - extend_fixable: Some(Vec::default()), - extend_select: Some(Vec::default()), - external: Some(Vec::default()), + common: LintCommonOptions { + allowed_confusables: Some(Vec::default()), + dummy_variable_rgx: Some(DUMMY_VARIABLE_RGX.as_str().to_string()), + ignore: Some(Vec::default()), + select: Some(DEFAULT_SELECTORS.to_vec()), + extend_fixable: Some(Vec::default()), + extend_select: Some(Vec::default()), + external: Some(Vec::default()), + ..LintCommonOptions::default() + }, ..LintOptions::default() }), diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 2475a757029ee..0dd882c7b78de 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -157,6 +157,7 @@ impl Configuration { let format_defaults = FormatterSettings::default(); // TODO(micha): Support changing the tab-width but disallow changing the number of spaces let formatter = FormatterSettings { + exclude: FilePatternSet::try_from_iter(format.exclude.unwrap_or_default())?, preview: match format.preview.unwrap_or(preview) { PreviewMode::Disabled => ruff_python_formatter::PreviewMode::Disabled, PreviewMode::Enabled => ruff_python_formatter::PreviewMode::Enabled, @@ -204,6 +205,7 @@ impl Configuration { linter: LinterSettings { rules: lint.as_rule_table(preview), + exclude: FilePatternSet::try_from_iter(lint.exclude.unwrap_or_default())?, target_version, project_root: project_root.to_path_buf(), allowed_confusables: lint @@ -365,10 +367,14 @@ impl Configuration { } pub fn from_options(options: Options, project_root: &Path) -> Result { - let lint = if let Some(lint) = options.lint { - lint.combine(options.lint_top_level) + let lint = if let Some(mut lint) = options.lint { + lint.common = lint.common.combine(options.lint_top_level); + lint } else { - options.lint_top_level + LintOptions { + common: options.lint_top_level, + ..LintOptions::default() + } }; Ok(Self { @@ -455,7 +461,10 @@ impl Configuration { target_version: options.target_version, lint: LintConfiguration::from_options(lint, project_root)?, - format: FormatConfiguration::from_options(options.format.unwrap_or_default())?, + format: FormatConfiguration::from_options( + options.format.unwrap_or_default(), + project_root, + )?, }) } @@ -501,6 +510,8 @@ impl Configuration { #[derive(Debug, Default)] pub struct LintConfiguration { + pub exclude: Option>, + // Rule selection pub extend_per_file_ignores: Vec, pub per_file_ignores: Option>, @@ -550,33 +561,47 @@ pub struct LintConfiguration { impl LintConfiguration { fn from_options(options: LintOptions, project_root: &Path) -> Result { Ok(LintConfiguration { + exclude: options.exclude.map(|paths| { + paths + .into_iter() + .map(|pattern| { + let absolute = fs::normalize_path_to(&pattern, project_root); + FilePattern::User(pattern, absolute) + }) + .collect() + }), + rule_selections: vec![RuleSelection { - select: options.select, + select: options.common.select, ignore: options + .common .ignore .into_iter() .flatten() - .chain(options.extend_ignore.into_iter().flatten()) + .chain(options.common.extend_ignore.into_iter().flatten()) .collect(), - extend_select: options.extend_select.unwrap_or_default(), - fixable: options.fixable, + extend_select: options.common.extend_select.unwrap_or_default(), + fixable: options.common.fixable, unfixable: options + .common .unfixable .into_iter() .flatten() - .chain(options.extend_unfixable.into_iter().flatten()) + .chain(options.common.extend_unfixable.into_iter().flatten()) .collect(), - extend_fixable: options.extend_fixable.unwrap_or_default(), + extend_fixable: options.common.extend_fixable.unwrap_or_default(), }], - extend_safe_fixes: options.extend_safe_fixes.unwrap_or_default(), - extend_unsafe_fixes: options.extend_unsafe_fixes.unwrap_or_default(), - allowed_confusables: options.allowed_confusables, + extend_safe_fixes: options.common.extend_safe_fixes.unwrap_or_default(), + extend_unsafe_fixes: options.common.extend_unsafe_fixes.unwrap_or_default(), + allowed_confusables: options.common.allowed_confusables, dummy_variable_rgx: options + .common .dummy_variable_rgx .map(|pattern| Regex::new(&pattern)) .transpose() .map_err(|e| anyhow!("Invalid `dummy-variable-rgx` value: {e}"))?, extend_per_file_ignores: options + .common .extend_per_file_ignores .map(|per_file_ignores| { per_file_ignores @@ -587,10 +612,10 @@ impl LintConfiguration { .collect() }) .unwrap_or_default(), - external: options.external, - ignore_init_module_imports: options.ignore_init_module_imports, - explicit_preview_rules: options.explicit_preview_rules, - per_file_ignores: options.per_file_ignores.map(|per_file_ignores| { + external: options.common.external, + ignore_init_module_imports: options.common.ignore_init_module_imports, + explicit_preview_rules: options.common.explicit_preview_rules, + per_file_ignores: options.common.per_file_ignores.map(|per_file_ignores| { per_file_ignores .into_iter() .map(|(pattern, prefixes)| { @@ -598,34 +623,34 @@ impl LintConfiguration { }) .collect() }), - task_tags: options.task_tags, - logger_objects: options.logger_objects, - typing_modules: options.typing_modules, + task_tags: options.common.task_tags, + logger_objects: options.common.logger_objects, + typing_modules: options.common.typing_modules, // Plugins - flake8_annotations: options.flake8_annotations, - flake8_bandit: options.flake8_bandit, - flake8_bugbear: options.flake8_bugbear, - flake8_builtins: options.flake8_builtins, - flake8_comprehensions: options.flake8_comprehensions, - flake8_copyright: options.flake8_copyright, - flake8_errmsg: options.flake8_errmsg, - flake8_gettext: options.flake8_gettext, - flake8_implicit_str_concat: options.flake8_implicit_str_concat, - flake8_import_conventions: options.flake8_import_conventions, - flake8_pytest_style: options.flake8_pytest_style, - flake8_quotes: options.flake8_quotes, - flake8_self: options.flake8_self, - flake8_tidy_imports: options.flake8_tidy_imports, - flake8_type_checking: options.flake8_type_checking, - flake8_unused_arguments: options.flake8_unused_arguments, - isort: options.isort, - mccabe: options.mccabe, - pep8_naming: options.pep8_naming, - pycodestyle: options.pycodestyle, - pydocstyle: options.pydocstyle, - pyflakes: options.pyflakes, - pylint: options.pylint, - pyupgrade: options.pyupgrade, + flake8_annotations: options.common.flake8_annotations, + flake8_bandit: options.common.flake8_bandit, + flake8_bugbear: options.common.flake8_bugbear, + flake8_builtins: options.common.flake8_builtins, + flake8_comprehensions: options.common.flake8_comprehensions, + flake8_copyright: options.common.flake8_copyright, + flake8_errmsg: options.common.flake8_errmsg, + flake8_gettext: options.common.flake8_gettext, + flake8_implicit_str_concat: options.common.flake8_implicit_str_concat, + flake8_import_conventions: options.common.flake8_import_conventions, + flake8_pytest_style: options.common.flake8_pytest_style, + flake8_quotes: options.common.flake8_quotes, + flake8_self: options.common.flake8_self, + flake8_tidy_imports: options.common.flake8_tidy_imports, + flake8_type_checking: options.common.flake8_type_checking, + flake8_unused_arguments: options.common.flake8_unused_arguments, + isort: options.common.isort, + mccabe: options.common.mccabe, + pep8_naming: options.common.pep8_naming, + pycodestyle: options.common.pycodestyle, + pydocstyle: options.common.pydocstyle, + pyflakes: options.common.pyflakes, + pylint: options.common.pylint, + pyupgrade: options.common.pyupgrade, }) } @@ -861,6 +886,7 @@ impl LintConfiguration { #[must_use] pub fn combine(self, config: Self) -> Self { Self { + exclude: self.exclude.or(config.exclude), rule_selections: config .rule_selections .into_iter() @@ -935,21 +961,28 @@ impl LintConfiguration { #[derive(Debug, Default)] pub struct FormatConfiguration { + pub exclude: Option>, pub preview: Option, pub indent_style: Option, - pub quote_style: Option, - pub magic_trailing_comma: Option, - pub line_ending: Option, } impl FormatConfiguration { #[allow(clippy::needless_pass_by_value)] - pub fn from_options(options: FormatOptions) -> Result { + pub fn from_options(options: FormatOptions, 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), indent_style: options.indent_style, quote_style: options.quote_style, @@ -968,6 +1001,7 @@ impl FormatConfiguration { #[allow(clippy::needless_pass_by_value)] pub fn combine(self, other: Self) -> Self { Self { + exclude: self.exclude.or(other.exclude), preview: self.preview.or(other.preview), indent_style: self.indent_style.or(other.indent_style), quote_style: self.quote_style.or(other.quote_style), diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 9250f4c166683..1f91a22f78b7e 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -153,7 +153,7 @@ pub struct Options { pub preview: Option, // File resolver options - /// A list of file patterns to exclude from linting. + /// A list of file patterns to exclude from formatting and linting. /// /// Exclusions are based on globs, and can be either: /// @@ -178,7 +178,7 @@ pub struct Options { )] pub exclude: Option>, - /// A list of file patterns to omit from linting, in addition to those + /// A list of file patterns to omit from formatting and linting, in addition to those /// specified by `exclude`. /// /// Exclusions are based on globs, and can be either: @@ -377,13 +377,46 @@ pub struct Options { /// The lint sections specified at the top level. #[serde(flatten)] - pub lint_top_level: LintOptions, + pub lint_top_level: LintCommonOptions, /// Options to configure code formatting. #[option_group] pub format: Option, } +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, PartialEq, Eq, Default, OptionsMetadata, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct LintOptions { + #[serde(flatten)] + pub common: LintCommonOptions, + + /// A list of file patterns to exclude from linting 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>, +} + +// Note: This struct should be inlined into [`LintOptions`] once support for the top-level lint settings +// is removed. + /// Experimental section to configure Ruff's linting. This new section will eventually /// replace the top-level linting options. /// @@ -393,7 +426,7 @@ pub struct Options { Debug, PartialEq, Eq, Default, OptionsMetadata, CombineOptions, Serialize, Deserialize, )] #[serde(deny_unknown_fields, rename_all = "kebab-case")] -pub struct LintOptions { +pub struct LintCommonOptions { /// A list of allowed "confusable" Unicode characters to ignore when /// enforcing `RUF001`, `RUF002`, and `RUF003`. #[option( @@ -2469,6 +2502,31 @@ impl PyUpgradeOptions { #[serde(deny_unknown_fields, rename_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct FormatOptions { + /// A list of file patterns to exclude from formatting 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). + /// + /// Note that you'll typically want to use + /// [`extend-exclude`](#extend-exclude) to modify the excluded paths. + #[option( + default = r#"[]"#, + value_type = "list[str]", + example = r#" + exclude = ["generated"] + "# + )] + pub exclude: Option>, + /// Whether to enable the unstable preview style formatting. #[option( default = "false", diff --git a/crates/ruff_workspace/src/pyproject.rs b/crates/ruff_workspace/src/pyproject.rs index 6488459697226..38b7811dd634f 100644 --- a/crates/ruff_workspace/src/pyproject.rs +++ b/crates/ruff_workspace/src/pyproject.rs @@ -161,7 +161,7 @@ mod tests { use ruff_linter::line_width::LineLength; use ruff_linter::settings::types::PatternPrefixPair; - use crate::options::{LintOptions, Options}; + use crate::options::{LintCommonOptions, Options}; use crate::pyproject::{find_settings_toml, parse_pyproject_toml, Pyproject, Tools}; use crate::tests::test_resource_path; @@ -236,9 +236,9 @@ select = ["E501"] pyproject.tool, Some(Tools { ruff: Some(Options { - lint_top_level: LintOptions { + lint_top_level: LintCommonOptions { select: Some(vec![codes::Pycodestyle::E501.into()]), - ..LintOptions::default() + ..LintCommonOptions::default() }, ..Options::default() }) @@ -257,10 +257,10 @@ ignore = ["E501"] pyproject.tool, Some(Tools { ruff: Some(Options { - lint_top_level: LintOptions { + lint_top_level: LintCommonOptions { extend_select: Some(vec![codes::Ruff::_100.into()]), ignore: Some(vec![codes::Pycodestyle::E501.into()]), - ..LintOptions::default() + ..LintCommonOptions::default() }, ..Options::default() }) @@ -315,12 +315,12 @@ other-attribute = 1 "with_excluded_file/other_excluded_file.py".to_string(), ]), - lint_top_level: LintOptions { + lint_top_level: LintCommonOptions { per_file_ignores: Some(FxHashMap::from_iter([( "__init__.py".to_string(), vec![codes::Pyflakes::_401.into()] )])), - ..LintOptions::default() + ..LintCommonOptions::default() }, ..Options::default() } diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs index 7516a6c40d7dc..aeca97709b0c6 100644 --- a/crates/ruff_workspace/src/resolver.rs +++ b/crates/ruff_workspace/src/resolver.rs @@ -1,13 +1,15 @@ //! Discover Python files, and their corresponding [`Settings`], from the //! filesystem. +use std::cmp::Ordering; use std::collections::BTreeMap; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::sync::RwLock; use anyhow::Result; use anyhow::{anyhow, bail}; -use ignore::{DirEntry, WalkBuilder, WalkState}; +use ignore::{WalkBuilder, WalkState}; use itertools::Itertools; use log::debug; use path_absolutize::path_dedot; @@ -276,7 +278,7 @@ pub fn python_files_in_path( paths: &[PathBuf], pyproject_config: &PyprojectConfig, transformer: &dyn ConfigurationTransformer, -) -> Result<(Vec>, Resolver)> { +) -> Result<(Vec>, Resolver)> { // Normalize every path (e.g., convert from relative to absolute). let mut paths: Vec = paths.iter().map(fs::normalize_path).unique().collect(); @@ -305,13 +307,12 @@ pub fn python_files_in_path( } } + let (first_path, rest_paths) = paths + .split_first() + .ok_or_else(|| anyhow!("Expected at least one path to search for Python files"))?; // Create the `WalkBuilder`. - let mut builder = WalkBuilder::new( - paths - .get(0) - .ok_or_else(|| anyhow!("Expected at least one path to search for Python files"))?, - ); - for path in &paths[1..] { + let mut builder = WalkBuilder::new(first_path); + for path in rest_paths { builder.add(path); } builder.standard_filters(pyproject_config.settings.file_resolver.respect_gitignore); @@ -321,7 +322,7 @@ pub fn python_files_in_path( // Run the `WalkParallel` to collect all Python files. let error: std::sync::Mutex> = std::sync::Mutex::new(Ok(())); let resolver: RwLock = RwLock::new(resolver); - let files: std::sync::Mutex>> = + let files: std::sync::Mutex>> = std::sync::Mutex::new(vec![]); walker.run(|| { Box::new(|result| { @@ -332,18 +333,14 @@ pub fn python_files_in_path( let resolver = resolver.read().unwrap(); let settings = resolver.resolve(path, pyproject_config); if let Some(file_name) = path.file_name() { - if !settings.file_resolver.exclude.is_empty() - && match_exclusion(path, file_name, &settings.file_resolver.exclude) - { + if match_exclusion(path, file_name, &settings.file_resolver.exclude) { debug!("Ignored path via `exclude`: {:?}", path); return WalkState::Skip; - } else if !settings.file_resolver.extend_exclude.is_empty() - && match_exclusion( - path, - file_name, - &settings.file_resolver.extend_exclude, - ) - { + } else if match_exclusion( + path, + file_name, + &settings.file_resolver.extend_exclude, + ) { debug!("Ignored path via `extend-exclude`: {:?}", path); return WalkState::Skip; } @@ -386,30 +383,37 @@ pub fn python_files_in_path( } } - if result.as_ref().map_or(true, |entry| { - // Ignore directories - if entry.file_type().map_or(true, |ft| ft.is_dir()) { - false - } else if entry.depth() == 0 { - // Accept all files that are passed-in directly. - true - } else { - // Otherwise, check if the file is included. - let path = entry.path(); - let resolver = resolver.read().unwrap(); - let settings = resolver.resolve(path, pyproject_config); - if settings.file_resolver.include.is_match(path) { - debug!("Included path via `include`: {:?}", path); - true - } else if settings.file_resolver.extend_include.is_match(path) { - debug!("Included path via `extend-include`: {:?}", path); - true + match result { + Ok(entry) => { + // Ignore directories + let resolved = if entry.file_type().map_or(true, |ft| ft.is_dir()) { + None + } else if entry.depth() == 0 { + // Accept all files that are passed-in directly. + Some(ResolvedFile::Root(entry.into_path())) } else { - false + // Otherwise, check if the file is included. + let path = entry.path(); + let resolver = resolver.read().unwrap(); + let settings = resolver.resolve(path, pyproject_config); + if settings.file_resolver.include.is_match(path) { + debug!("Included path via `include`: {:?}", path); + Some(ResolvedFile::Nested(entry.into_path())) + } else if settings.file_resolver.extend_include.is_match(path) { + debug!("Included path via `extend-include`: {:?}", path); + Some(ResolvedFile::Nested(entry.into_path())) + } else { + None + } + }; + + if let Some(resolved) = resolved { + files.lock().unwrap().push(Ok(resolved)); } } - }) { - files.lock().unwrap().push(result); + Err(err) => { + files.lock().unwrap().push(Err(err)); + } } WalkState::Continue @@ -421,6 +425,51 @@ pub fn python_files_in_path( Ok((files.into_inner().unwrap(), resolver.into_inner().unwrap())) } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResolvedFile { + /// File explicitly passed to the CLI + Root(PathBuf), + /// File in a sub-directory + Nested(PathBuf), +} + +impl ResolvedFile { + pub fn into_path(self) -> PathBuf { + match self { + ResolvedFile::Root(path) => path, + ResolvedFile::Nested(path) => path, + } + } + + pub fn path(&self) -> &Path { + match self { + ResolvedFile::Root(root) => root.as_path(), + ResolvedFile::Nested(root) => root.as_path(), + } + } + + pub fn file_name(&self) -> &OsStr { + let path = self.path(); + path.file_name().unwrap_or(path.as_os_str()) + } + + pub fn is_root(&self) -> bool { + matches!(self, ResolvedFile::Root(_)) + } +} + +impl PartialOrd for ResolvedFile { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ResolvedFile { + fn cmp(&self, other: &Self) -> Ordering { + self.path().cmp(other.path()) + } +} + /// Return `true` if the Python file at [`Path`] is _not_ excluded. pub fn python_file_at_path( path: &Path, @@ -458,25 +507,17 @@ fn is_file_excluded( ) -> bool { // TODO(charlie): Respect gitignore. for path in path.ancestors() { - if path.file_name().is_none() { - break; - } let settings = resolver.resolve(path, pyproject_strategy); if let Some(file_name) = path.file_name() { - if !settings.file_resolver.exclude.is_empty() - && match_exclusion(path, file_name, &settings.file_resolver.exclude) - { + if match_exclusion(path, file_name, &settings.file_resolver.exclude) { debug!("Ignored path via `exclude`: {:?}", path); return true; - } else if !settings.file_resolver.extend_exclude.is_empty() - && match_exclusion(path, file_name, &settings.file_resolver.extend_exclude) - { + } else if match_exclusion(path, file_name, &settings.file_resolver.extend_exclude) { debug!("Ignored path via `extend-exclude`: {:?}", path); return true; } } else { - debug!("Ignored path due to error in parsing: {:?}", path); - return true; + break; } if path == settings.file_resolver.project_root { // Bail out; we'd end up past the project root on the next iteration @@ -489,7 +530,7 @@ fn is_file_excluded( /// Return `true` if the given file should be ignored based on the exclusion /// criteria. -fn match_exclusion, R: AsRef>( +pub fn match_exclusion, R: AsRef>( file_path: P, file_basename: R, exclusion: &globset::GlobSet, @@ -515,7 +556,7 @@ mod tests { use crate::resolver::{ is_file_excluded, match_exclusion, python_files_in_path, resolve_root_settings, ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy, Relativity, - Resolver, + ResolvedFile, Resolver, }; use crate::settings::Settings; use crate::tests::test_resource_path; @@ -584,12 +625,12 @@ mod tests { &NoOpTransformer, )?; let paths = paths - .iter() + .into_iter() .flatten() - .map(ignore::DirEntry::path) + .map(ResolvedFile::into_path) .sorted() .collect::>(); - assert_eq!(paths, &[file2, file1]); + assert_eq!(paths, [file2, file1]); Ok(()) } diff --git a/crates/ruff_workspace/src/settings.rs b/crates/ruff_workspace/src/settings.rs index ec4b2834d70f6..02c3376b6c297 100644 --- a/crates/ruff_workspace/src/settings.rs +++ b/crates/ruff_workspace/src/settings.rs @@ -111,6 +111,7 @@ impl FileResolverSettings { #[derive(CacheKey, Clone, Debug)] pub struct FormatterSettings { + pub exclude: FilePatternSet, pub preview: PreviewMode, pub line_width: LineWidth, @@ -162,6 +163,7 @@ impl Default for FormatterSettings { let default_options = PyFormatOptions::default(); Self { + exclude: FilePatternSet::default(), preview: ruff_python_formatter::PreviewMode::Disabled, line_width: default_options.line_width(), line_ending: LineEnding::Lf, diff --git a/ruff.schema.json b/ruff.schema.json index 9752f750363cb..f678b56d880c2 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -41,7 +41,7 @@ ] }, "exclude": { - "description": "A list of file patterns to exclude from linting.\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).\n\nNote that you'll typically want to use [`extend-exclude`](#extend-exclude) to modify the excluded paths.", + "description": "A list of file patterns to exclude from formatting and linting.\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).\n\nNote that you'll typically want to use [`extend-exclude`](#extend-exclude) to modify the excluded paths.", "type": [ "array", "null" @@ -65,7 +65,7 @@ ] }, "extend-exclude": { - "description": "A list of file patterns to omit from linting, in addition to those specified by `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).", + "description": "A list of file patterns to omit from formatting and linting, in addition to those specified by `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" @@ -1209,6 +1209,16 @@ "description": "Experimental: Configures how `ruff format` formats your code.\n\nPlease provide feedback in [this discussion](https://github.com/astral-sh/ruff/discussions/7310).", "type": "object", "properties": { + "exclude": { + "description": "A list of file patterns to exclude from formatting 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).\n\nNote that you'll typically want to use [`extend-exclude`](#extend-exclude) to modify the excluded paths.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "indent-style": { "description": "Whether to use 4 spaces or hard tabs for indenting code.\n\nDefaults to 4 spaces. We care about accessibility; if you do not need tabs for accessibility, we do not recommend you use them.", "anyOf": [ @@ -1592,6 +1602,16 @@ "null" ] }, + "exclude": { + "description": "A list of file patterns to exclude from linting 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" + } + }, "explicit-preview-rules": { "description": "Whether to require exact codes to select preview rules. When enabled, preview rules will not be selected by prefixes — the full code of each preview rule will be required to enable the rule.", "type": [