Add a --check flag to the formatter CLI (#6982)

## Summary

Returns an exit code of 1 if any files would be reformatted:

```
ruff on  charlie/format-check:main [$?⇡] is 📦 v0.0.286 via 🐍 v3.11.2 via 🦀 v1.72.0
❯ cargo run -p ruff_cli -- format foo.py --check
   Compiling ruff_cli v0.0.286 (/Users/crmarsh/workspace/ruff/crates/ruff_cli)
    Finished dev [unoptimized + debuginfo] target(s) in 1.69s
     Running `target/debug/ruff format foo.py --check`
warning: `ruff format` is a work-in-progress, subject to change at any time, and intended only for experimentation.
1 file would be reformatted
ruff on  charlie/format-check:main [$?⇡] is 📦 v0.0.286 via 🐍 v3.11.2 via 🦀 v1.72.0 took 2s
❯ echo $?
1
```

Closes #6966.
This commit is contained in:
Charlie Marsh 2023-08-29 12:40:00 -04:00 committed by GitHub
parent 25c374856a
commit fad23bbe60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 84 additions and 16 deletions

1
Cargo.lock generated
View file

@ -2210,6 +2210,7 @@ dependencies = [
"glob", "glob",
"ignore", "ignore",
"insta", "insta",
"is-macro",
"itertools", "itertools",
"itoa", "itoa",
"log", "log",

View file

@ -47,6 +47,7 @@ colored = { workspace = true }
filetime = { workspace = true } filetime = { workspace = true }
glob = { workspace = true } glob = { workspace = true }
ignore = { workspace = true } ignore = { workspace = true }
is-macro = { workspace = true }
itertools = { workspace = true } itertools = { workspace = true }
itoa = { version = "1.0.6" } itoa = { version = "1.0.6" }
log = { workspace = true } log = { workspace = true }

View file

@ -329,6 +329,10 @@ pub struct CheckCommand {
pub struct FormatCommand { pub struct FormatCommand {
/// List of files or directories to format. /// List of files or directories to format.
pub files: Vec<PathBuf>, pub files: Vec<PathBuf>,
/// 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). /// Specify file to write the linter output to (default: stdout).
#[arg(short, long)] #[arg(short, long)]
pub output_file: Option<PathBuf>, pub output_file: Option<PathBuf>,
@ -480,6 +484,7 @@ impl FormatCommand {
pub fn partition(self) -> (FormatArguments, Overrides) { pub fn partition(self) -> (FormatArguments, Overrides) {
( (
FormatArguments { FormatArguments {
check: self.check,
config: self.config, config: self.config,
files: self.files, files: self.files,
isolated: self.isolated, isolated: self.isolated,
@ -541,6 +546,7 @@ pub struct CheckArguments {
/// etc.). /// etc.).
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
pub struct FormatArguments { pub struct FormatArguments {
pub check: bool,
pub config: Option<PathBuf>, pub config: Option<PathBuf>,
pub files: Vec<PathBuf>, pub files: Vec<PathBuf>,
pub isolated: bool, pub isolated: bool,

View file

@ -25,6 +25,14 @@ use crate::args::{FormatArguments, Overrides};
use crate::resolve::resolve; use crate::resolve::resolve;
use crate::ExitStatus; 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. /// Format a set of files, and return the exit status.
pub(crate) fn format( pub(crate) fn format(
cli: &FormatArguments, cli: &FormatArguments,
@ -37,6 +45,11 @@ pub(crate) fn format(
overrides, overrides,
cli.stdin_filename.as_deref(), 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)?; let (paths, resolver) = python_files_in_path(&cli.files, &pyproject_config, overrides)?;
if paths.is_empty() { if paths.is_empty() {
@ -56,7 +69,7 @@ pub(crate) fn format(
let line_length = resolver.resolve(path, &pyproject_config).line_length; let line_length = resolver.resolve(path, &pyproject_config).line_length;
let options = PyFormatOptions::from_source_type(source_type) let options = PyFormatOptions::from_source_type(source_type)
.with_line_width(LineWidth::from(NonZeroU16::from(line_length))); .with_line_width(LineWidth::from(NonZeroU16::from(line_length)));
format_path(path, options) format_path(path, options, mode)
} else { } else {
Ok(FormatResult::Skipped) Ok(FormatResult::Skipped)
} }
@ -68,6 +81,8 @@ pub(crate) fn format(
let duration = start.elapsed(); let duration = start.elapsed();
debug!("Formatted files in: {:?}", duration); debug!("Formatted files in: {:?}", duration);
let summary = FormatResultSummary::from(results);
// Report on any errors. // Report on any errors.
if !errors.is_empty() { if !errors.is_empty() {
warn!("Encountered {} errors while formatting:", errors.len()); warn!("Encountered {} errors while formatting:", errors.len());
@ -86,14 +101,28 @@ pub(crate) fn format(
} }
_ => Box::new(BufWriter::new(io::stdout())), _ => Box::new(BufWriter::new(io::stdout())),
}; };
let summary = FormatResultSummary::from(results); summary.show_user(&mut writer, mode)?;
summary.show_user(&mut writer)?;
} }
if errors.is_empty() { match mode {
Ok(ExitStatus::Success) FormatMode::Write => {
} else { if errors.is_empty() {
Ok(ExitStatus::Error) 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( fn format_path(
path: &Path, path: &Path,
options: PyFormatOptions, options: PyFormatOptions,
mode: FormatMode,
) -> Result<FormatResult, FormatterIterationError> { ) -> Result<FormatResult, FormatterIterationError> {
let unformatted = std::fs::read_to_string(path) let unformatted = std::fs::read_to_string(path)
.map_err(|err| FormatterIterationError::Read(path.to_path_buf(), err))?; .map_err(|err| FormatterIterationError::Read(path.to_path_buf(), err))?;
@ -114,8 +144,10 @@ fn format_path(
if formatted.len() == unformatted.len() && formatted == unformatted { if formatted.len() == unformatted.len() && formatted == unformatted {
Ok(FormatResult::Unchanged) Ok(FormatResult::Unchanged)
} else { } else {
std::fs::write(path, formatted.as_bytes()) if mode.is_write() {
.map_err(|err| FormatterIterationError::Write(path.to_path_buf(), err))?; std::fs::write(path, formatted.as_bytes())
.map_err(|err| FormatterIterationError::Write(path.to_path_buf(), err))?;
}
Ok(FormatResult::Formatted) Ok(FormatResult::Formatted)
} }
} }
@ -156,22 +188,30 @@ impl From<Vec<FormatResult>> for FormatResultSummary {
impl FormatResultSummary { impl FormatResultSummary {
/// Pretty-print a [`FormatResultSummary`] for user-facing display. /// 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 { if self.formatted > 0 && self.unchanged > 0 {
writeln!( writeln!(
writer, writer,
"{} file{} reformatted, {} file{} left unchanged", "{} file{} {}, {} file{} left unchanged",
self.formatted, self.formatted,
if self.formatted == 1 { "" } else { "s" }, if self.formatted == 1 { "" } else { "s" },
match mode {
FormatMode::Write => "reformatted",
FormatMode::Check => "would be reformatted",
},
self.unchanged, self.unchanged,
if self.unchanged == 1 { "" } else { "s" }, if self.unchanged == 1 { "" } else { "s" },
) )
} else if self.formatted > 0 { } else if self.formatted > 0 {
writeln!( writeln!(
writer, writer,
"{} file{} reformatted", "{} file{} {}",
self.formatted, self.formatted,
if self.formatted == 1 { "" } else { "s" }, if self.formatted == 1 { "" } else { "s" },
match mode {
FormatMode::Write => "reformatted",
FormatMode::Check => "would be reformatted",
}
) )
} else if self.unchanged > 0 { } else if self.unchanged > 0 {
writeln!( writeln!(

View file

@ -6,6 +6,7 @@ use ruff_python_formatter::{format_module, PyFormatOptions};
use ruff_workspace::resolver::python_file_at_path; use ruff_workspace::resolver::python_file_at_path;
use crate::args::{FormatArguments, Overrides}; use crate::args::{FormatArguments, Overrides};
use crate::commands::format::FormatMode;
use crate::resolve::resolve; use crate::resolve::resolve;
use crate::stdin::read_from_stdin; use crate::stdin::read_from_stdin;
use crate::ExitStatus; use crate::ExitStatus;
@ -18,6 +19,11 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &Overrides) -> Resu
overrides, overrides,
cli.stdin_filename.as_deref(), 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 let Some(filename) = cli.stdin_filename.as_deref() {
if !python_file_at_path(filename, &pyproject_config, overrides)? { 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 let options = cli
.stdin_filename .stdin_filename
.as_deref() .as_deref()
.map(PyFormatOptions::from_extension) .map(PyFormatOptions::from_extension)
.unwrap_or_default(); .unwrap_or_default();
let formatted = format_module(&stdin, options)?; let formatted = format_module(&unformatted, options)?;
stdout().lock().write_all(formatted.as_code().as_bytes())?;
Ok(ExitStatus::Success) 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)
}
}
}
} }