Skip to content

Commit

Permalink
Add prototype of ruff format for projects
Browse files Browse the repository at this point in the history
**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.

Error handling works in my manual tests:

```
$  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)

```
$ 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.
  • Loading branch information
konstin committed Aug 25, 2023
1 parent 87ed83b commit e66ea4f
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 16 deletions.
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/ruff_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ doc = false
ruff = { path = "../ruff", features = ["clap"] }
ruff_cache = { path = "../ruff_cache" }
ruff_diagnostics = { path = "../ruff_diagnostics" }
ruff_formatter = { path = "../ruff_formatter" }
ruff_macros = { path = "../ruff_macros" }
ruff_python_ast = { path = "../ruff_python_ast" }
ruff_python_formatter = { path = "../ruff_python_formatter" }
Expand Down Expand Up @@ -59,6 +60,8 @@ serde_json = { workspace = true }
shellexpand = { workspace = true }
similar = { workspace = true }
strum = { workspace = true, features = [] }
thiserror = { workspace = true }
tracing = { workspace = true }
walkdir = { version = "2.3.2" }
wild = { version = "2" }

Expand Down
104 changes: 104 additions & 0 deletions crates/ruff_cli/src/commands/format.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#![allow(clippy::print_stderr)]

use crate::args::{Arguments, Overrides};
use crate::resolve::resolve;
use crate::ExitStatus;
use anyhow::bail;
use colored::Colorize;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use ruff::resolver::python_files_in_path;
use ruff::settings::types::{FilePattern, FilePatternSet};
use ruff_formatter::LineWidth;
use ruff_python_formatter::{format_module, FormatModuleError, PyFormatOptions};
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::span;
use tracing::Level;

/// The inner errors are all flat, i.e. none of them has a source
#[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),
}

pub(crate) fn format(cli: &Arguments, overrides: &Overrides) -> anyhow::Result<ExitStatus> {
let mut pyproject_config = resolve(
cli.isolated,
cli.config.as_deref(),
overrides,
cli.stdin_filename.as_deref(),
)?;
// We don't want to format pyproject.toml
pyproject_config.settings.lib.include = FilePatternSet::try_from_vec(vec![
FilePattern::Builtin("*.py"),
FilePattern::Builtin("*.pyi"),
])
.unwrap();
let (paths, resolver) = python_files_in_path(&cli.files, &pyproject_config, overrides)?;
if paths.is_empty() {
bail!("no python files in TODO(@konstin) pass them in")
}

let results: Vec<Result<(), FormatterIterationError>> = paths
.into_par_iter()
.map(|dir_entry| {
let dir_entry = dir_entry?;
// TODO(@konstin): For some reason it does not filter in the beginning
// TODO(@konstin): When integrating with the linter, how do we get the filtering right?
let path = dir_entry.path();
if dir_entry.file_name() == "pyproject.toml"
|| path.extension().is_some_and(|ext| ext == "ipynb")
{
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)
})
.collect();

let mut all_success = true;
for result in results {
if let Err(err) = result {
// The inner errors are all flat, i.e. none of them has a source
eprintln!("{}", err.to_string().red().bold());
all_success = false;
}
}

if all_success {
Ok(ExitStatus::Success)
} else {
Ok(ExitStatus::Error)
}
}

#[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(())
}
1 change: 1 addition & 0 deletions crates/ruff_cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
31 changes: 17 additions & 14 deletions crates/ruff_cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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> {
Expand Down
9 changes: 9 additions & 0 deletions crates/ruff_python_formatter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
The goal of our formatter is to be compatible with Black except for rare edge cases (mostly
involving comment placement).

You can try an experimental version of the formatter on your project with:

```shell
cargo run --bin ruff -- format path/to/your/project
```

Note that currently the only supported option is `line-length` and that both the CLI and the
formatting are a work-in-progress and will change before the stable release.

## Dev tools

**Testing your changes** You can use the `ruff_python_formatter` binary to format individual files
Expand Down
4 changes: 2 additions & 2 deletions crates/ruff_python_formatter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ where

#[derive(Error, Debug)]
pub enum FormatModuleError {
#[error("source contains syntax errors (lexer error): {0:?}")]
#[error("source contains syntax errors: {0:?}")]
LexError(LexicalError),
#[error("source contains syntax errors (parser error): {0:?}")]
#[error("source contains syntax errors: {0:?}")]
ParseError(ParseError),
#[error(transparent)]
FormatError(#[from] FormatError),
Expand Down

0 comments on commit e66ea4f

Please sign in to comment.