diff --git a/Cargo.lock b/Cargo.lock index b44f1af78a..24f7e2ccc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2210,6 +2210,7 @@ dependencies = [ "glob", "ignore", "insta", + "is-macro", "itertools", "itoa", "log", diff --git a/crates/ruff_cli/Cargo.toml b/crates/ruff_cli/Cargo.toml index 89d8eb0fe4..9f81c42389 100644 --- a/crates/ruff_cli/Cargo.toml +++ b/crates/ruff_cli/Cargo.toml @@ -47,6 +47,7 @@ colored = { workspace = true } filetime = { workspace = true } glob = { workspace = true } ignore = { workspace = true } +is-macro = { workspace = true } itertools = { workspace = true } itoa = { version = "1.0.6" } log = { workspace = true } diff --git a/crates/ruff_cli/src/args.rs b/crates/ruff_cli/src/args.rs index 76ee670a41..9baedc08ba 100644 --- a/crates/ruff_cli/src/args.rs +++ b/crates/ruff_cli/src/args.rs @@ -329,6 +329,10 @@ pub struct CheckCommand { pub struct FormatCommand { /// List of files or directories to format. pub files: Vec, + /// Avoid writing any formatted files back; instead, exit with a non-zero status code if any + /// files would have been modified, and zero otherwise. + #[arg(long)] + pub check: bool, /// Specify file to write the linter output to (default: stdout). #[arg(short, long)] pub output_file: Option, @@ -480,6 +484,7 @@ impl FormatCommand { pub fn partition(self) -> (FormatArguments, Overrides) { ( FormatArguments { + check: self.check, config: self.config, files: self.files, isolated: self.isolated, @@ -541,6 +546,7 @@ pub struct CheckArguments { /// etc.). #[allow(clippy::struct_excessive_bools)] pub struct FormatArguments { + pub check: bool, pub config: Option, pub files: Vec, pub isolated: bool, diff --git a/crates/ruff_cli/src/commands/format.rs b/crates/ruff_cli/src/commands/format.rs index f95c83436a..8d3657d958 100644 --- a/crates/ruff_cli/src/commands/format.rs +++ b/crates/ruff_cli/src/commands/format.rs @@ -25,6 +25,14 @@ use crate::args::{FormatArguments, Overrides}; use crate::resolve::resolve; use crate::ExitStatus; +#[derive(Debug, Copy, Clone, is_macro::Is)] +pub(crate) enum FormatMode { + /// Write the formatted contents back to the file. + Write, + /// Check if the file is formatted, but do not write the formatted contents back. + Check, +} + /// Format a set of files, and return the exit status. pub(crate) fn format( cli: &FormatArguments, @@ -37,6 +45,11 @@ pub(crate) fn format( overrides, cli.stdin_filename.as_deref(), )?; + let mode = if cli.check { + FormatMode::Check + } else { + FormatMode::Write + }; let (paths, resolver) = python_files_in_path(&cli.files, &pyproject_config, overrides)?; if paths.is_empty() { @@ -56,7 +69,7 @@ pub(crate) fn format( let line_length = resolver.resolve(path, &pyproject_config).line_length; let options = PyFormatOptions::from_source_type(source_type) .with_line_width(LineWidth::from(NonZeroU16::from(line_length))); - format_path(path, options) + format_path(path, options, mode) } else { Ok(FormatResult::Skipped) } @@ -68,6 +81,8 @@ pub(crate) fn format( let duration = start.elapsed(); debug!("Formatted files in: {:?}", duration); + let summary = FormatResultSummary::from(results); + // Report on any errors. if !errors.is_empty() { warn!("Encountered {} errors while formatting:", errors.len()); @@ -86,14 +101,28 @@ pub(crate) fn format( } _ => Box::new(BufWriter::new(io::stdout())), }; - let summary = FormatResultSummary::from(results); - summary.show_user(&mut writer)?; + summary.show_user(&mut writer, mode)?; } - if errors.is_empty() { - Ok(ExitStatus::Success) - } else { - Ok(ExitStatus::Error) + match mode { + FormatMode::Write => { + if errors.is_empty() { + Ok(ExitStatus::Success) + } else { + Ok(ExitStatus::Error) + } + } + FormatMode::Check => { + if errors.is_empty() { + if summary.formatted > 0 { + Ok(ExitStatus::Failure) + } else { + Ok(ExitStatus::Success) + } + } else { + Ok(ExitStatus::Error) + } + } } } @@ -101,6 +130,7 @@ pub(crate) fn format( fn format_path( path: &Path, options: PyFormatOptions, + mode: FormatMode, ) -> Result { let unformatted = std::fs::read_to_string(path) .map_err(|err| FormatterIterationError::Read(path.to_path_buf(), err))?; @@ -114,8 +144,10 @@ fn format_path( if formatted.len() == unformatted.len() && formatted == unformatted { Ok(FormatResult::Unchanged) } else { - std::fs::write(path, formatted.as_bytes()) - .map_err(|err| FormatterIterationError::Write(path.to_path_buf(), err))?; + if mode.is_write() { + std::fs::write(path, formatted.as_bytes()) + .map_err(|err| FormatterIterationError::Write(path.to_path_buf(), err))?; + } Ok(FormatResult::Formatted) } } @@ -156,22 +188,30 @@ impl From> for FormatResultSummary { impl FormatResultSummary { /// Pretty-print a [`FormatResultSummary`] for user-facing display. - fn show_user(&self, writer: &mut dyn Write) -> Result<(), io::Error> { + fn show_user(&self, writer: &mut dyn Write, mode: FormatMode) -> Result<(), io::Error> { if self.formatted > 0 && self.unchanged > 0 { writeln!( writer, - "{} file{} reformatted, {} file{} left unchanged", + "{} file{} {}, {} file{} left unchanged", self.formatted, if self.formatted == 1 { "" } else { "s" }, + match mode { + FormatMode::Write => "reformatted", + FormatMode::Check => "would be reformatted", + }, self.unchanged, if self.unchanged == 1 { "" } else { "s" }, ) } else if self.formatted > 0 { writeln!( writer, - "{} file{} reformatted", + "{} file{} {}", self.formatted, if self.formatted == 1 { "" } else { "s" }, + match mode { + FormatMode::Write => "reformatted", + FormatMode::Check => "would be reformatted", + } ) } else if self.unchanged > 0 { writeln!( diff --git a/crates/ruff_cli/src/commands/format_stdin.rs b/crates/ruff_cli/src/commands/format_stdin.rs index aa8ff2678c..be6d753ea0 100644 --- a/crates/ruff_cli/src/commands/format_stdin.rs +++ b/crates/ruff_cli/src/commands/format_stdin.rs @@ -6,6 +6,7 @@ use ruff_python_formatter::{format_module, PyFormatOptions}; use ruff_workspace::resolver::python_file_at_path; use crate::args::{FormatArguments, Overrides}; +use crate::commands::format::FormatMode; use crate::resolve::resolve; use crate::stdin::read_from_stdin; use crate::ExitStatus; @@ -18,6 +19,11 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &Overrides) -> Resu overrides, cli.stdin_filename.as_deref(), )?; + let mode = if cli.check { + FormatMode::Check + } else { + FormatMode::Write + }; if let Some(filename) = cli.stdin_filename.as_deref() { if !python_file_at_path(filename, &pyproject_config, overrides)? { @@ -25,13 +31,27 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &Overrides) -> Resu } } - let stdin = read_from_stdin()?; + // Format the file. + let unformatted = read_from_stdin()?; let options = cli .stdin_filename .as_deref() .map(PyFormatOptions::from_extension) .unwrap_or_default(); - let formatted = format_module(&stdin, options)?; - stdout().lock().write_all(formatted.as_code().as_bytes())?; - Ok(ExitStatus::Success) + let formatted = format_module(&unformatted, options)?; + + match mode { + FormatMode::Write => { + stdout().lock().write_all(formatted.as_code().as_bytes())?; + Ok(ExitStatus::Success) + } + FormatMode::Check => { + if formatted.as_code().len() == unformatted.len() && formatted.as_code() == unformatted + { + Ok(ExitStatus::Success) + } else { + Ok(ExitStatus::Failure) + } + } + } }