diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3bce3cb9ee..e5e3bc6ee7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -259,6 +259,10 @@ jobs: uses: taiki-e/install-action@6064345e6658255e90e9500fdf9a06ab77e6909c # v2.57.6 with: tool: cargo-insta + - name: "Install uv" + uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3 + with: + enable-cache: "true" - name: ty mdtests (GitHub annotations) if: ${{ needs.determine_changes.outputs.ty == 'true' }} env: @@ -317,6 +321,10 @@ jobs: uses: taiki-e/install-action@6064345e6658255e90e9500fdf9a06ab77e6909c # v2.57.6 with: tool: cargo-insta + - name: "Install uv" + uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3 + with: + enable-cache: "true" - name: "Run tests" shell: bash env: @@ -340,6 +348,10 @@ jobs: uses: taiki-e/install-action@6064345e6658255e90e9500fdf9a06ab77e6909c # v2.57.6 with: tool: cargo-nextest + - name: "Install uv" + uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3 + with: + enable-cache: "true" - name: "Run tests" shell: bash env: diff --git a/crates/ruff_formatter/src/lib.rs b/crates/ruff_formatter/src/lib.rs index 65f0bcf8a9..d765af4704 100644 --- a/crates/ruff_formatter/src/lib.rs +++ b/crates/ruff_formatter/src/lib.rs @@ -81,14 +81,19 @@ impl IndentStyle { pub const fn is_space(&self) -> bool { matches!(self, IndentStyle::Space) } + + /// Returns the string representation of the indent style. + pub const fn as_str(&self) -> &'static str { + match self { + IndentStyle::Tab => "tab", + IndentStyle::Space => "space", + } + } } impl std::fmt::Display for IndentStyle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - IndentStyle::Tab => std::write!(f, "tab"), - IndentStyle::Space => std::write!(f, "space"), - } + f.write_str(self.as_str()) } } diff --git a/crates/ruff_formatter/src/printer/printer_options/mod.rs b/crates/ruff_formatter/src/printer/printer_options/mod.rs index a8895690f6..a841f85744 100644 --- a/crates/ruff_formatter/src/printer/printer_options/mod.rs +++ b/crates/ruff_formatter/src/printer/printer_options/mod.rs @@ -139,4 +139,16 @@ impl LineEnding { LineEnding::CarriageReturn => "\r", } } + + /// Returns the string used to configure this line ending. + /// + /// See [`LineEnding::as_str`] for the actual string representation of the line ending. + #[inline] + pub const fn as_setting_str(&self) -> &'static str { + match self { + LineEnding::LineFeed => "lf", + LineEnding::CarriageReturnLineFeed => "crlf", + LineEnding::CarriageReturn => "cr", + } + } } diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index 26916d66ea..ec84fa65fb 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -252,15 +252,20 @@ impl QuoteStyle { pub const fn is_preserve(self) -> bool { matches!(self, QuoteStyle::Preserve) } + + /// Returns the string representation of the quote style. + pub const fn as_str(&self) -> &'static str { + match self { + QuoteStyle::Single => "single", + QuoteStyle::Double => "double", + QuoteStyle::Preserve => "preserve", + } + } } impl fmt::Display for QuoteStyle { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Single => write!(f, "single"), - Self::Double => write!(f, "double"), - Self::Preserve => write!(f, "preserve"), - } + f.write_str(self.as_str()) } } @@ -302,10 +307,10 @@ impl MagicTrailingComma { impl fmt::Display for MagicTrailingComma { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Respect => write!(f, "respect"), - Self::Ignore => write!(f, "ignore"), - } + f.write_str(match self { + MagicTrailingComma::Respect => "respect", + MagicTrailingComma::Ignore => "ignore", + }) } } diff --git a/crates/ruff_server/Cargo.toml b/crates/ruff_server/Cargo.toml index 4bfaf47663..0d53439406 100644 --- a/crates/ruff_server/Cargo.toml +++ b/crates/ruff_server/Cargo.toml @@ -50,5 +50,8 @@ insta = { workspace = true } [target.'cfg(target_vendor = "apple")'.dependencies] libc = { workspace = true } +[features] +test-uv = [] + [lints] workspace = true diff --git a/crates/ruff_server/src/format.rs b/crates/ruff_server/src/format.rs index d63d94f5ec..44f5466e11 100644 --- a/crates/ruff_server/src/format.rs +++ b/crates/ruff_server/src/format.rs @@ -1,18 +1,52 @@ +use std::io::Write; use std::path::Path; +use std::process::{Command, Stdio}; -use ruff_formatter::PrintedRange; +use anyhow::Context; + +use ruff_formatter::{FormatOptions, PrintedRange}; use ruff_python_ast::PySourceType; -use ruff_python_formatter::{FormatModuleError, format_module_source}; +use ruff_python_formatter::{FormatModuleError, PyFormatOptions, format_module_source}; +use ruff_source_file::LineIndex; use ruff_text_size::TextRange; use ruff_workspace::FormatterSettings; use crate::edit::TextDocument; +/// The backend to use for formatting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum FormatBackend { + /// Use the built-in Ruff formatter. + /// + /// The formatter version will match the LSP version. + #[default] + Internal, + /// Use uv for formatting. + /// + /// The formatter version may differ from the LSP version. + Uv, +} + pub(crate) fn format( document: &TextDocument, source_type: PySourceType, formatter_settings: &FormatterSettings, path: &Path, + backend: FormatBackend, +) -> crate::Result> { + match backend { + FormatBackend::Uv => format_external(document, source_type, formatter_settings, path), + FormatBackend::Internal => format_internal(document, source_type, formatter_settings, path), + } +} + +/// Format using the built-in Ruff formatter. +fn format_internal( + document: &TextDocument, + source_type: PySourceType, + formatter_settings: &FormatterSettings, + path: &Path, ) -> crate::Result> { let format_options = formatter_settings.to_format_options(source_type, document.contents(), Some(path)); @@ -35,12 +69,44 @@ pub(crate) fn format( } } +/// Format using an external uv command. +fn format_external( + document: &TextDocument, + source_type: PySourceType, + formatter_settings: &FormatterSettings, + path: &Path, +) -> crate::Result> { + let format_options = + formatter_settings.to_format_options(source_type, document.contents(), Some(path)); + let uv_command = UvFormatCommand::from(format_options); + uv_command.format_document(document.contents(), path) +} + pub(crate) fn format_range( document: &TextDocument, source_type: PySourceType, formatter_settings: &FormatterSettings, range: TextRange, path: &Path, + backend: FormatBackend, +) -> crate::Result> { + match backend { + FormatBackend::Uv => { + format_range_external(document, source_type, formatter_settings, range, path) + } + FormatBackend::Internal => { + format_range_internal(document, source_type, formatter_settings, range, path) + } + } +} + +/// Format range using the built-in Ruff formatter +fn format_range_internal( + document: &TextDocument, + source_type: PySourceType, + formatter_settings: &FormatterSettings, + range: TextRange, + path: &Path, ) -> crate::Result> { let format_options = formatter_settings.to_format_options(source_type, document.contents(), Some(path)); @@ -63,6 +129,198 @@ pub(crate) fn format_range( } } +/// Format range using an external command, i.e., `uv`. +fn format_range_external( + document: &TextDocument, + source_type: PySourceType, + formatter_settings: &FormatterSettings, + range: TextRange, + path: &Path, +) -> crate::Result> { + let format_options = + formatter_settings.to_format_options(source_type, document.contents(), Some(path)); + let uv_command = UvFormatCommand::from(format_options); + + // Format the range using uv and convert the result to `PrintedRange` + match uv_command.format_range(document.contents(), range, path, document.index())? { + Some(formatted) => Ok(Some(PrintedRange::new(formatted, range))), + None => Ok(None), + } +} + +/// Builder for uv format commands +#[derive(Debug)] +pub(crate) struct UvFormatCommand { + options: PyFormatOptions, +} + +impl From for UvFormatCommand { + fn from(options: PyFormatOptions) -> Self { + Self { options } + } +} + +impl UvFormatCommand { + /// Build the command with all necessary arguments + fn build_command( + &self, + path: &Path, + range_with_index: Option<(TextRange, &LineIndex, &str)>, + ) -> Command { + let mut command = Command::new("uv"); + command.arg("format"); + command.arg("--"); + + let target_version = format!( + "py{}{}", + self.options.target_version().major, + self.options.target_version().minor + ); + + // Add only the formatting options that the CLI supports + command.arg("--target-version"); + command.arg(&target_version); + + command.arg("--line-length"); + command.arg(self.options.line_width().to_string()); + + if self.options.preview().is_enabled() { + command.arg("--preview"); + } + + // Pass other formatting options via --config + command.arg("--config"); + command.arg(format!( + "format.indent-style = '{}'", + self.options.indent_style() + )); + + command.arg("--config"); + command.arg(format!("indent-width = {}", self.options.indent_width())); + + command.arg("--config"); + command.arg(format!( + "format.quote-style = '{}'", + self.options.quote_style() + )); + + command.arg("--config"); + command.arg(format!( + "format.line-ending = '{}'", + self.options.line_ending().as_setting_str() + )); + + command.arg("--config"); + command.arg(format!( + "format.skip-magic-trailing-comma = {}", + match self.options.magic_trailing_comma() { + ruff_python_formatter::MagicTrailingComma::Respect => "false", + ruff_python_formatter::MagicTrailingComma::Ignore => "true", + } + )); + + if let Some((range, line_index, source)) = range_with_index { + // The CLI expects line:column format + let start_pos = line_index.line_column(range.start(), source); + let end_pos = line_index.line_column(range.end(), source); + let range_str = format!( + "{}:{}-{}:{}", + start_pos.line.get(), + start_pos.column.get(), + end_pos.line.get(), + end_pos.column.get() + ); + command.arg("--range"); + command.arg(&range_str); + } + + command.arg("--stdin-filename"); + command.arg(path.to_string_lossy().as_ref()); + + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + command + } + + /// Execute the format command on the given source. + pub(crate) fn format( + &self, + source: &str, + path: &Path, + range_with_index: Option<(TextRange, &LineIndex)>, + ) -> crate::Result> { + let mut command = + self.build_command(path, range_with_index.map(|(r, idx)| (r, idx, source))); + let mut child = match command.spawn() { + Ok(child) => child, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("uv was not found; is it installed and on the PATH?") + } + Err(err) => return Err(err).context("Failed to spawn uv"), + }; + + let mut stdin = child + .stdin + .take() + .context("Failed to get stdin from format subprocess")?; + stdin + .write_all(source.as_bytes()) + .context("Failed to write to stdin")?; + drop(stdin); + + let result = child + .wait_with_output() + .context("Failed to get output from format subprocess")?; + + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr); + // We don't propagate format errors due to invalid syntax + if stderr.contains("Failed to parse") { + tracing::warn!("Unable to format document: {}", stderr); + return Ok(None); + } + // Special-case for when `uv format` is not available + if stderr.contains("unrecognized subcommand 'format'") { + anyhow::bail!( + "The installed version of uv does not support `uv format`; upgrade to a newer version" + ); + } + anyhow::bail!("Failed to format document: {}", stderr); + } + + let formatted = String::from_utf8(result.stdout) + .context("Failed to parse stdout from format subprocess as utf-8")?; + + if formatted == source { + Ok(None) + } else { + Ok(Some(formatted)) + } + } + + /// Format the entire document. + pub(crate) fn format_document( + &self, + source: &str, + path: &Path, + ) -> crate::Result> { + self.format(source, path, None) + } + + /// Format a specific range. + pub(crate) fn format_range( + &self, + source: &str, + range: TextRange, + path: &Path, + line_index: &LineIndex, + ) -> crate::Result> { + self.format(source, path, Some((range, line_index))) + } +} + #[cfg(test)] mod tests { use std::path::Path; @@ -74,7 +332,7 @@ mod tests { use ruff_workspace::FormatterSettings; use crate::TextDocument; - use crate::format::{format, format_range}; + use crate::format::{FormatBackend, format, format_range}; #[test] fn format_per_file_version() { @@ -98,6 +356,7 @@ with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a ..Default::default() }, Path::new("test.py"), + FormatBackend::Internal, ) .expect("Expected no errors when formatting") .expect("Expected formatting changes"); @@ -120,6 +379,7 @@ with open("a_really_long_foo") as foo, open("a_really_long_bar") as bar, open("a ..Default::default() }, Path::new("test.py"), + FormatBackend::Internal, ) .expect("Expected no errors when formatting") .expect("Expected formatting changes"); @@ -168,6 +428,7 @@ sys.exit( }, range, Path::new("test.py"), + FormatBackend::Internal, ) .expect("Expected no errors when formatting") .expect("Expected formatting changes"); @@ -191,6 +452,7 @@ sys.exit( }, range, Path::new("test.py"), + FormatBackend::Internal, ) .expect("Expected no errors when formatting") .expect("Expected formatting changes"); @@ -204,4 +466,279 @@ sys.exit( Ok(()) } + + #[cfg(feature = "test-uv")] + mod uv_tests { + use super::*; + + #[test] + fn test_uv_format_document() { + let document = TextDocument::new( + r#" +def hello( x,y ,z ): + return x+y +z + + +def world( ): + pass +"# + .to_string(), + 0, + ); + + let result = format( + &document, + PySourceType::Python, + &FormatterSettings::default(), + Path::new("test.py"), + FormatBackend::Uv, + ) + .expect("Expected no errors when formatting with uv") + .expect("Expected formatting changes"); + + // uv should format this to a consistent style + assert_snapshot!(result, @r#" + def hello(x, y, z): + return x + y + z + + + def world(): + pass + "#); + } + + #[test] + fn test_uv_format_range() -> anyhow::Result<()> { + let document = TextDocument::new( + r#" +def messy_function( a, b,c ): + return a+b+c + +def another_function(x,y,z): + result=x+y+z + return result +"# + .to_string(), + 0, + ); + + // Find the range of the second function + let start = document.contents().find("def another_function").unwrap(); + let end = document.contents().find("return result").unwrap() + "return result".len(); + let range = TextRange::new(TextSize::try_from(start)?, TextSize::try_from(end)?); + + let result = format_range( + &document, + PySourceType::Python, + &FormatterSettings::default(), + range, + Path::new("test.py"), + FormatBackend::Uv, + ) + .expect("Expected no errors when formatting range with uv") + .expect("Expected formatting changes"); + + assert_snapshot!(result.as_code(), @r#" + def messy_function( a, b,c ): + return a+b+c + + def another_function(x, y, z): + result = x + y + z + return result + "#); + + Ok(()) + } + + #[test] + fn test_uv_format_with_line_length() { + use ruff_formatter::LineWidth; + + let document = TextDocument::new( + r#" +def hello(very_long_parameter_name_1, very_long_parameter_name_2, very_long_parameter_name_3): + return very_long_parameter_name_1 + very_long_parameter_name_2 + very_long_parameter_name_3 +"# + .to_string(), + 0, + ); + + // Test with shorter line length + let formatter_settings = FormatterSettings { + line_width: LineWidth::try_from(60).unwrap(), + ..Default::default() + }; + + let result = format( + &document, + PySourceType::Python, + &formatter_settings, + Path::new("test.py"), + FormatBackend::Uv, + ) + .expect("Expected no errors when formatting with uv") + .expect("Expected formatting changes"); + + // With line length 60, the function should be wrapped + assert_snapshot!(result, @r#" + def hello( + very_long_parameter_name_1, + very_long_parameter_name_2, + very_long_parameter_name_3, + ): + return ( + very_long_parameter_name_1 + + very_long_parameter_name_2 + + very_long_parameter_name_3 + ) + "#); + } + + #[test] + fn test_uv_format_with_indent_style() { + use ruff_formatter::IndentStyle; + + let document = TextDocument::new( + r#" +def hello(): + if True: + print("Hello") + if False: + print("World") +"# + .to_string(), + 0, + ); + + // Test with tabs instead of spaces + let formatter_settings = FormatterSettings { + indent_style: IndentStyle::Tab, + ..Default::default() + }; + + let result = format( + &document, + PySourceType::Python, + &formatter_settings, + Path::new("test.py"), + FormatBackend::Uv, + ) + .expect("Expected no errors when formatting with uv") + .expect("Expected formatting changes"); + + // Should have formatting changes (spaces to tabs) + assert_snapshot!(result, @r#" + def hello(): + if True: + print("Hello") + if False: + print("World") + "#); + } + + #[test] + fn test_uv_format_syntax_error() { + let document = TextDocument::new( + r#" +def broken(: + pass +"# + .to_string(), + 0, + ); + + // uv should return None for syntax errors (as indicated by the TODO comment) + let result = format( + &document, + PySourceType::Python, + &FormatterSettings::default(), + Path::new("test.py"), + FormatBackend::Uv, + ) + .expect("Expected no errors from format function"); + + // Should return None since the syntax is invalid + assert_eq!(result, None, "Expected None for syntax error"); + } + + #[test] + fn test_uv_format_with_quote_style() { + use ruff_python_formatter::QuoteStyle; + + let document = TextDocument::new( + r#" +x = "hello" +y = 'world' +z = '''multi +line''' +"# + .to_string(), + 0, + ); + + // Test with single quotes + let formatter_settings = FormatterSettings { + quote_style: QuoteStyle::Single, + ..Default::default() + }; + + let result = format( + &document, + PySourceType::Python, + &formatter_settings, + Path::new("test.py"), + FormatBackend::Uv, + ) + .expect("Expected no errors when formatting with uv") + .expect("Expected formatting changes"); + + assert_snapshot!(result, @r#" + x = 'hello' + y = 'world' + z = """multi + line""" + "#); + } + + #[test] + fn test_uv_format_with_magic_trailing_comma() { + use ruff_python_formatter::MagicTrailingComma; + + let document = TextDocument::new( + r#" +foo = [ + 1, + 2, + 3, +] + +bar = [1, 2, 3,] +"# + .to_string(), + 0, + ); + + // Test with ignore magic trailing comma + let formatter_settings = FormatterSettings { + magic_trailing_comma: MagicTrailingComma::Ignore, + ..Default::default() + }; + + let result = format( + &document, + PySourceType::Python, + &formatter_settings, + Path::new("test.py"), + FormatBackend::Uv, + ) + .expect("Expected no errors when formatting with uv") + .expect("Expected formatting changes"); + + assert_snapshot!(result, @r#" + foo = [1, 2, 3] + + bar = [1, 2, 3] + "#); + } + } } diff --git a/crates/ruff_server/src/server/api/requests/format.rs b/crates/ruff_server/src/server/api/requests/format.rs index 9066111217..407c5eb229 100644 --- a/crates/ruff_server/src/server/api/requests/format.rs +++ b/crates/ruff_server/src/server/api/requests/format.rs @@ -33,6 +33,10 @@ impl super::BackgroundDocumentRequestHandler for Format { pub(super) fn format_full_document(snapshot: &DocumentSnapshot) -> Result { let mut fixes = Fixes::default(); let query = snapshot.query(); + let backend = snapshot + .client_settings() + .editor_settings() + .format_backend(); match snapshot.query() { DocumentQuery::Notebook { notebook, .. } => { @@ -41,7 +45,7 @@ pub(super) fn format_full_document(snapshot: &DocumentSnapshot) -> Result .map(|url| (url.clone(), notebook.cell_document_by_uri(url).unwrap())) { if let Some(changes) = - format_text_document(text_document, query, snapshot.encoding(), true)? + format_text_document(text_document, query, snapshot.encoding(), true, backend)? { fixes.insert(url, changes); } @@ -49,7 +53,7 @@ pub(super) fn format_full_document(snapshot: &DocumentSnapshot) -> Result } DocumentQuery::Text { document, .. } => { if let Some(changes) = - format_text_document(document, query, snapshot.encoding(), false)? + format_text_document(document, query, snapshot.encoding(), false, backend)? { fixes.insert(snapshot.query().make_key().into_url(), changes); } @@ -68,11 +72,16 @@ pub(super) fn format_document(snapshot: &DocumentSnapshot) -> Result Result { let settings = query.settings(); let file_path = query.virtual_file_path(); @@ -101,6 +111,7 @@ fn format_text_document( query.source_type(), &settings.formatter, &file_path, + backend, ) .with_failure_code(lsp_server::ErrorCode::InternalError)?; let Some(mut formatted) = formatted else { diff --git a/crates/ruff_server/src/server/api/requests/format_range.rs b/crates/ruff_server/src/server/api/requests/format_range.rs index 4e4cddae1a..d1d98583a9 100644 --- a/crates/ruff_server/src/server/api/requests/format_range.rs +++ b/crates/ruff_server/src/server/api/requests/format_range.rs @@ -36,7 +36,11 @@ fn format_document_range( .context("Failed to get text document for the format range request") .unwrap(); let query = snapshot.query(); - format_text_document_range(text_document, range, query, snapshot.encoding()) + let backend = snapshot + .client_settings() + .editor_settings() + .format_backend(); + format_text_document_range(text_document, range, query, snapshot.encoding(), backend) } /// Formats the specified [`Range`] in the [`TextDocument`]. @@ -45,6 +49,7 @@ fn format_text_document_range( range: Range, query: &DocumentQuery, encoding: PositionEncoding, + backend: crate::format::FormatBackend, ) -> Result { let settings = query.settings(); let file_path = query.virtual_file_path(); @@ -68,6 +73,7 @@ fn format_text_document_range( &settings.formatter, range, &file_path, + backend, ) .with_failure_code(lsp_server::ErrorCode::InternalError)?; diff --git a/crates/ruff_server/src/session/index/ruff_settings.rs b/crates/ruff_server/src/session/index/ruff_settings.rs index 658a2a08cc..93dc739d87 100644 --- a/crates/ruff_server/src/session/index/ruff_settings.rs +++ b/crates/ruff_server/src/session/index/ruff_settings.rs @@ -401,6 +401,7 @@ impl ConfigurationTransformer for EditorConfigurationTransformer<'_> { configuration, format_preview, lint_preview, + format_backend: _, select, extend_select, ignore, diff --git a/crates/ruff_server/src/session/options.rs b/crates/ruff_server/src/session/options.rs index 0a23e712c7..dba88c99ae 100644 --- a/crates/ruff_server/src/session/options.rs +++ b/crates/ruff_server/src/session/options.rs @@ -7,9 +7,12 @@ use serde_json::{Map, Value}; use ruff_linter::{RuleSelector, line_width::LineLength, rule_selector::ParseError}; -use crate::session::{ - Client, - settings::{ClientSettings, EditorSettings, GlobalClientSettings, ResolvedConfiguration}, +use crate::{ + format::FormatBackend, + session::{ + Client, + settings::{ClientSettings, EditorSettings, GlobalClientSettings, ResolvedConfiguration}, + }, }; pub(crate) type WorkspaceOptionsMap = FxHashMap; @@ -124,6 +127,7 @@ impl ClientOptions { configuration, lint_preview: lint.preview, format_preview: format.preview, + format_backend: format.backend, select: lint.select.and_then(|select| { Self::resolve_rules( &select, @@ -283,11 +287,13 @@ impl Combine for LintOptions { #[serde(rename_all = "camelCase")] struct FormatOptions { preview: Option, + backend: Option, } impl Combine for FormatOptions { fn combine_with(&mut self, other: Self) { self.preview.combine_with(other.preview); + self.backend.combine_with(other.backend); } } @@ -443,6 +449,12 @@ pub(crate) trait Combine { fn combine_with(&mut self, other: Self); } +impl Combine for FormatBackend { + fn combine_with(&mut self, other: Self) { + *self = other; + } +} + impl Combine for Option where T: Combine, @@ -584,6 +596,7 @@ mod tests { format: Some( FormatOptions { preview: None, + backend: None, }, ), code_action: Some( @@ -640,6 +653,7 @@ mod tests { format: Some( FormatOptions { preview: None, + backend: None, }, ), code_action: Some( @@ -704,6 +718,7 @@ mod tests { format: Some( FormatOptions { preview: None, + backend: None, }, ), code_action: Some( @@ -782,6 +797,7 @@ mod tests { configuration: None, lint_preview: Some(true), format_preview: None, + format_backend: None, select: Some(vec![ RuleSelector::Linter(Linter::Pyflakes), RuleSelector::Linter(Linter::Isort) @@ -819,6 +835,7 @@ mod tests { configuration: None, lint_preview: Some(false), format_preview: None, + format_backend: None, select: Some(vec![ RuleSelector::Linter(Linter::Pyflakes), RuleSelector::Linter(Linter::Isort) @@ -919,6 +936,7 @@ mod tests { configuration: None, lint_preview: None, format_preview: None, + format_backend: None, select: None, extend_select: None, ignore: Some(vec![RuleSelector::from_str("RUF001").unwrap()]), diff --git a/crates/ruff_server/src/session/settings.rs b/crates/ruff_server/src/session/settings.rs index 94cf41c827..1aff2b0eca 100644 --- a/crates/ruff_server/src/session/settings.rs +++ b/crates/ruff_server/src/session/settings.rs @@ -8,6 +8,7 @@ use ruff_workspace::options::Options; use crate::{ ClientOptions, + format::FormatBackend, session::{ Client, options::{ClientConfiguration, ConfigurationPreference}, @@ -84,6 +85,7 @@ pub(crate) struct EditorSettings { pub(super) configuration: Option, pub(super) lint_preview: Option, pub(super) format_preview: Option, + pub(super) format_backend: Option, pub(super) select: Option>, pub(super) extend_select: Option>, pub(super) ignore: Option>, @@ -163,3 +165,9 @@ impl ClientSettings { &self.editor_settings } } + +impl EditorSettings { + pub(crate) fn format_backend(&self) -> FormatBackend { + self.format_backend.unwrap_or_default() + } +} diff --git a/docs/editors/settings.md b/docs/editors/settings.md index d15d145ef0..035dd23852 100644 --- a/docs/editors/settings.md +++ b/docs/editors/settings.md @@ -971,6 +971,62 @@ Whether to enable Ruff's preview mode when formatting. } ``` +### `backend` {: #format_backend } + +The backend to use for formatting files. Following options are available: + +- `"internal"`: Use the built-in Ruff formatter +- `"uv"`: Use uv for formatting (requires uv >= 0.8.13) + +For `internal`, the formatter version will match the selected Ruff version while for `uv`, the +formatter version may differ. + +**Default value**: `"internal"` + +**Type**: `"internal" | "uv"` + +**Example usage**: + +=== "VS Code" + + ```json + { + "ruff.format.backend": "uv" + } + ``` + +=== "Neovim" + + ```lua + require('lspconfig').ruff.setup { + init_options = { + settings = { + format = { + backend = "uv" + } + } + } + } + ``` + +=== "Zed" + + ```json + { + "lsp": { + "ruff": { + "initialization_options": { + "settings": { + "format": { + "backend": "uv" + } + } + } + } + } + } + ``` + ## VS Code specific Additionally, the Ruff extension provides the following settings specific to VS Code. These settings