#![allow(clippy::print_stdout)]
use std::ffi::OsString;
use std::fs::File;
use std::io::{self, BufWriter, Write, stdout};
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::error;
use notify::{RecursiveMode, Watcher, recommended_watcher};
use args::{GlobalConfigArgs, ServerCommand};
use ruff_db::diagnostic::{Diagnostic, Severity};
use ruff_linter::logging::{LogLevel, set_up_logging};
use ruff_linter::settings::flags::FixMode;
use ruff_linter::{fs, warn_user, warn_user_once};
use ruff_workspace::Settings;
use crate::args::{
AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, TerminalColor,
};
use crate::printer::{Flags as PrinterFlags, Printer};
pub mod args;
mod cache;
mod commands;
mod diagnostics;
mod printer;
pub mod resolve;
mod stdin;
mod version;
#[derive(Copy, Clone)]
pub enum ExitStatus {
Success,
Failure,
Error,
}
impl From<ExitStatus> 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,
}
fn change_detected(event: ¬ify::Event) -> Option<ChangeKind> {
let mut source_file = false;
if event.kind.is_access() || event.kind.is_other() {
return None;
}
if event.need_rescan() {
return Some(ChangeKind::Configuration);
}
for path in &event.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
}
fn is_stdin(files: &[PathBuf], stdin_filename: Option<&Path>) -> bool {
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;
};
file == Path::new("-")
}
fn resolve_default_files(files: Vec<PathBuf>, is_stdin: bool) -> Vec<PathBuf> {
if files.is_empty() {
if is_stdin {
vec![Path::new("-").to_path_buf()]
} else {
vec![Path::new(".").to_path_buf()]
}
} else {
files
}
}
pub fn run(
Args {
command,
global_options,
}: Args,
) -> Result<ExitStatus> {
if let Some(color_override) =
colored_override(global_options.color, std::env::var_os("FORCE_COLOR"))
{
colored::control::set_override(color_override);
}
{
ruff_db::set_program_version(crate::version::version().to_string()).unwrap();
let default_panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
#[expect(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);
}));
}
if !matches!(command, Command::Server { .. }) {
set_up_logging(global_options.log_level())?;
}
match command {
Command::Version { output_format } => {
commands::version::version(output_format)?;
Ok(ExitStatus::Success)
}
Command::Rule {
rule,
all,
output_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,
output_format,
} => {
commands::config::config(option.as_deref(), output_format)?;
Ok(ExitStatus::Success)
}
Command::Linter { output_format } => {
commands::linter::linter(output_format)?;
Ok(ExitStatus::Success)
}
Command::Clean => {
commands::clean::clean(global_options.log_level())?;
Ok(ExitStatus::Success)
}
Command::GenerateShellCompletion { shell } => {
shell.generate(&mut Args::command(), &mut stdout());
Ok(ExitStatus::Success)
}
Command::Check(args) => check(args, global_options),
Command::Format(args) => format(args, global_options),
Command::Server(args) => server(args),
Command::Analyze(AnalyzeCommand::Graph(args)) => analyze_graph(args, global_options),
}
}
fn format(args: FormatCommand, global_options: GlobalConfigArgs) -> Result<ExitStatus> {
let cli_output_format_set = args.output_format.is_some();
let (cli, config_arguments) = args.partition(global_options)?;
let pyproject_config = resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
if cli_output_format_set && !pyproject_config.settings.formatter.preview.is_enabled() {
warn_user_once!(
"The --output-format flag for the formatter is unstable and requires preview mode to use."
);
}
if is_stdin(&cli.files, cli.stdin_filename.as_deref()) {
commands::format_stdin::format_stdin(&cli, &config_arguments, &pyproject_config)
} else {
commands::format::format(cli, &config_arguments, &pyproject_config)
}
}
fn analyze_graph(
args: AnalyzeGraphCommand,
global_options: GlobalConfigArgs,
) -> Result<ExitStatus> {
let (cli, config_arguments) = args.partition(global_options)?;
commands::analyze_graph::analyze_graph(cli, &config_arguments)
}
fn server(args: ServerCommand) -> Result<ExitStatus> {
commands::server::run_server(args.resolve_preview())
}
pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result<ExitStatus> {
let (cli, config_arguments) = args.partition(global_options)?;
let pyproject_config = resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
let mut writer: Box<dyn Write> = match cli.output_file {
Some(path) if !cli.watch => {
colored::control::set_override(false);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
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,
&config_arguments,
&mut writer,
)?;
return Ok(ExitStatus::Success);
}
if cli.show_files {
commands::show_files::show_files(
&files,
&pyproject_config,
&config_arguments,
&mut writer,
)?;
return Ok(ExitStatus::Success);
}
let Settings {
fix,
fix_only,
unsafe_fixes,
output_format,
show_fixes,
..
} = pyproject_config.settings;
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;
}
#[cfg(debug_assertions)]
if cache {
warn_user!("Detected debug build without --no-cache.");
}
if let Some(reason) = &cli.add_noqa {
if !fix_mode.is_generate() {
warn_user!("--fix is incompatible with --add-noqa.");
}
if reason.contains(['\n', '\r']) {
return Err(anyhow::anyhow!(
"--add-noqa <reason> cannot contain newline characters"
));
}
let reason_opt = (!reason.is_empty()).then_some(reason.as_str());
let modifications =
commands::add_noqa::add_noqa(&files, &pyproject_config, &config_arguments, reason_opt)?;
if modifications > 0 && config_arguments.log_level >= LogLevel::Default {
let s = if modifications == 1 { "" } else { "s" };
#[expect(clippy::print_stderr)]
{
eprintln!("Added {modifications} noqa directive{s}.");
}
}
return Ok(ExitStatus::Success);
}
let printer = Printer::new(
output_format,
config_arguments.log_level,
fix_mode,
unsafe_fixes,
printer_flags,
);
let preview = pyproject_config.settings.linter.preview;
if cli.watch {
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)?;
}
Printer::clear_screen()?;
printer.write_to_user("Starting linter in watch mode...\n");
let diagnostics = commands::check::check(
&files,
&pyproject_config,
&config_arguments,
cache.into(),
noqa.into(),
fix_mode,
unsafe_fixes,
)?;
printer.write_continuously(&mut writer, &diagnostics, preview)?;
let mut pyproject_config = pyproject_config;
loop {
match rx.recv() {
Ok(event) => {
let Some(change_kind) = change_detected(&event?) else {
continue;
};
if matches!(change_kind, ChangeKind::Configuration) {
pyproject_config =
resolve::resolve(&config_arguments, cli.stdin_filename.as_deref())?;
}
Printer::clear_screen()?;
printer.write_to_user("File change detected...\n");
let diagnostics = commands::check::check(
&files,
&pyproject_config,
&config_arguments,
cache.into(),
noqa.into(),
fix_mode,
unsafe_fixes,
)?;
printer.write_continuously(&mut writer, &diagnostics, preview)?;
}
Err(err) => return Err(err.into()),
}
}
} else {
let diagnostics = if is_stdin {
commands::check_stdin::check_stdin(
cli.stdin_filename.map(fs::normalize_path).as_deref(),
&pyproject_config,
&config_arguments,
noqa.into(),
fix_mode,
)?
} else {
commands::check::check(
&files,
&pyproject_config,
&config_arguments,
cache.into(),
noqa.into(),
fix_mode,
unsafe_fixes,
)?
};
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, preview)?;
}
if !cli.exit_zero {
let max_severity = diagnostics
.inner
.iter()
.map(Diagnostic::severity)
.max()
.unwrap_or(Severity::Info);
if max_severity.is_fatal() {
let message = "Panic during linting indicates a bug in Ruff. If you could open an issue at:
https://github.com/astral-sh/ruff/issues/new?title=%5BLinter%20panic%5D
...with the relevant file contents, the `pyproject.toml` settings, and the stack trace above, we'd be very appreciative!
";
error!("{message}");
return Ok(ExitStatus::Error);
}
if cli.diff {
if !diagnostics.fixed.is_empty() {
return Ok(ExitStatus::Failure);
}
} else if fix_only {
if cli.exit_non_zero_on_fix {
if !diagnostics.fixed.is_empty() {
return Ok(ExitStatus::Failure);
}
}
} else {
if cli.exit_non_zero_on_fix {
if !diagnostics.fixed.is_empty() || !diagnostics.inner.is_empty() {
return Ok(ExitStatus::Failure);
}
} else {
if !diagnostics.inner.is_empty() {
return Ok(ExitStatus::Failure);
}
}
}
}
}
Ok(ExitStatus::Success)
}
fn colored_override(
color: Option<TerminalColor>,
env_force_color: Option<OsString>,
) -> Option<bool> {
match color {
Some(TerminalColor::Always) => Some(true),
Some(TerminalColor::Never) => Some(false),
Some(TerminalColor::Auto) | None => {
env_force_color.map(|force_color: OsString| !force_color.is_empty())
}
}
}
#[cfg(test)]
mod test_file_change_detector {
use std::path::PathBuf;
use crate::{ChangeKind, change_detected};
#[test]
fn detect_correct_file_change() {
assert_eq!(
Some(ChangeKind::Configuration),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp/pyproject.toml"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::Configuration),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("pyproject.toml"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::Configuration),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp1/tmp2/tmp3/pyproject.toml"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::Configuration),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp/ruff.toml"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::Configuration),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp/.ruff.toml"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::SourceFile),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp/rule.py"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::SourceFile),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp/rule.pyi"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::Configuration),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("pyproject.toml"),
PathBuf::from("tmp/rule.py"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
Some(ChangeKind::Configuration),
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp/rule.py"),
PathBuf::from("pyproject.toml"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
assert_eq!(
None,
change_detected(¬ify::Event {
kind: notify::EventKind::Create(notify::event::CreateKind::File),
paths: vec![
PathBuf::from("tmp/rule.js"),
PathBuf::from("tmp/bin/ruff.rs"),
],
attrs: notify::event::EventAttributes::default(),
}),
);
}
}
#[cfg(test)]
mod test_set_colored_override {
use crate::{args::TerminalColor, colored_override};
#[test]
fn force_color_env_is_respected() {
assert_eq!(colored_override(None, Some("1".into())), Some(true));
}
#[test]
fn cli_args_takes_precedences_over_force_color_env() {
assert_eq!(
colored_override(Some(TerminalColor::Never), Some("1".into())),
Some(false)
);
assert_eq!(
colored_override(Some(TerminalColor::Always), None),
Some(true)
);
}
}