diff --git a/Cargo.lock b/Cargo.lock index f6fd46fbfecbf8..2f94694cc505f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2336,6 +2336,7 @@ dependencies = [ "ruff_source_file", "ruff_text_size", "rustc-hash", + "schemars", "serde", "serde_json", "similar", @@ -2523,7 +2524,9 @@ dependencies = [ "ruff_formatter", "ruff_linter", "ruff_macros", + "ruff_python_ast", "ruff_python_formatter", + "ruff_source_file", "rustc-hash", "schemars", "serde", diff --git a/crates/ruff_cli/src/commands/format.rs b/crates/ruff_cli/src/commands/format.rs index 1cf389062e2780..a8a353e670d6ce 100644 --- a/crates/ruff_cli/src/commands/format.rs +++ b/crates/ruff_cli/src/commands/format.rs @@ -15,9 +15,9 @@ use ruff_linter::fs; use ruff_linter::logging::LogLevel; use ruff_linter::warn_user_once; use ruff_python_ast::{PySourceType, SourceType}; -use ruff_python_formatter::{format_module, FormatModuleError, PyFormatOptions}; -use ruff_source_file::{find_newline, LineEnding}; +use ruff_python_formatter::{format_module, FormatModuleError}; use ruff_workspace::resolver::python_files_in_path; +use ruff_workspace::FormatterSettings; use crate::args::{CliOverrides, FormatArguments}; use crate::panic::{catch_unwind, PanicError}; @@ -73,15 +73,17 @@ pub(crate) fn format( }; let resolved_settings = resolver.resolve(path, &pyproject_config); - let options = resolved_settings.formatter.to_format_options(source_type); - debug!("Formatting {} with {:?}", path.display(), options); - Some(match catch_unwind(|| format_path(path, options, mode)) { - Ok(inner) => inner, - Err(error) => { - Err(FormatCommandError::Panic(Some(path.to_path_buf()), error)) - } - }) + Some( + match catch_unwind(|| { + format_path(path, &resolved_settings.formatter, source_type, mode) + }) { + Ok(inner) => inner, + Err(error) => { + Err(FormatCommandError::Panic(Some(path.to_path_buf()), error)) + } + }, + ) } Err(err) => Some(Err(FormatCommandError::Ignore(err))), } @@ -139,19 +141,15 @@ pub(crate) fn format( #[tracing::instrument(skip_all, fields(path = %path.display()))] fn format_path( path: &Path, - options: PyFormatOptions, + settings: &FormatterSettings, + source_type: PySourceType, mode: FormatMode, ) -> Result { let unformatted = std::fs::read_to_string(path) .map_err(|err| FormatCommandError::Read(Some(path.to_path_buf()), err))?; - let line_ending = match find_newline(&unformatted) { - Some((_, LineEnding::Lf)) | None => ruff_formatter::printer::LineEnding::LineFeed, - Some((_, LineEnding::Cr)) => ruff_formatter::printer::LineEnding::CarriageReturn, - Some((_, LineEnding::CrLf)) => ruff_formatter::printer::LineEnding::CarriageReturnLineFeed, - }; - - let options = options.with_line_ending(line_ending); + let options = settings.to_format_options(source_type, &unformatted); + debug!("Formatting {} with {:?}", path.display(), options); let formatted = format_module(&unformatted, options) .map_err(|err| FormatCommandError::FormatModule(Some(path.to_path_buf()), err))?; diff --git a/crates/ruff_cli/src/commands/format_stdin.rs b/crates/ruff_cli/src/commands/format_stdin.rs index f4d8a6b089e5b6..ed897a3111e200 100644 --- a/crates/ruff_cli/src/commands/format_stdin.rs +++ b/crates/ruff_cli/src/commands/format_stdin.rs @@ -5,8 +5,9 @@ use anyhow::Result; use log::warn; use ruff_python_ast::PySourceType; -use ruff_python_formatter::{format_module, PyFormatOptions}; +use ruff_python_formatter::format_module; use ruff_workspace::resolver::python_file_at_path; +use ruff_workspace::FormatterSettings; use crate::args::{CliOverrides, FormatArguments}; use crate::commands::format::{FormatCommandError, FormatCommandResult, FormatMode}; @@ -37,12 +38,7 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &CliOverrides) -> R // Format the file. let path = cli.stdin_filename.as_deref(); - let options = pyproject_config - .settings - .formatter - .to_format_options(path.map(PySourceType::from).unwrap_or_default()); - - match format_source(path, options, mode) { + match format_source(path, &pyproject_config.settings.formatter, mode) { Ok(result) => match mode { FormatMode::Write => Ok(ExitStatus::Success), FormatMode::Check => { @@ -63,11 +59,17 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &CliOverrides) -> R /// Format source code read from `stdin`. fn format_source( path: Option<&Path>, - options: PyFormatOptions, + settings: &FormatterSettings, mode: FormatMode, ) -> Result { let unformatted = read_from_stdin() .map_err(|err| FormatCommandError::Read(path.map(Path::to_path_buf), err))?; + + let options = settings.to_format_options( + path.map(PySourceType::from).unwrap_or_default(), + &unformatted, + ); + let formatted = format_module(&unformatted, options) .map_err(|err| FormatCommandError::FormatModule(path.map(Path::to_path_buf), err))?; let formatted = formatted.as_code(); diff --git a/crates/ruff_cli/tests/format.rs b/crates/ruff_cli/tests/format.rs new file mode 100644 index 00000000000000..c51a521816f1b9 --- /dev/null +++ b/crates/ruff_cli/tests/format.rs @@ -0,0 +1,207 @@ +#![cfg(not(target_family = "wasm"))] + +use std::fs; +use std::process::Command; +use std::str; + +use anyhow::Result; +use insta_cmd::{assert_cmd_snapshot, get_cargo_bin}; +use tempfile::TempDir; + +const BIN_NAME: &str = "ruff"; + +#[test] +fn default_options() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print('Should\'t change quotes') + + +if condition: + + print('Hy "Micha"') # Should not change quotes + +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + def foo( + arg1, + arg2, + ): + print("Should't change quotes") + + + if condition: + print('Hy "Micha"') # Should not change quotes + + ----- stderr ----- + warning: `ruff format` is a work-in-progress, subject to change at any time, and intended only for experimentation. + "###); +} + +#[test] +fn format_options() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +[format] +indent-style = "tab" +quote-style = "single" +skip-magic-trailing-comma = true +line-ending = "cr-lf" +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--config"]) + .arg(&ruff_toml) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Shouldn't change quotes") + + +if condition: + + print("Should change quotes") + +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + def foo(arg1, arg2): + print("Shouldn't change quotes") + + + if condition: + print('Should change quotes') + + ----- stderr ----- + warning: `ruff format` is a work-in-progress, subject to change at any time, and intended only for experimentation. + "###); + Ok(()) +} + +#[test] +fn format_option_inheritance() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + let base_toml = tempdir.path().join("base.toml"); + fs::write( + &ruff_toml, + r#" +extend = "base.toml" + +[format] +quote-style = "single" +"#, + )?; + + fs::write( + base_toml, + r#" +[format] +indent-style = "tab" +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--config"]) + .arg(&ruff_toml) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Shouldn't change quotes") + + +if condition: + + print("Should change quotes") + +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + def foo( + arg1, + arg2, + ): + print("Shouldn't change quotes") + + + if condition: + print('Should change quotes') + + ----- stderr ----- + warning: `ruff format` is a work-in-progress, subject to change at any time, and intended only for experimentation. + "###); + Ok(()) +} + +/// Tests that the legacy `format` option continues to work but emits a warning. +#[test] +fn legacy_format_option() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +format = "json" +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["check", "--select", "F401", "--no-cache", "--config"]) + .arg(&ruff_toml) + .arg("-") + .pass_stdin(r#" +import os +"#), @r###" + success: false + exit_code: 1 + ----- stdout ----- + [ + { + "code": "F401", + "end_location": { + "column": 10, + "row": 2 + }, + "filename": "-", + "fix": { + "applicability": "Automatic", + "edits": [ + { + "content": "", + "end_location": { + "column": 1, + "row": 3 + }, + "location": { + "column": 1, + "row": 2 + } + } + ], + "message": "Remove unused import: `os`" + }, + "location": { + "column": 8, + "row": 2 + }, + "message": "`os` imported but unused", + "noqa_row": 2, + "url": "https://docs.astral.sh/ruff/rules/unused-import" + } + ] + ----- stderr ----- + warning: The option `format` has been deprecated to avoid ambiguity with Ruff's upcoming formatter. Use `output-format` instead. + "###); + Ok(()) +} diff --git a/crates/ruff_formatter/src/lib.rs b/crates/ruff_formatter/src/lib.rs index 63c4a5a6042a2c..6cd63f3e7c387c 100644 --- a/crates/ruff_formatter/src/lib.rs +++ b/crates/ruff_formatter/src/lib.rs @@ -55,7 +55,11 @@ use ruff_macros::CacheKey; use ruff_text_size::{TextRange, TextSize}; #[derive(Debug, Eq, PartialEq, Clone, Copy, Hash, CacheKey)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "kebab-case") +)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Default)] pub enum IndentStyle { diff --git a/crates/ruff_formatter/src/printer/printer_options/mod.rs b/crates/ruff_formatter/src/printer/printer_options/mod.rs index e3fca43c2bee88..efbe850cbf22ef 100644 --- a/crates/ruff_formatter/src/printer/printer_options/mod.rs +++ b/crates/ruff_formatter/src/printer/printer_options/mod.rs @@ -1,5 +1,4 @@ use crate::{FormatOptions, IndentStyle, IndentWidth, LineWidth}; -use ruff_macros::CacheKey; /// Options that affect how the [`crate::Printer`] prints the format tokens #[derive(Clone, Debug, Eq, PartialEq, Default)] @@ -121,7 +120,7 @@ impl SourceMapGeneration { } } -#[derive(Copy, Clone, Debug, Eq, PartialEq, Default, CacheKey)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum LineEnding { /// Line Feed only (\n), common on Linux and macOS as well as inside git repos diff --git a/crates/ruff_python_formatter/Cargo.toml b/crates/ruff_python_formatter/Cargo.toml index 639e350d1f6d3b..75782a5d25ba8f 100644 --- a/crates/ruff_python_formatter/Cargo.toml +++ b/crates/ruff_python_formatter/Cargo.toml @@ -30,6 +30,7 @@ memchr = { workspace = true } once_cell = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, optional = true } +schemars = { workspace = true, optional = true } smallvec = { workspace = true } static_assertions = { workspace = true } thiserror = { workspace = true } @@ -52,4 +53,5 @@ required-features = ["serde"] [features] serde = ["dep:serde", "ruff_formatter/serde", "ruff_source_file/serde", "ruff_python_ast/serde"] -default = ["serde"] +schemars = ["dep:schemars", "ruff_formatter/schemars"] +default = [] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring.options.json index 0e595f5597d92e..28553e727b70fa 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/docstring.options.json @@ -1,18 +1,18 @@ [ { - "indent_style": "Space", + "indent_style": "space", "indent_width": 4 }, { - "indent_style": "Space", + "indent_style": "space", "indent_width": 2 }, { - "indent_style": "Tab", + "indent_style": "tab", "indent_width": 8 }, { - "indent_style": "Tab", + "indent_style": "tab", "indent_width": 4 } ] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/fmt_off_docstring.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/fmt_off_docstring.options.json index 3bcc422e22fa03..5545f7eb218272 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/fmt_off_docstring.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/fmt_off_docstring.options.json @@ -1,10 +1,10 @@ [ { - "indent_style": "Space", + "indent_style": "space", "indent_width": 4 }, { - "indent_style": "Space", + "indent_style": "space", "indent_width": 2 } ] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/indent.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/indent.options.json index ae4c01b2503abe..fbfb763c1aab05 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/indent.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/indent.options.json @@ -1,13 +1,13 @@ [ { - "indent_style": "Space", + "indent_style": "space", "indent_width": 4 }, { - "indent_style": "Space", + "indent_style": "space", "indent_width": 1 }, { - "indent_style": "Tab" + "indent_style": "tab" } ] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/mixed_space_and_tab.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/mixed_space_and_tab.options.json index 769ca99e8784f1..6384a00fe76459 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/mixed_space_and_tab.options.json +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fmt_on_off/mixed_space_and_tab.options.json @@ -1,13 +1,13 @@ [ { - "indent_style": "Space", + "indent_style": "space", "indent_width": 4 }, { - "indent_style": "Space", + "indent_style": "space", "indent_width": 2 }, { - "indent_style": "Tab" + "indent_style": "tab" } ] diff --git a/crates/ruff_python_formatter/src/lib.rs b/crates/ruff_python_formatter/src/lib.rs index 09d3a07d7858f4..5ba8e3dde02789 100644 --- a/crates/ruff_python_formatter/src/lib.rs +++ b/crates/ruff_python_formatter/src/lib.rs @@ -17,7 +17,6 @@ use crate::comments::{ pub use crate::context::PyFormatContext; pub use crate::options::{MagicTrailingComma, PreviewMode, PyFormatOptions, QuoteStyle}; use crate::verbatim::suppressed_node; -pub use settings::FormatterSettings; pub(crate) mod builders; pub mod cli; @@ -30,7 +29,6 @@ mod options; pub(crate) mod other; pub(crate) mod pattern; mod prelude; -mod settings; pub(crate) mod statement; pub(crate) mod type_param; mod verbatim; diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index 7d0ae3d0b218f3..c6eec75310ccf4 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -5,8 +5,8 @@ use ruff_python_ast::PySourceType; use std::path::Path; use std::str::FromStr; -/// Resolved options for formatting one individual file. This is different from [`crate::FormatterSettings`] which -/// represents the formatting settings for multiple files (the whole project, a subdirectory, ...) +/// Resolved options for formatting one individual file. The difference to `FormatterSettings` +/// is that `FormatterSettings` stores the settings for multiple files (the entire project, a subdirectory, ..) #[derive(Clone, Debug)] #[cfg_attr( feature = "serde", @@ -185,6 +185,7 @@ impl FormatOptions for PyFormatOptions { derive(serde::Serialize, serde::Deserialize), serde(rename_all = "kebab-case") )] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub enum QuoteStyle { Single, #[default] diff --git a/crates/ruff_python_formatter/src/settings.rs b/crates/ruff_python_formatter/src/settings.rs deleted file mode 100644 index 4a9f42d20afe02..00000000000000 --- a/crates/ruff_python_formatter/src/settings.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::path::PathBuf; - -use ruff_formatter::{FormatOptions, IndentStyle, LineWidth}; -use ruff_macros::CacheKey; -use ruff_python_ast::PySourceType; - -use crate::{MagicTrailingComma, PreviewMode, PyFormatOptions, QuoteStyle}; - -#[derive(CacheKey, Clone, Debug)] -pub struct FormatterSettings { - /// The files that are excluded from formatting (but may be linted). - pub exclude: Vec, - - pub preview: PreviewMode, - - pub line_width: LineWidth, - - pub indent_style: IndentStyle, - - pub quote_style: QuoteStyle, - - pub magic_trailing_comma: MagicTrailingComma, -} - -impl FormatterSettings { - pub fn to_format_options(&self, source_type: PySourceType) -> PyFormatOptions { - PyFormatOptions::from_source_type(source_type) - .with_indent_style(self.indent_style) - .with_quote_style(self.quote_style) - .with_magic_trailing_comma(self.magic_trailing_comma) - .with_preview(self.preview) - .with_line_width(self.line_width) - } -} - -impl Default for FormatterSettings { - fn default() -> Self { - let default_options = PyFormatOptions::default(); - - Self { - exclude: Vec::default(), - preview: PreviewMode::Disabled, - line_width: default_options.line_width(), - indent_style: default_options.indent_style(), - quote_style: default_options.quote_style(), - magic_trailing_comma: default_options.magic_trailing_comma(), - } - } -} diff --git a/crates/ruff_wasm/src/lib.rs b/crates/ruff_wasm/src/lib.rs index e30407af5aab44..e3a6974e6b28cd 100644 --- a/crates/ruff_wasm/src/lib.rs +++ b/crates/ruff_wasm/src/lib.rs @@ -303,7 +303,7 @@ impl<'a> ParsedModule<'a> { // TODO(konstin): Add an options for py/pyi to the UI (2/2) let options = settings .formatter - .to_format_options(PySourceType::default()); + .to_format_options(PySourceType::default(), self.source_code); format_node( &self.module, diff --git a/crates/ruff_workspace/Cargo.toml b/crates/ruff_workspace/Cargo.toml index 3be2dca20fbdbd..7cde787831b846 100644 --- a/crates/ruff_workspace/Cargo.toml +++ b/crates/ruff_workspace/Cargo.toml @@ -15,7 +15,9 @@ license = { workspace = true } [dependencies] ruff_linter = { path = "../ruff_linter" } ruff_formatter = { path = "../ruff_formatter" } -ruff_python_formatter = { path = "../ruff_python_formatter" } +ruff_python_formatter = { path = "../ruff_python_formatter", features = ["serde"] } +ruff_python_ast = { path = "../ruff_python_ast" } +ruff_source_file = { path = "../ruff_source_file" } ruff_cache = { path = "../ruff_cache" } ruff_macros = { path = "../ruff_macros" } @@ -43,4 +45,6 @@ tempfile = "3.6.0" [features] -schemars = [ "dep:schemars" ] +schemars = [ "dep:schemars", "ruff_formatter/schemars", "ruff_python_formatter/schemars" ] + +default = [] diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 849f4d6406a6db..3ea2bf9e1cfd59 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -16,7 +16,7 @@ use shellexpand::LookupError; use strum::IntoEnumIterator; use ruff_cache::cache_dir; -use ruff_formatter::LineWidth; +use ruff_formatter::{IndentStyle, LineWidth}; use ruff_linter::line_width::{LineLength, TabSize}; use ruff_linter::registry::RuleNamespace; use ruff_linter::registry::{Rule, RuleSet, INCOMPATIBLE_CODES}; @@ -32,17 +32,20 @@ use ruff_linter::settings::{ use ruff_linter::{ fs, warn_user, warn_user_once, warn_user_once_by_id, RuleSelector, RUFF_PKG_VERSION, }; -use ruff_python_formatter::FormatterSettings; +use ruff_python_formatter::{MagicTrailingComma, QuoteStyle}; use crate::options::{ Flake8AnnotationsOptions, Flake8BanditOptions, Flake8BugbearOptions, Flake8BuiltinsOptions, Flake8ComprehensionsOptions, Flake8CopyrightOptions, Flake8ErrMsgOptions, Flake8GetTextOptions, Flake8ImplicitStrConcatOptions, Flake8ImportConventionsOptions, Flake8PytestStyleOptions, Flake8QuotesOptions, Flake8SelfOptions, Flake8TidyImportsOptions, Flake8TypeCheckingOptions, - Flake8UnusedArgumentsOptions, IsortOptions, McCabeOptions, Options, Pep8NamingOptions, - PyUpgradeOptions, PycodestyleOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, + Flake8UnusedArgumentsOptions, FormatOptions, FormatOrOutputFormat, IsortOptions, McCabeOptions, + Options, Pep8NamingOptions, PyUpgradeOptions, PycodestyleOptions, PydocstyleOptions, + PyflakesOptions, PylintOptions, +}; +use crate::settings::{ + FileResolverSettings, FormatterSettings, LineEnding, Settings, EXCLUDE, INCLUDE, }; -use crate::settings::{FileResolverSettings, Settings, EXCLUDE, INCLUDE}; #[derive(Debug, Default)] pub struct RuleSelection { @@ -113,6 +116,8 @@ pub struct Configuration { pub pyflakes: Option, pub pylint: Option, pub pyupgrade: Option, + + pub format: Option, } impl Configuration { @@ -129,6 +134,31 @@ impl Configuration { let target_version = self.target_version.unwrap_or_default(); let rules = self.as_rule_table(); + let preview = self.preview.unwrap_or_default(); + + let formatter = if let Some(format) = self.format { + let default = FormatterSettings::default(); + + // TODO(micha): Support changing the tab-width but disallow changing the number of spaces + FormatterSettings { + exclude: FilePatternSet::try_from_iter(format.exclude.unwrap_or_default())?, + preview: match format.preview.unwrap_or(preview) { + PreviewMode::Disabled => ruff_python_formatter::PreviewMode::Disabled, + PreviewMode::Enabled => ruff_python_formatter::PreviewMode::Enabled, + }, + line_width: self.line_length.map_or(default.line_width, |length| { + LineWidth::from(NonZeroU16::from(length)) + }), + line_ending: format.line_ending.unwrap_or(default.line_ending), + indent_style: format.indent_style.unwrap_or(default.indent_style), + quote_style: format.quote_style.unwrap_or(default.quote_style), + magic_trailing_comma: format + .magic_trailing_comma + .unwrap_or(default.magic_trailing_comma), + } + } else { + FormatterSettings::default() + }; Ok(Settings { cache_dir: self @@ -185,7 +215,7 @@ impl Configuration { .task_tags .unwrap_or_else(|| TASK_TAGS.iter().map(ToString::to_string).collect()), logger_objects: self.logger_objects.unwrap_or_default(), - preview: self.preview.unwrap_or_default(), + preview, typing_modules: self.typing_modules.unwrap_or_default(), // Plugins flake8_annotations: self @@ -290,18 +320,7 @@ impl Configuration { .unwrap_or_default(), }, - formatter: FormatterSettings { - exclude: vec![], - preview: self - .preview - .map(|preview| match preview { - PreviewMode::Disabled => ruff_python_formatter::PreviewMode::Disabled, - PreviewMode::Enabled => ruff_python_formatter::PreviewMode::Enabled, - }) - .unwrap_or_default(), - line_width: LineWidth::from(NonZeroU16::from(self.line_length.unwrap_or_default())), - ..FormatterSettings::default() - }, + formatter, }) } @@ -395,7 +414,12 @@ impl Configuration { external: options.external, fix: options.fix, fix_only: options.fix_only, - output_format: options.output_format.or(options.format), + output_format: options.output_format.or_else(|| { + options + .format + .as_ref() + .and_then(FormatOrOutputFormat::as_output_format) + }), force_exclude: options.force_exclude, ignore_init_module_imports: options.ignore_init_module_imports, include: options.include.map(|paths| { @@ -459,6 +483,12 @@ impl Configuration { pyflakes: options.pyflakes, pylint: options.pylint, pyupgrade: options.pyupgrade, + + format: if let Some(FormatOrOutputFormat::Format(format)) = options.format { + Some(FormatConfiguration::from_options(format, project_root)?) + } else { + None + }, }) } @@ -782,6 +812,67 @@ impl Configuration { pyflakes: self.pyflakes.combine(config.pyflakes), pylint: self.pylint.combine(config.pylint), pyupgrade: self.pyupgrade.combine(config.pyupgrade), + + format: match (self.format, config.format) { + (Some(format), Some(other_format)) => Some(format.combine(other_format)), + (Some(format), None) => Some(format), + (None, Some(format)) => Some(format), + (None, None) => None, + }, + } + } +} + +#[derive(Debug, Default)] +pub struct FormatConfiguration { + pub exclude: Option>, + + pub preview: Option, + + pub indent_style: Option, + + pub quote_style: Option, + + pub magic_trailing_comma: Option, + + pub line_ending: Option, +} + +impl FormatConfiguration { + pub fn from_options(options: FormatOptions, project_root: &Path) -> Result { + Ok(Self { + exclude: options.exclude.map(|paths| { + paths + .into_iter() + .map(|pattern| { + let absolute = fs::normalize_path_to(&pattern, project_root); + FilePattern::User(pattern, absolute) + }) + .collect() + }), + preview: options.preview.map(PreviewMode::from), + indent_style: options.indent_style, + quote_style: options.quote_style, + magic_trailing_comma: options.skip_magic_trailing_comma.map(|skip| { + if skip { + MagicTrailingComma::Ignore + } else { + MagicTrailingComma::Respect + } + }), + line_ending: options.line_ending, + }) + } + + #[must_use] + pub fn combine(self, other: Self) -> Self { + Self { + exclude: self.exclude.or(other.exclude), + preview: self.preview.or(other.preview), + indent_style: self.indent_style.or(other.indent_style), + quote_style: self.quote_style.or(other.quote_style), + magic_trailing_comma: self.magic_trailing_comma.or(other.magic_trailing_comma), + line_ending: self.line_ending.or(other.line_ending), } } } diff --git a/crates/ruff_workspace/src/lib.rs b/crates/ruff_workspace/src/lib.rs index a18e151a3538da..53074296137048 100644 --- a/crates/ruff_workspace/src/lib.rs +++ b/crates/ruff_workspace/src/lib.rs @@ -6,7 +6,7 @@ pub mod resolver; pub mod options_base; mod settings; -pub use settings::Settings; +pub use settings::{FileResolverSettings, FormatterSettings, Settings}; #[cfg(test)] mod tests { diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index d5b8ea28f024e7..c1e1e67f6b7bf1 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -1,4 +1,12 @@ +use std::collections::BTreeSet; +use std::hash::BuildHasherDefault; + use regex::Regex; +use ruff_formatter::IndentStyle; +use rustc_hash::{FxHashMap, FxHashSet}; +use serde::{Deserialize, Serialize}; +use strum::IntoEnumIterator; + use ruff_linter::line_width::{LineLength, TabSize}; use ruff_linter::rules::flake8_pytest_style::settings::SettingsError; use ruff_linter::rules::flake8_pytest_style::types; @@ -19,11 +27,9 @@ use ruff_linter::settings::types::{ }; use ruff_linter::{warn_user_once, RuleSelector}; use ruff_macros::{CombineOptions, ConfigurationOptions}; -use rustc_hash::{FxHashMap, FxHashSet}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; -use std::hash::BuildHasherDefault; -use strum::IntoEnumIterator; +use ruff_python_formatter::QuoteStyle; + +use crate::settings::LineEnding; #[derive(Debug, PartialEq, Eq, Default, ConfigurationOptions, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] @@ -252,17 +258,6 @@ pub struct Options { )] pub fixable: Option>, - /// The style in which violation messages should be formatted: `"text"` - /// (default), `"grouped"` (group messages by file), `"json"` - /// (machine-readable), `"junit"` (machine-readable XML), `"github"` (GitHub - /// Actions annotations), `"gitlab"` (GitLab CI code quality report), - /// `"pylint"` (Pylint text format) or `"azure"` (Azure Pipeline logging commands). - /// - /// This option has been **deprecated** in favor of `output-format` - /// to avoid ambiguity with Ruff's upcoming formatter. - #[cfg_attr(feature = "schemars", schemars(skip))] - pub format: Option, - /// The style in which violation messages should be formatted: `"text"` /// (default), `"grouped"` (group messages by file), `"json"` /// (machine-readable), `"junit"` (machine-readable XML), `"github"` (GitHub @@ -681,6 +676,20 @@ pub struct Options { #[option_group] pub pyupgrade: Option, + /// Options to configure the code formatting. + /// + /// Previously: + /// The style in which violation messages should be formatted: `"text"` + /// (default), `"grouped"` (group messages by file), `"json"` + /// (machine-readable), `"junit"` (machine-readable XML), `"github"` (GitHub + /// Actions annotations), `"gitlab"` (GitLab CI code quality report), + /// `"pylint"` (Pylint text format) or `"azure"` (Azure Pipeline logging commands). + /// + /// This option has been **deprecated** in favor of `output-format` + /// to avoid ambiguity with Ruff's upcoming formatter. + #[option_group] + pub format: Option, + // Tables are required to go last. /// A list of mappings from file pattern to rule codes or prefixes to /// exclude, when considering any matching files. @@ -2381,11 +2390,141 @@ impl PyUpgradeOptions { } } +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum FormatOrOutputFormat { + Format(FormatOptions), + OutputFormat(SerializationFormat), +} + +impl FormatOrOutputFormat { + pub const fn metadata() -> crate::options_base::OptionGroup { + FormatOptions::metadata() + } + + pub const fn as_output_format(&self) -> Option { + match self { + FormatOrOutputFormat::Format(_) => None, + FormatOrOutputFormat::OutputFormat(format) => Some(*format), + } + } +} + +#[derive( + Debug, PartialEq, Eq, Default, Serialize, Deserialize, ConfigurationOptions, CombineOptions, +)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct FormatOptions { + /// A list of file patterns to exclude from formatting. + /// + /// Exclusions are based on globs, and can be either: + /// + /// - Single-path patterns, like `.mypy_cache` (to exclude any directory + /// named `.mypy_cache` in the tree), `foo.py` (to exclude any file named + /// `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). + /// - Relative patterns, like `directory/foo.py` (to exclude that specific + /// file) or `directory/*.py` (to exclude any Python files in + /// `directory`). Note that these paths are relative to the project root + /// (e.g., the directory containing your `pyproject.toml`). + /// + /// For more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax). + /// + /// Note that you don't need to list files that are excluded by + /// [`exclude`](#exclude). + // TODO(micha): Document once the CLI supports the exclude option + // #[option( + // default = r#"[]"#, + // value_type = "list[str]", + // example = r#" + // [format] + // exclude = ["generated"] + // "# + // )] + pub exclude: Option>, + + /// Whether to enable the unstable preview style formatting. + #[option( + default = "false", + value_type = "bool", + example = r#" + [format] + # Enable preview style formatting + preview = true + "# + )] + pub preview: Option, + + /// Whether to use 4 spaces or hard tabs for indenting code. + /// + /// Defaults to 4 spaces. We only recommend changing this option, if you need to for accessibility reasons. + #[option( + default = "space", + value_type = r#""space" | "tab""#, + example = r#" + [format] + # Use tabs instead of 4 space indentation + indent-style = "tab" + "# + )] + pub indent_style: Option, + + /// Whether to prefer single `'` or double `"` quotes for strings and docstrings. + /// + /// Ruff may deviate from this option if using the configured quotes would require more escaped quotes: + /// + /// ```python + /// a = "It's monday morning" + /// b = "a string without any quotes" + /// ``` + /// + /// Ruff leaves `a` unchanged when using `quote-style = "single"` because it is otherwise + /// necessary to escape the `'` which leads to less readable code: `'It\'s monday morning'`. + /// Ruff changes the quotes of `b` to use single quotes. + #[option( + default = r#"double"#, + value_type = r#""double" | "single""#, + example = r#" + [format] + # Prefer single quotes over double quotes + quote-style = "single" + "# + )] + pub quote_style: Option, + + /// Ruff uses existing trailing commas as an indication that short lines should be left separate. + /// If this option is set to `true`, the magic trailing comma is ignored. + #[option( + default = r#"false"#, + value_type = r#"bool"#, + example = r#" + [format] + # Ignore magic trailing commas + skip-magic-trailing-comma = true + "# + )] + pub skip_magic_trailing_comma: Option, + + /// The character Ruff uses at the end of a line. + #[option( + default = r#"lf"#, + value_type = r#""lf" | "crlf" | "auto" | "native""#, + example = r#" + [format] + # Automatically detect the line ending on a file per file basis. + quote-style = "auto" + "# + )] + pub line_ending: Option, +} + #[cfg(test)] mod tests { - use crate::options::Flake8SelfOptions; use ruff_linter::rules::flake8_self; + use crate::options::Flake8SelfOptions; + #[test] fn flake8_self_options() { let default_settings = flake8_self::settings::Settings::default(); diff --git a/crates/ruff_workspace/src/options_base.rs b/crates/ruff_workspace/src/options_base.rs index 10ef9f5776d812..7a91117beccecd 100644 --- a/crates/ruff_workspace/src/options_base.rs +++ b/crates/ruff_workspace/src/options_base.rs @@ -37,7 +37,7 @@ impl OptionGroup { /// ```rust /// # use ruff_workspace::options_base::{OptionGroup, OptionEntry, OptionField}; /// - /// const options: [(&'static str, OptionEntry); 2] = [ + /// const OPTIONS: [(&'static str, OptionEntry); 2] = [ /// ("ignore_names", OptionEntry::Field(OptionField { /// doc: "ignore_doc", /// default: "ignore_default", @@ -53,7 +53,7 @@ impl OptionGroup { /// })) /// ]; /// - /// let group = OptionGroup::new(&options); + /// let group = OptionGroup::new(&OPTIONS); /// /// let ignore_names = group.get("ignore_names"); /// @@ -73,7 +73,7 @@ impl OptionGroup { /// ```rust /// # use ruff_workspace::options_base::{OptionGroup, OptionEntry, OptionField}; /// - /// const ignore_options: [(&'static str, OptionEntry); 2] = [ + /// const IGNORE_OPTIONS: [(&'static str, OptionEntry); 2] = [ /// ("names", OptionEntry::Field(OptionField { /// doc: "ignore_name_doc", /// default: "ignore_name_default", @@ -89,8 +89,8 @@ impl OptionGroup { /// })) /// ]; /// - /// const options: [(&'static str, OptionEntry); 2] = [ - /// ("ignore", OptionEntry::Group(OptionGroup::new(&ignore_options))), + /// const OPTIONS: [(&'static str, OptionEntry); 2] = [ + /// ("ignore", OptionEntry::Group(OptionGroup::new(&IGNORE_OPTIONS))), /// /// ("global_names", OptionEntry::Field(OptionField { /// doc: "global_doc", @@ -100,7 +100,7 @@ impl OptionGroup { /// })) /// ]; /// - /// let group = OptionGroup::new(&options); + /// let group = OptionGroup::new(&OPTIONS); /// /// let ignore_names = group.get("ignore.names"); /// diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs index 5075bb715322cd..a7d3002bd253bc 100644 --- a/crates/ruff_workspace/src/resolver.rs +++ b/crates/ruff_workspace/src/resolver.rs @@ -17,6 +17,7 @@ use ruff_linter::packaging::is_package; use ruff_linter::{fs, warn_user_once}; use crate::configuration::Configuration; +use crate::options::FormatOrOutputFormat; use crate::pyproject; use crate::pyproject::settings_toml; use crate::settings::Settings; @@ -220,8 +221,8 @@ fn resolve_configuration( let options = pyproject::load_options(&path) .map_err(|err| anyhow!("Failed to parse `{}`: {}", path.display(), err))?; - if options.format.is_some() { - warn_user_once!("The option `format` has been deprecated to avoid ambiguity with Ruff's upcoming formatter. Use `format-output` instead."); + if matches!(options.format, Some(FormatOrOutputFormat::OutputFormat(_))) { + warn_user_once!("The option `format` has been deprecated to avoid ambiguity with Ruff's upcoming formatter. Use `output-format` instead."); } let project_root = relativity.resolve(&path); diff --git a/crates/ruff_workspace/src/settings.rs b/crates/ruff_workspace/src/settings.rs index 83c1d6ab656989..c99bb6e964bcd3 100644 --- a/crates/ruff_workspace/src/settings.rs +++ b/crates/ruff_workspace/src/settings.rs @@ -1,9 +1,12 @@ use path_absolutize::path_dedot; use ruff_cache::cache_dir; +use ruff_formatter::{FormatOptions, IndentStyle, LineWidth}; use ruff_linter::settings::types::{FilePattern, FilePatternSet, SerializationFormat}; use ruff_linter::settings::LinterSettings; use ruff_macros::CacheKey; -use ruff_python_formatter::FormatterSettings; +use ruff_python_ast::PySourceType; +use ruff_python_formatter::{MagicTrailingComma, PreviewMode, PyFormatOptions, QuoteStyle}; +use ruff_source_file::find_newline; use std::path::{Path, PathBuf}; #[derive(Debug, CacheKey)] @@ -102,3 +105,92 @@ impl FileResolverSettings { } } } + +#[derive(CacheKey, Clone, Debug)] +pub struct FormatterSettings { + /// The files that are excluded from formatting (but may be linted). + pub exclude: FilePatternSet, + + pub preview: PreviewMode, + + pub line_width: LineWidth, + + pub indent_style: IndentStyle, + + pub quote_style: QuoteStyle, + + pub magic_trailing_comma: MagicTrailingComma, + + pub line_ending: LineEnding, +} + +impl FormatterSettings { + pub fn to_format_options(&self, source_type: PySourceType, source: &str) -> PyFormatOptions { + let line_ending = match self.line_ending { + LineEnding::Lf => ruff_formatter::printer::LineEnding::LineFeed, + LineEnding::CrLf => ruff_formatter::printer::LineEnding::CarriageReturnLineFeed, + #[cfg(target_os = "windows")] + LineEnding::Native => ruff_formatter::printer::LineEnding::CarriageReturnLineFeed, + #[cfg(not(target_os = "windows"))] + LineEnding::Native => ruff_formatter::printer::LineEnding::LineFeed, + LineEnding::Auto => match find_newline(source) { + Some((_, ruff_source_file::LineEnding::Lf)) => { + ruff_formatter::printer::LineEnding::LineFeed + } + Some((_, ruff_source_file::LineEnding::CrLf)) => { + ruff_formatter::printer::LineEnding::CarriageReturnLineFeed + } + Some((_, ruff_source_file::LineEnding::Cr)) => { + ruff_formatter::printer::LineEnding::CarriageReturn + } + None => ruff_formatter::printer::LineEnding::LineFeed, + }, + }; + + PyFormatOptions::from_source_type(source_type) + .with_indent_style(self.indent_style) + .with_quote_style(self.quote_style) + .with_magic_trailing_comma(self.magic_trailing_comma) + .with_preview(self.preview) + .with_line_ending(line_ending) + .with_line_width(self.line_width) + } +} + +impl Default for FormatterSettings { + fn default() -> Self { + let default_options = PyFormatOptions::default(); + + Self { + exclude: FilePatternSet::default(), + preview: ruff_python_formatter::PreviewMode::Disabled, + line_width: default_options.line_width(), + line_ending: LineEnding::Lf, + indent_style: default_options.indent_style(), + quote_style: default_options.quote_style(), + magic_trailing_comma: default_options.magic_trailing_comma(), + } + } +} + +#[derive( + Copy, Clone, Debug, Eq, PartialEq, Default, CacheKey, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum LineEnding { + /// Line endings will be converted to `\n` as is common on Unix. + #[default] + Lf, + + /// Line endings will be converted to `\r\n` as is common on Windows. + CrLf, + + /// The newline style is detected automatically on a file per file basis. + /// Files with mixed line endings will be converted to the first detected line ending. + /// Defaults to [`LineEnding::Lf`] for a files that contain no line endings. + Auto, + + /// Line endings will be converted to `\n` on Unix and `\r\n` on Windows. + Native, +} diff --git a/ruff.schema.json b/ruff.schema.json index bfad9f5c5eb8ed..cfe3bdc0bcca7b 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -326,6 +326,17 @@ "null" ] }, + "format": { + "description": "Options to configure the code formatting.\n\nPreviously: The style in which violation messages should be formatted: `\"text\"` (default), `\"grouped\"` (group messages by file), `\"json\"` (machine-readable), `\"junit\"` (machine-readable XML), `\"github\"` (GitHub Actions annotations), `\"gitlab\"` (GitLab CI code quality report), `\"pylint\"` (Pylint text format) or `\"azure\"` (Azure Pipeline logging commands).\n\nThis option has been **deprecated** in favor of `output-format` to avoid ambiguity with Ruff's upcoming formatter.", + "anyOf": [ + { + "$ref": "#/definitions/FormatOrOutputFormat" + }, + { + "type": "null" + } + ] + }, "ignore": { "description": "A list of rule codes or prefixes to ignore. Prefixes can specify exact rules (like `F841`), entire categories (like `F`), or anything in between.\n\nWhen breaking ties between enabled and disabled rules (via `select` and `ignore`, respectively), more specific prefixes override less specific prefixes.", "type": [ @@ -1151,6 +1162,79 @@ }, "additionalProperties": false }, + "FormatOptions": { + "type": "object", + "properties": { + "exclude": { + "description": "A list of file patterns to exclude from formatting.\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).\n\nNote that you don't need to list files that are excluded by [`exclude`](#exclude).", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "indent-style": { + "description": "Whether to use 4 spaces or hard tabs for indenting code.\n\nDefaults to 4 spaces. We only recommend changing this option, if you need to for accessibility reasons.", + "anyOf": [ + { + "$ref": "#/definitions/IndentStyle" + }, + { + "type": "null" + } + ] + }, + "line-ending": { + "description": "The character Ruff uses at the end of a line.", + "anyOf": [ + { + "$ref": "#/definitions/LineEnding" + }, + { + "type": "null" + } + ] + }, + "preview": { + "description": "Whether to enable the unstable preview style formatting.", + "type": [ + "boolean", + "null" + ] + }, + "quote-style": { + "description": "Whether to prefer single `'` or double `\"` quotes for strings and docstrings.\n\nRuff may deviate from this option if using the configured quotes would require more escaped quotes:\n\n```python a = \"It's monday morning\" b = \"a string without any quotes\" ```\n\nRuff leaves `a` unchanged when using `quote-style = \"single\"` because it is otherwise necessary to escape the `'` which leads to less readable code: `'It\\'s monday morning'`. Ruff changes the quotes of `b` to use single quotes.", + "anyOf": [ + { + "$ref": "#/definitions/QuoteStyle" + }, + { + "type": "null" + } + ] + }, + "skip-magic-trailing-comma": { + "description": "Ruff uses existing trailing commas as an indication that short lines should be left separate. If this option is set to `true`, the magic trailing comma is ignored.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + }, + "FormatOrOutputFormat": { + "anyOf": [ + { + "$ref": "#/definitions/FormatOptions" + }, + { + "$ref": "#/definitions/SerializationFormat" + } + ] + }, "ImportSection": { "anyOf": [ { @@ -1171,6 +1255,24 @@ "local-folder" ] }, + "IndentStyle": { + "oneOf": [ + { + "description": "Use tabs to indent code.", + "type": "string", + "enum": [ + "tab" + ] + }, + { + "description": "Use [`IndentWidth`] spaces to indent code.", + "type": "string", + "enum": [ + "space" + ] + } + ] + }, "IsortOptions": { "type": "object", "properties": { @@ -1404,6 +1506,38 @@ }, "additionalProperties": false }, + "LineEnding": { + "oneOf": [ + { + "description": "Line endings will be converted to `\\n` as is common on Unix.", + "type": "string", + "enum": [ + "lf" + ] + }, + { + "description": "Line endings will be converted to `\\r\\n` as is common on Windows.", + "type": "string", + "enum": [ + "cr-lf" + ] + }, + { + "description": "The newline style is detected automatically on a file per file basis. Files with mixed line endings will be converted to the first detected line ending. Defaults to [`LineEnding::Lf`] for a files that contain no line endings.", + "type": "string", + "enum": [ + "auto" + ] + }, + { + "description": "Line endings will be converted to `\\n` on Unix and `\\r\\n` on Windows.", + "type": "string", + "enum": [ + "native" + ] + } + ] + }, "LineLength": { "description": "The length of a line of text that is considered too long.\n\nThe allowed range of values is 1..=320", "type": "integer", @@ -1673,6 +1807,13 @@ } ] }, + "QuoteStyle": { + "type": "string", + "enum": [ + "single", + "double" + ] + }, "RelativeImportsOrder": { "oneOf": [ {