Add prototype of ruff format for projects (#6871)

**Summary** Add recursive formatting based on `ruff check` file
discovery for `ruff format`, as a prototype for the formatter alpha.
This allows e.g. `format ../projects/django/`. It's still lacking
support for any settings except line length.

Note just like the existing `ruff format` this will become part of the
production build, i.e. you'll be able to use it - hidden by default and
with a prominent warning - with `ruff format .` after the next release.

Error handling works in my manual tests (the colors do also work):

```
$  target/debug/ruff format scripts/
warning: `ruff format` is a work-in-progress, subject to change at any time, and intended for internal use only.
```
(the above changes `add_rule.py` where we have the wrong bin op
breaking)

```
$ target/debug/ruff format ../projects/django/
warning: `ruff format` is a work-in-progress, subject to change at any time, and intended for internal use only.
Failed to format /home/konsti/projects/django/tests/test_runner_apps/tagged/tests_syntax_error.py: source contains syntax errors: ParseError { error: UnrecognizedToken(Name { name: "syntax_error" }, None), offset: 131, source_path: "<filename>" }
```

```
$ target/debug/ruff format a
warning: `ruff format` is a work-in-progress, subject to change at any time, and intended for internal use only.
Failed to read /home/konsti/ruff/a/d.py: Permission denied (os error 13)
```

**Test Plan** Missing! I'm not sure if it's worth building tests at this
stage or how they should look like.
This commit is contained in:
konsti 2023-08-27 21:12:18 +02:00 committed by GitHub
parent 059757a8c8
commit c2413dcd2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 142 additions and 16 deletions

View file

@ -0,0 +1,107 @@
use std::io;
use std::path::{Path, PathBuf};
use anyhow::Result;
use colored::Colorize;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use thiserror::Error;
use tracing::{span, Level};
use ruff::resolver::python_files_in_path;
use ruff::warn_user_once;
use ruff_formatter::LineWidth;
use ruff_python_ast::PySourceType;
use ruff_python_formatter::{format_module, FormatModuleError, PyFormatOptions};
use crate::args::{Arguments, Overrides};
use crate::resolve::resolve;
use crate::ExitStatus;
/// Format a set of files, and return the exit status.
pub(crate) fn format(cli: &Arguments, overrides: &Overrides) -> Result<ExitStatus> {
let pyproject_config = resolve(
cli.isolated,
cli.config.as_deref(),
overrides,
cli.stdin_filename.as_deref(),
)?;
let (paths, resolver) = python_files_in_path(&cli.files, &pyproject_config, overrides)?;
if paths.is_empty() {
warn_user_once!("No Python files found under the given path(s)");
return Ok(ExitStatus::Success);
}
let all_success = paths
.into_par_iter()
.map(|dir_entry| {
let dir_entry = dir_entry?;
let path = dir_entry.path();
let source_type = PySourceType::from(path);
if !(source_type.is_python() || source_type.is_stub())
|| path
.extension()
.is_some_and(|extension| extension == "toml")
{
return Ok(());
}
let line_length = resolver.resolve(path, &pyproject_config).line_length;
// TODO(konstin): Unify `LineWidth` and `LineLength`
let line_width = LineWidth::try_from(
u16::try_from(line_length.get()).expect("Line shouldn't be larger than 2**16"),
)
.expect("Configured line length is too large for the formatter");
let options = PyFormatOptions::from_extension(path).with_line_width(line_width);
format_path(path, options)
})
.map(|result| {
match result {
Ok(()) => true,
Err(err) => {
// The inner errors are all flat, i.e., none of them has a source.
#[allow(clippy::print_stderr)]
{
eprintln!("{}", err.to_string().red().bold());
}
false
}
}
})
.all(|success| success);
if all_success {
Ok(ExitStatus::Success)
} else {
Ok(ExitStatus::Error)
}
}
/// An error that can occur while formatting a set of files.
#[derive(Error, Debug)]
enum FormatterIterationError {
#[error("Failed to traverse the inputs paths: {0}")]
Ignore(#[from] ignore::Error),
#[error("Failed to read {0}: {1}")]
Read(PathBuf, io::Error),
#[error("Failed to write {0}: {1}")]
Write(PathBuf, io::Error),
#[error("Failed to format {0}: {1}")]
FormatModule(PathBuf, FormatModuleError),
}
#[tracing::instrument(skip_all, fields(path = %path.display()))]
fn format_path(path: &Path, options: PyFormatOptions) -> Result<(), FormatterIterationError> {
let unformatted = std::fs::read_to_string(path)
.map_err(|err| FormatterIterationError::Read(path.to_path_buf(), err))?;
let formatted = {
let span = span!(Level::TRACE, "format_path_without_io", path = %path.display());
let _enter = span.enter();
format_module(&unformatted, options)
.map_err(|err| FormatterIterationError::FormatModule(path.to_path_buf(), err))?
};
std::fs::write(path, formatted.as_code().as_bytes())
.map_err(|err| FormatterIterationError::Write(path.to_path_buf(), err))?;
Ok(())
}

View file

@ -1,6 +1,7 @@
pub(crate) mod add_noqa;
pub(crate) mod clean;
pub(crate) mod config;
pub(crate) mod format;
pub(crate) mod linter;
pub(crate) mod rule;
pub(crate) mod run;

View file

@ -4,8 +4,9 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::mpsc::channel;
use anyhow::{Context, Result};
use anyhow::Result;
use clap::CommandFactory;
use clap::FromArgMatches;
use log::warn;
use notify::{recommended_watcher, RecursiveMode, Watcher};
@ -154,32 +155,34 @@ quoting the executed command, along with the relevant file contents and `pyproje
Ok(ExitStatus::Success)
}
fn format(files: &[PathBuf]) -> Result<ExitStatus> {
fn format(paths: &[PathBuf]) -> Result<ExitStatus> {
warn_user_once!(
"`ruff format` is a work-in-progress, subject to change at any time, and intended for \
internal use only."
"`ruff format` is a work-in-progress, subject to change at any time, and intended only for \
experimentation."
);
match &files {
match &paths {
// Check if we should read from stdin
[path] if path == Path::new("-") => {
let unformatted = read_from_stdin()?;
let options = PyFormatOptions::from_extension(Path::new("stdin.py"));
let formatted = format_module(&unformatted, options)?;
stdout().lock().write_all(formatted.as_code().as_bytes())?;
Ok(ExitStatus::Success)
}
_ => {
for file in files {
let unformatted = std::fs::read_to_string(file)
.with_context(|| format!("Could not read {}: ", file.display()))?;
let options = PyFormatOptions::from_extension(file);
let formatted = format_module(&unformatted, options)?;
std::fs::write(file, formatted.as_code().as_bytes())
.with_context(|| format!("Could not write to {}, exiting", file.display()))?;
}
// We want to use the same as `ruff check <files>`, but we don't actually want to allow
// any of the linter settings.
// TODO(@konstin): Refactor this to allow getting config and resolver without going
// though clap.
let args_matches = CheckArgs::command()
.no_binary_name(true)
.get_matches_from(paths);
let check_args: CheckArgs = CheckArgs::from_arg_matches(&args_matches)?;
let (cli, overrides) = check_args.partition();
commands::format::format(&cli, &overrides)
}
}
Ok(ExitStatus::Success)
}
pub fn check(args: CheckArgs, log_level: LogLevel) -> Result<ExitStatus> {