diff --git a/src/cli.rs b/src/cli.rs index d37b50db27..172430ed99 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -198,6 +198,7 @@ pub struct Arguments { } /// CLI settings that function as configuration overrides. +#[derive(Clone)] #[allow(clippy::struct_excessive_bools)] pub struct Overrides { pub dummy_variable_rgx: Option, diff --git a/src/commands.rs b/src/commands.rs index 2d2181fe4e..da78ee0c04 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -11,20 +11,15 @@ use crate::settings::types::SerializationFormat; use crate::{Configuration, Settings}; /// Print the user-facing configuration settings. -pub fn show_settings( - configuration: &Configuration, - project_root: Option<&Path>, - pyproject: Option<&Path>, -) { +pub fn show_settings(configuration: &Configuration, pyproject: Option<&Path>) { println!("Resolved configuration: {configuration:#?}"); - println!("Found project root at: {project_root:?}"); println!("Found pyproject.toml at: {pyproject:?}"); } /// Show the list of files to be checked based on current settings. -pub fn show_files(files: &[PathBuf], default: &Settings, overrides: &Overrides) { +pub fn show_files(files: &[PathBuf], defaults: &Settings, overrides: &Overrides) { // Collect all files in the hierarchy. - let (paths, _resolver) = collect_python_files(files, overrides, default); + let (paths, _resolver) = collect_python_files(files, overrides, defaults); // Print the list of files. for entry in paths diff --git a/src/lib.rs b/src/lib.rs index 248ac54639..1b289fa587 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,24 +84,22 @@ pub mod updates; mod vendored; pub mod visibility; -/// Run Ruff over Python source code directly. -pub fn check(path: &Path, contents: &str, autofix: bool) -> Result> { - // Find the project root and pyproject.toml. - let project_root = pyproject::find_project_root(&[path.to_path_buf()]); - match &project_root { - Some(path) => debug!("Found project root at: {:?}", path), - None => debug!("Unable to identify project root; assuming current directory..."), - }; - let pyproject = pyproject::find_pyproject_toml(project_root.as_ref()); - match &pyproject { - Some(path) => debug!("Found pyproject.toml at: {:?}", path), - None => debug!("Unable to find pyproject.toml; using default settings..."), +fn resolve(path: &Path) -> Result { + // Find the relevant `pyproject.toml`. + let Some(pyproject) = pyproject::find_pyproject_toml(path) else { + debug!("Unable to find pyproject.toml; using default settings..."); + return Settings::from_configuration(Configuration::default(), None); }; - let settings = Settings::from_configuration( - Configuration::from_pyproject(pyproject.as_ref())?, - project_root.as_deref(), - )?; + // Load and parse the `pyproject.toml`. + let options = pyproject::load_options(&pyproject)?; + let configuration = Configuration::from_options(options)?; + Settings::from_configuration(configuration, pyproject.parent()) +} + +/// Run Ruff over Python source code directly. +pub fn check(path: &Path, contents: &str, autofix: bool) -> Result> { + let settings = resolve(path)?; // Tokenize once. let tokens: Vec = tokenize(contents); diff --git a/src/main.rs b/src/main.rs index 25da49ffb0..7d0b6f367d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,12 +31,13 @@ use ::ruff::settings::types::SerializationFormat; use ::ruff::settings::{pyproject, Settings}; #[cfg(feature = "update-informer")] use ::ruff::updates; -use ::ruff::{cache, commands, fs}; +use ::ruff::{cache, commands}; use anyhow::Result; use clap::{CommandFactory, Parser}; use colored::Colorize; use log::{debug, error}; use notify::{recommended_watcher, RecursiveMode, Watcher}; +use path_absolutize::path_dedot; #[cfg(not(target_family = "wasm"))] use rayon::prelude::*; use rustpython_ast::Location; @@ -60,14 +61,14 @@ fn run_once_stdin( fn run_once( files: &[PathBuf], - default: &Settings, + defaults: &Settings, overrides: &Overrides, cache: bool, autofix: &fixer::Mode, ) -> Diagnostics { // Collect all the files to check. let start = Instant::now(); - let (paths, resolver) = collect_python_files(files, overrides, default); + let (paths, resolver) = collect_python_files(files, overrides, defaults); let duration = start.elapsed(); debug!("Identified files to lint in: {:?}", duration); @@ -77,7 +78,7 @@ fn run_once( match entry { Ok(entry) => { let path = entry.path(); - let settings = resolver.resolve(path).unwrap_or(default); + let settings = resolver.resolve(path).unwrap_or(defaults); lint_path(path, settings, &cache.into(), autofix) .map_err(|e| (Some(path.to_owned()), e.to_string())) } @@ -89,7 +90,7 @@ fn run_once( } .unwrap_or_else(|(path, message)| { if let Some(path) = &path { - let settings = resolver.resolve(path).unwrap_or(default); + let settings = resolver.resolve(path).unwrap_or(defaults); if settings.enabled.contains(&CheckCode::E902) { Diagnostics::new(vec![Message { kind: CheckKind::IOError(message), @@ -121,10 +122,10 @@ fn run_once( diagnostics } -fn add_noqa(files: &[PathBuf], default: &Settings, overrides: &Overrides) -> usize { +fn add_noqa(files: &[PathBuf], defaults: &Settings, overrides: &Overrides) -> usize { // Collect all the files to check. let start = Instant::now(); - let (paths, resolver) = collect_python_files(files, overrides, default); + let (paths, resolver) = collect_python_files(files, overrides, defaults); let duration = start.elapsed(); debug!("Identified files to lint in: {:?}", duration); @@ -133,7 +134,7 @@ fn add_noqa(files: &[PathBuf], default: &Settings, overrides: &Overrides) -> usi .flatten() .filter_map(|entry| { let path = entry.path(); - let settings = resolver.resolve(path).unwrap_or(default); + let settings = resolver.resolve(path).unwrap_or(defaults); match add_noqa_to_path(path, settings) { Ok(count) => Some(count), Err(e) => { @@ -150,10 +151,10 @@ fn add_noqa(files: &[PathBuf], default: &Settings, overrides: &Overrides) -> usi modifications } -fn autoformat(files: &[PathBuf], default: &Settings, overrides: &Overrides) -> usize { +fn autoformat(files: &[PathBuf], defaults: &Settings, overrides: &Overrides) -> usize { // Collect all the files to format. let start = Instant::now(); - let (paths, resolver) = collect_python_files(files, overrides, default); + let (paths, resolver) = collect_python_files(files, overrides, defaults); let duration = start.elapsed(); debug!("Identified files to lint in: {:?}", duration); @@ -162,7 +163,7 @@ fn autoformat(files: &[PathBuf], default: &Settings, overrides: &Overrides) -> u .flatten() .filter_map(|entry| { let path = entry.path(); - let settings = resolver.resolve(path).unwrap_or(default); + let settings = resolver.resolve(path).unwrap_or(defaults); match autoformat_path(path, settings) { Ok(()) => Some(()), Err(e) => { @@ -185,50 +186,36 @@ fn inner_main() -> Result { let log_level = extract_log_level(&cli); set_up_logging(&log_level)?; + if cli.show_settings && cli.show_files { + anyhow::bail!("specify --show-settings or show-files (not both)") + } if let Some(shell) = cli.generate_shell_completion { shell.generate(&mut Cli::command(), &mut io::stdout()); return Ok(ExitCode::SUCCESS); } - // Find the project root and pyproject.toml. - // TODO(charlie): look in the current directory, but respect `--config`. - let project_root = cli.config.as_ref().map_or_else( - || pyproject::find_project_root(&cli.files), - |config| config.parent().map(fs::normalize_path), - ); + // Find the `pyproject.toml`. let pyproject = cli .config - .or_else(|| pyproject::find_pyproject_toml(project_root.as_ref())); - match &project_root { - Some(path) => debug!("Found project root at: {:?}", path), - None => debug!("Unable to identify project root; assuming current directory..."), - }; - match &pyproject { - Some(path) => debug!("Found pyproject.toml at: {:?}", path), - None => debug!("Unable to find pyproject.toml; using default settings..."), - }; + .or_else(|| pyproject::find_pyproject_toml(&path_dedot::CWD)); - // Reconcile configuration from pyproject.toml and command-line arguments. - let mut configuration = Configuration::from_pyproject(pyproject.as_ref())?; - configuration.merge(&overrides); - - if cli.show_settings && cli.show_files { - eprintln!("Error: specify --show-settings or show-files (not both)."); - return Ok(ExitCode::FAILURE); - } + // Reconcile configuration from `pyproject.toml` and command-line arguments. + let mut configuration = pyproject + .as_ref() + .map(|path| Configuration::from_pyproject(path)) + .transpose()? + .unwrap_or_default(); + configuration.merge(overrides.clone()); if cli.show_settings { // TODO(charlie): This would be more useful if required a single file, and told // you the settings used to lint that file. - commands::show_settings( - &configuration, - project_root.as_deref(), - pyproject.as_deref(), - ); + commands::show_settings(&configuration, pyproject.as_deref()); return Ok(ExitCode::SUCCESS); } - // TODO(charlie): Included in `pyproject.toml`, but not inherited. + // Extract options that are included in the `pyproject.toml`, but aren't in + // `Settings`. let fix = if configuration.fix { fixer::Mode::Apply } else if matches!(configuration.format, SerializationFormat::Json) { @@ -238,7 +225,12 @@ fn inner_main() -> Result { }; let format = configuration.format; - let settings = Settings::from_configuration(configuration, project_root.as_deref())?; + // Construct the "default" settings. These are used when no `pyproject.toml` + // files are present, or files are injected from outside of the hierarchy. + let defaults = Settings::from_configuration( + configuration, + pyproject.as_ref().and_then(|path| path.parent()), + )?; if let Some(code) = cli.explain { commands::explain(&code, format)?; @@ -246,7 +238,7 @@ fn inner_main() -> Result { } if cli.show_files { - commands::show_files(&cli.files, &settings, &overrides); + commands::show_files(&cli.files, &defaults, &overrides); return Ok(ExitCode::SUCCESS); } @@ -278,7 +270,7 @@ fn inner_main() -> Result { let messages = run_once( &cli.files, - &settings, + &defaults, &overrides, cache_enabled, &fixer::Mode::None, @@ -294,8 +286,8 @@ fn inner_main() -> Result { loop { match rx.recv() { - Ok(e) => { - let paths = e?.paths; + Ok(event) => { + let paths = event?.paths; let py_changed = paths.iter().any(|p| { p.extension() .map(|ext| ext == "py" || ext == "pyi") @@ -307,7 +299,7 @@ fn inner_main() -> Result { let messages = run_once( &cli.files, - &settings, + &defaults, &overrides, cache_enabled, &fixer::Mode::None, @@ -315,16 +307,16 @@ fn inner_main() -> Result { printer.write_continuously(&messages)?; } } - Err(e) => return Err(e.into()), + Err(err) => return Err(err.into()), } } } else if cli.add_noqa { - let modifications = add_noqa(&cli.files, &settings, &overrides); + let modifications = add_noqa(&cli.files, &defaults, &overrides); if modifications > 0 && log_level >= LogLevel::Default { println!("Added {modifications} noqa directives."); } } else if cli.autoformat { - let modifications = autoformat(&cli.files, &settings, &overrides); + let modifications = autoformat(&cli.files, &defaults, &overrides); if modifications > 0 && log_level >= LogLevel::Default { println!("Formatted {modifications} files."); } @@ -335,9 +327,9 @@ fn inner_main() -> Result { let diagnostics = if is_stdin { let filename = cli.stdin_filename.unwrap_or_else(|| "-".to_string()); let path = Path::new(&filename); - run_once_stdin(&settings, path, &fix)? + run_once_stdin(&defaults, path, &fix)? } else { - run_once(&cli.files, &settings, &overrides, cache_enabled, &fix) + run_once(&cli.files, &defaults, &overrides, cache_enabled, &fix) }; // Always try to print violations (the printer itself may suppress output), diff --git a/src/resolver.rs b/src/resolver.rs index 9fdde0976a..bdfc684cb1 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -42,7 +42,7 @@ pub fn settings_for_path(pyproject: &Path, overrides: &Overrides) -> Result<(Pat .to_path_buf(); let options = pyproject::load_options(pyproject)?; let mut configuration = Configuration::from_options(options)?; - configuration.merge(overrides); + configuration.merge(overrides.clone()); let settings = Settings::from_configuration(configuration, Some(&project_root))?; Ok((project_root, settings)) } diff --git a/src/settings/configuration.rs b/src/settings/configuration.rs index ec7792f036..344f406512 100644 --- a/src/settings/configuration.rs +++ b/src/settings/configuration.rs @@ -5,7 +5,6 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; -use log::debug; use once_cell::sync::Lazy; use regex::Regex; use rustc_hash::FxHashSet; @@ -81,15 +80,8 @@ static DEFAULT_DUMMY_VARIABLE_RGX: Lazy = Lazy::new(|| Regex::new("^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$").unwrap()); impl Configuration { - pub fn from_pyproject(pyproject: Option<&PathBuf>) -> Result { - Self::from_options(pyproject.map_or_else( - || { - debug!("No pyproject.toml found."); - debug!("Falling back to default configuration..."); - Ok(Options::default()) - }, - |path| load_options(path), - )?) + pub fn from_pyproject(pyproject: &Path) -> Result { + Self::from_options(load_options(pyproject)?) } pub fn from_options(options: Options) -> Result { @@ -184,54 +176,90 @@ impl Configuration { }) } - pub fn merge(&mut self, overrides: &Overrides) { - if let Some(dummy_variable_rgx) = &overrides.dummy_variable_rgx { - self.dummy_variable_rgx = dummy_variable_rgx.clone(); + pub fn merge(&mut self, overrides: Overrides) { + if let Some(dummy_variable_rgx) = overrides.dummy_variable_rgx { + self.dummy_variable_rgx = dummy_variable_rgx; } - if let Some(exclude) = &overrides.exclude { - self.exclude = exclude.clone(); + if let Some(exclude) = overrides.exclude { + self.exclude = exclude; } - if let Some(extend_exclude) = &overrides.extend_exclude { - self.extend_exclude = extend_exclude.clone(); + if let Some(extend_exclude) = overrides.extend_exclude { + self.extend_exclude = extend_exclude; } - if let Some(extend_ignore) = &overrides.extend_ignore { - self.extend_ignore = extend_ignore.clone(); + if let Some(extend_ignore) = overrides.extend_ignore { + self.extend_ignore = extend_ignore; } - if let Some(extend_select) = &overrides.extend_select { - self.extend_select = extend_select.clone(); + if let Some(extend_select) = overrides.extend_select { + self.extend_select = extend_select; } - if let Some(fix) = &overrides.fix { - self.fix = *fix; + if let Some(fix) = overrides.fix { + self.fix = fix; } - if let Some(fixable) = &overrides.fixable { - self.fixable = fixable.clone(); + if let Some(fixable) = overrides.fixable { + self.fixable = fixable; } - if let Some(format) = &overrides.format { - self.format = *format; + if let Some(format) = overrides.format { + self.format = format; } - if let Some(ignore) = &overrides.ignore { - self.ignore = ignore.clone(); + if let Some(ignore) = overrides.ignore { + self.ignore = ignore; } - if let Some(line_length) = &overrides.line_length { - self.line_length = *line_length; + if let Some(line_length) = overrides.line_length { + self.line_length = line_length; } - if let Some(max_complexity) = &overrides.max_complexity { - self.mccabe.max_complexity = *max_complexity; + if let Some(max_complexity) = overrides.max_complexity { + self.mccabe.max_complexity = max_complexity; } - if let Some(per_file_ignores) = &overrides.per_file_ignores { - self.per_file_ignores = collect_per_file_ignores(per_file_ignores.clone()); + if let Some(per_file_ignores) = overrides.per_file_ignores { + self.per_file_ignores = collect_per_file_ignores(per_file_ignores); } - if let Some(select) = &overrides.select { - self.select = select.clone(); + if let Some(select) = overrides.select { + self.select = select; } - if let Some(show_source) = &overrides.show_source { - self.show_source = *show_source; + if let Some(show_source) = overrides.show_source { + self.show_source = show_source; } - if let Some(target_version) = &overrides.target_version { - self.target_version = *target_version; + if let Some(target_version) = overrides.target_version { + self.target_version = target_version; } - if let Some(unfixable) = &overrides.unfixable { - self.unfixable = unfixable.clone(); + if let Some(unfixable) = overrides.unfixable { + self.unfixable = unfixable; + } + } +} + +impl Default for Configuration { + fn default() -> Self { + Configuration { + allowed_confusables: FxHashSet::default(), + dummy_variable_rgx: DEFAULT_DUMMY_VARIABLE_RGX.clone(), + src: vec![Path::new(".").to_path_buf()], + target_version: PythonVersion::Py310, + exclude: DEFAULT_EXCLUDE.clone(), + extend_exclude: Vec::default(), + extend_ignore: Vec::default(), + select: vec![CheckCodePrefix::E, CheckCodePrefix::F], + extend_select: Vec::default(), + external: Vec::default(), + fix: false, + fixable: CATEGORIES.to_vec(), + unfixable: Vec::default(), + format: SerializationFormat::default(), + ignore: Vec::default(), + ignore_init_module_imports: false, + line_length: 88, + per_file_ignores: Vec::default(), + show_source: false, + // Plugins + flake8_annotations: flake8_annotations::settings::Settings::default(), + flake8_bugbear: flake8_bugbear::settings::Settings::default(), + flake8_import_conventions: flake8_import_conventions::settings::Settings::default(), + flake8_quotes: flake8_quotes::settings::Settings::default(), + flake8_tidy_imports: flake8_tidy_imports::settings::Settings::default(), + isort: isort::settings::Settings::default(), + mccabe: mccabe::settings::Settings::default(), + pep8_naming: pep8_naming::settings::Settings::default(), + pyupgrade: pyupgrade::settings::Settings::default(), } } } diff --git a/src/settings/pyproject.rs b/src/settings/pyproject.rs index 18523d0b8b..fefbe0f8d8 100644 --- a/src/settings/pyproject.rs +++ b/src/settings/pyproject.rs @@ -3,8 +3,6 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; -use common_path::common_path_all; -use path_absolutize::Absolutize; use serde::{Deserialize, Serialize}; use crate::fs; @@ -35,14 +33,14 @@ fn parse_pyproject_toml(path: &Path) -> Result { Ok(toml::from_str(&contents)?) } -pub fn find_pyproject_toml(path: Option<&PathBuf>) -> Option { - if let Some(path) = path { - let path_pyproject_toml = path.join("pyproject.toml"); - if path_pyproject_toml.is_file() { - return Some(path_pyproject_toml); +/// Find the nearest `pyproject.toml` file. +pub fn find_pyproject_toml(path: &Path) -> Option { + for directory in path.ancestors() { + let pyproject = directory.join("pyproject.toml"); + if pyproject.is_file() { + return Some(pyproject); } } - find_user_pyproject_toml() } @@ -57,28 +55,6 @@ fn find_user_pyproject_toml() -> Option { } } -pub fn find_project_root(sources: &[PathBuf]) -> Option { - let absolute_sources: Vec = sources - .iter() - .flat_map(|source| source.absolutize().map(|path| path.to_path_buf())) - .collect(); - if let Some(prefix) = common_path_all(absolute_sources.iter().map(PathBuf::as_path)) { - for directory in prefix.ancestors() { - if directory.join(".git").is_dir() { - return Some(directory.to_path_buf()); - } - if directory.join(".hg").is_dir() { - return Some(directory.to_path_buf()); - } - if directory.join("pyproject.toml").is_file() { - return Some(directory.to_path_buf()); - } - } - } - - None -} - pub fn load_options(pyproject: &Path) -> Result { Ok(parse_pyproject_toml(pyproject) .map_err(|err| anyhow!("Failed to parse `{}`: {}", pyproject.to_string_lossy(), err))? @@ -90,7 +66,6 @@ pub fn load_options(pyproject: &Path) -> Result { #[cfg(test)] mod tests { use std::env::current_dir; - use std::path::PathBuf; use std::str::FromStr; use anyhow::Result; @@ -100,7 +75,7 @@ mod tests { use crate::flake8_quotes::settings::Quote; use crate::flake8_tidy_imports::settings::Strictness; use crate::settings::pyproject::{ - find_project_root, find_pyproject_toml, parse_pyproject_toml, Options, Pyproject, Tools, + find_pyproject_toml, parse_pyproject_toml, Options, Pyproject, Tools, }; use crate::settings::types::PatternPrefixPair; use crate::{ @@ -369,14 +344,14 @@ other-attribute = 1 #[test] fn find_and_parse_pyproject_toml() -> Result<()> { let cwd = current_dir()?; - let project_root = - find_project_root(&[PathBuf::from("resources/test/fixtures/__init__.py")]).unwrap(); - assert_eq!(project_root, cwd.join("resources/test/fixtures")); + let pyproject = + find_pyproject_toml(&cwd.join("resources/test/fixtures/__init__.py")).unwrap(); + assert_eq!( + pyproject, + cwd.join("resources/test/fixtures/pyproject.toml") + ); - let path = find_pyproject_toml(Some(&project_root)).unwrap(); - assert_eq!(path, cwd.join("resources/test/fixtures/pyproject.toml")); - - let pyproject = parse_pyproject_toml(&path)?; + let pyproject = parse_pyproject_toml(&pyproject)?; let config = pyproject.tool.and_then(|tool| tool.ruff).unwrap(); assert_eq!( config,