#![allow(clippy::print_stdout)] use std::fs::File; use std::io::{self, stdout, BufWriter, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::mpsc::channel; use anyhow::Result; use clap::CommandFactory; use colored::Colorize; use log::warn; use notify::{recommended_watcher, RecursiveMode, Watcher}; use ruff_linter::logging::{set_up_logging, LogLevel}; use ruff_linter::settings::flags::FixMode; use ruff_linter::settings::types::SerializationFormat; use ruff_linter::{fs, warn_user, warn_user_once}; use ruff_workspace::Settings; use crate::args::{Args, CheckCommand, Command, FormatCommand, HelpFormat}; use crate::printer::{Flags as PrinterFlags, Printer}; pub mod args; mod cache; mod commands; mod diagnostics; mod panic; mod printer; pub mod resolve; mod stdin; mod version; #[derive(Copy, Clone)] pub enum ExitStatus { /// Linting was successful and there were no linting errors. Success, /// Linting was successful but there were linting errors. Failure, /// Linting failed. Error, } impl From for ExitCode { fn from(status: ExitStatus) -> Self { match status { ExitStatus::Success => ExitCode::from(0), ExitStatus::Failure => ExitCode::from(1), ExitStatus::Error => ExitCode::from(2), } } } #[derive(Copy, Clone, Debug, Eq, PartialEq)] enum ChangeKind { Configuration, SourceFile, } /// Return the [`ChangeKind`] based on the list of modified file paths. /// /// Returns `None` if no relevant changes were detected. fn change_detected(paths: &[PathBuf]) -> Option { // If any `.toml` files were modified, return `ChangeKind::Configuration`. Otherwise, return // `ChangeKind::SourceFile` if any `.py`, `.pyi`, `.pyw`, or `.ipynb` files were modified. let mut source_file = false; for path in paths { if let Some(suffix) = path.extension() { match suffix.to_str() { Some("toml") => { return Some(ChangeKind::Configuration); } Some("py" | "pyi" | "pyw" | "ipynb") => source_file = true, _ => {} } } } if source_file { return Some(ChangeKind::SourceFile); } None } /// Returns true if the command should read from standard input. fn is_stdin(files: &[PathBuf], stdin_filename: Option<&Path>) -> bool { // If the user provided a `--stdin-filename`, always read from standard input. if stdin_filename.is_some() { if let Some(file) = files.iter().find(|file| file.as_path() != Path::new("-")) { warn_user_once!( "Ignoring file {} in favor of standard input.", file.display() ); } return true; } let [file] = files else { return false; }; // If the user provided exactly `-`, read from standard input. file == Path::new("-") } /// Returns the default set of files if none are provided, otherwise returns `None`. fn resolve_default_files(files: Vec, is_stdin: bool) -> Vec { if files.is_empty() { if is_stdin { vec![Path::new("-").to_path_buf()] } else { vec![Path::new(".").to_path_buf()] } } else { files } } /// Get the actual value of the `format` desired from either `output_format` /// or `format`, and warn the user if they're using the deprecated form. fn resolve_help_output_format(output_format: HelpFormat, format: Option) -> HelpFormat { if format.is_some() { warn_user!("The `--format` argument is deprecated. Use `--output-format` instead."); } format.unwrap_or(output_format) } pub fn run( Args { command, log_level_args, }: Args, ) -> Result { { let default_panic_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { #[allow(clippy::print_stderr)] { eprintln!( r#" {}{} {} If you could open an issue at: https://github.com/astral-sh/ruff/issues/new?title=%5BPanic%5D ...quoting the executed command, along with the relevant file contents and `pyproject.toml` settings, we'd be very appreciative! "#, "error".red().bold(), ":".bold(), "Ruff crashed.".bold(), ); } default_panic_hook(info); })); } // Enabled ANSI colors on Windows 10. #[cfg(windows)] assert!(colored::control::set_virtual_terminal(true).is_ok()); let log_level = LogLevel::from(&log_level_args); set_up_logging(&log_level)?; match command { Command::Version { output_format } => { commands::version::version(output_format)?; Ok(ExitStatus::Success) } Command::Rule { rule, all, format, mut output_format, } => { output_format = resolve_help_output_format(output_format, format); if all { commands::rule::rules(output_format)?; } if let Some(rule) = rule { commands::rule::rule(rule, output_format)?; } Ok(ExitStatus::Success) } Command::Config { option } => { commands::config::config(option.as_deref())?; Ok(ExitStatus::Success) } Command::Linter { format, mut output_format, } => { output_format = resolve_help_output_format(output_format, format); commands::linter::linter(output_format)?; Ok(ExitStatus::Success) } Command::Clean => { commands::clean::clean(log_level)?; Ok(ExitStatus::Success) } Command::GenerateShellCompletion { shell } => { shell.generate(&mut Args::command(), &mut stdout()); Ok(ExitStatus::Success) } Command::Check(args) => check(args, log_level), Command::Format(args) => format(args, log_level), } } fn format(args: FormatCommand, log_level: LogLevel) -> Result { let (cli, overrides) = args.partition(); if is_stdin(&cli.files, cli.stdin_filename.as_deref()) { commands::format_stdin::format_stdin(&cli, &overrides) } else { commands::format::format(cli, &overrides, log_level) } } pub fn check(args: CheckCommand, log_level: LogLevel) -> Result { let (cli, overrides) = args.partition(); // Construct the "default" settings. These are used when no `pyproject.toml` // files are present, or files are injected from outside of the hierarchy. let pyproject_config = resolve::resolve( cli.isolated, cli.config.as_deref(), &overrides, cli.stdin_filename.as_deref(), )?; let mut writer: Box = match cli.output_file { Some(path) if !cli.watch => { colored::control::set_override(false); let file = File::create(path)?; Box::new(BufWriter::new(file)) } _ => Box::new(BufWriter::new(io::stdout())), }; let stderr_writer = Box::new(BufWriter::new(io::stderr())); let is_stdin = is_stdin(&cli.files, cli.stdin_filename.as_deref()); let files = resolve_default_files(cli.files, is_stdin); if cli.show_settings { commands::show_settings::show_settings(&files, &pyproject_config, &overrides, &mut writer)?; return Ok(ExitStatus::Success); } if cli.show_files { commands::show_files::show_files(&files, &pyproject_config, &overrides, &mut writer)?; return Ok(ExitStatus::Success); } // Extract options that are included in `Settings`, but only apply at the top // level. let Settings { fix, fix_only, unsafe_fixes, output_format, show_fixes, show_source, .. } = pyproject_config.settings; // Fix rules are as follows: // - By default, generate all fixes, but don't apply them to the filesystem. // - If `--fix` or `--fix-only` is set, apply applicable fixes to the filesystem (or // print them to stdout, if we're reading from stdin). // - If `--diff` or `--fix-only` are set, don't print any violations (only applicable fixes) // - By default, applicable fixes only include [`Applicablility::Automatic`], but if // `--unsafe-fixes` is set, then [`Applicablility::Suggested`] fixes are included. let fix_mode = if cli.diff { FixMode::Diff } else if fix || fix_only { FixMode::Apply } else { FixMode::Generate }; let cache = !cli.no_cache; let noqa = !cli.ignore_noqa; let mut printer_flags = PrinterFlags::empty(); if !(cli.diff || fix_only) { printer_flags |= PrinterFlags::SHOW_VIOLATIONS; } if show_fixes { printer_flags |= PrinterFlags::SHOW_FIX_SUMMARY; } if show_source { printer_flags |= PrinterFlags::SHOW_SOURCE; } if cli.ecosystem_ci { warn_user!( "The formatting of fixes emitted by this option is a work-in-progress, subject to \ change at any time, and intended only for internal use." ); printer_flags |= PrinterFlags::SHOW_FIX_DIFF; } #[cfg(debug_assertions)] if cache { // `--no-cache` doesn't respect code changes, and so is often confusing during // development. warn_user!("Detected debug build without --no-cache."); } if cli.add_noqa { if !fix_mode.is_generate() { warn_user!("--fix is incompatible with --add-noqa."); } let modifications = commands::add_noqa::add_noqa(&files, &pyproject_config, &overrides)?; if modifications > 0 && log_level >= LogLevel::Default { let s = if modifications == 1 { "" } else { "s" }; #[allow(clippy::print_stderr)] { eprintln!("Added {modifications} noqa directive{s}."); } } return Ok(ExitStatus::Success); } let printer = Printer::new( output_format, log_level, fix_mode, unsafe_fixes, printer_flags, ); if cli.watch { if output_format != SerializationFormat::Text { warn_user!("`--output-format text` is always used in watch mode."); } // Configure the file watcher. let (tx, rx) = channel(); let mut watcher = recommended_watcher(tx)?; for file in &files { watcher.watch(file, RecursiveMode::Recursive)?; } if let Some(file) = pyproject_config.path.as_ref() { watcher.watch(file, RecursiveMode::Recursive)?; } // Perform an initial run instantly. Printer::clear_screen()?; printer.write_to_user("Starting linter in watch mode...\n"); let messages = commands::check::check( &files, &pyproject_config, &overrides, cache.into(), noqa.into(), fix_mode, unsafe_fixes, )?; printer.write_continuously(&mut writer, &messages)?; // In watch mode, we may need to re-resolve the configuration. // TODO(charlie): Re-compute other derivative values, like the `printer`. let mut pyproject_config = pyproject_config; loop { match rx.recv() { Ok(event) => { let Some(change_kind) = change_detected(&event?.paths) else { continue; }; if matches!(change_kind, ChangeKind::Configuration) { pyproject_config = resolve::resolve( cli.isolated, cli.config.as_deref(), &overrides, cli.stdin_filename.as_deref(), )?; } Printer::clear_screen()?; printer.write_to_user("File change detected...\n"); let messages = commands::check::check( &files, &pyproject_config, &overrides, cache.into(), noqa.into(), fix_mode, unsafe_fixes, )?; printer.write_continuously(&mut writer, &messages)?; } Err(err) => return Err(err.into()), } } } else { // Generate lint violations. let diagnostics = if is_stdin { commands::check_stdin::check_stdin( cli.stdin_filename.map(fs::normalize_path).as_deref(), &pyproject_config, &overrides, noqa.into(), fix_mode, )? } else { commands::check::check( &files, &pyproject_config, &overrides, cache.into(), noqa.into(), fix_mode, unsafe_fixes, )? }; // Always try to print violations (though the printer itself may suppress output) // If we're writing fixes via stdin, the transformed source code goes to the writer // so send the summary to stderr instead let mut summary_writer = if is_stdin && matches!(fix_mode, FixMode::Apply | FixMode::Diff) { stderr_writer } else { writer }; if cli.statistics { printer.write_statistics(&diagnostics, &mut summary_writer)?; } else { printer.write_once(&diagnostics, &mut summary_writer)?; } if !cli.exit_zero { if cli.diff { // If we're printing a diff, we always want to exit non-zero if there are // any fixable violations (since we've printed the diff, but not applied the // fixes). if !diagnostics.fixed.is_empty() { return Ok(ExitStatus::Failure); } } else if fix_only { // If we're only fixing, we want to exit zero (since we've fixed all fixable // violations), unless we're explicitly asked to exit non-zero on fix. if cli.exit_non_zero_on_fix { if !diagnostics.fixed.is_empty() { return Ok(ExitStatus::Failure); } } } else { // If we're running the linter (not just fixing), we want to exit non-zero if // there are any violations, unless we're explicitly asked to exit zero on // fix. if cli.exit_non_zero_on_fix { if !diagnostics.fixed.is_empty() || !diagnostics.messages.is_empty() { return Ok(ExitStatus::Failure); } } else { if !diagnostics.messages.is_empty() { return Ok(ExitStatus::Failure); } } } } } Ok(ExitStatus::Success) } #[cfg(test)] mod test_file_change_detector { use std::path::PathBuf; use crate::{change_detected, ChangeKind}; #[test] fn detect_correct_file_change() { assert_eq!( Some(ChangeKind::Configuration), change_detected(&[ PathBuf::from("tmp/pyproject.toml"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); assert_eq!( Some(ChangeKind::Configuration), change_detected(&[ PathBuf::from("pyproject.toml"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); assert_eq!( Some(ChangeKind::Configuration), change_detected(&[ PathBuf::from("tmp1/tmp2/tmp3/pyproject.toml"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); assert_eq!( Some(ChangeKind::Configuration), change_detected(&[ PathBuf::from("tmp/ruff.toml"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); assert_eq!( Some(ChangeKind::Configuration), change_detected(&[ PathBuf::from("tmp/.ruff.toml"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); assert_eq!( Some(ChangeKind::SourceFile), change_detected(&[ PathBuf::from("tmp/rule.py"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); assert_eq!( Some(ChangeKind::SourceFile), change_detected(&[ PathBuf::from("tmp/rule.pyi"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); assert_eq!( Some(ChangeKind::Configuration), change_detected(&[ PathBuf::from("pyproject.toml"), PathBuf::from("tmp/rule.py"), ]), ); assert_eq!( Some(ChangeKind::Configuration), change_detected(&[ PathBuf::from("tmp/rule.py"), PathBuf::from("pyproject.toml"), ]), ); assert_eq!( None, change_detected(&[ PathBuf::from("tmp/rule.js"), PathBuf::from("tmp/bin/ruff.rs"), ]), ); } }