Add support for using uv as an alternative formatter backend (#19665)

This adds a new `backend: internal | uv` option to the LSP
`FormatOptions` allowing users to perform document and range formatting
operations though uv. The idea here is to prototype a solution for users
to transition to a `uv format` command without encountering version
mismatches (and consequently, formatting differences) between the LSP's
version of `ruff` and uv's version of `ruff`.

The primarily alternative to this would be to use uv to discover the
`ruff` version used to start the LSP in the first place. However, this
would increase the scope of a minimal `uv format` command beyond "run a
formatter", and raise larger questions about how uv should be used to
coordinate toolchain discovery. I think those are good things to
explore, but I'm hesitant to let them block a `uv format`
implementation. Another downside of using uv to discover `ruff`, is that
it needs to be implemented _outside_ the LSP; e.g., we'd need to change
the instructions on how to run the LSP and implement it in each editor
integration, like the VS Code plugin.

---------

Co-authored-by: Dhruv Manilawala <dhruvmanila@gmail.com>
This commit is contained in:
Zanie Blue 2025-09-09 10:09:53 -05:00 committed by GitHub
parent 79706a2e26
commit 9cdac2d6fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 696 additions and 22 deletions

View file

@ -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:

View file

@ -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())
}
}

View file

@ -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",
}
}
}

View file

@ -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",
})
}
}

View file

@ -50,5 +50,8 @@ insta = { workspace = true }
[target.'cfg(target_vendor = "apple")'.dependencies]
libc = { workspace = true }
[features]
test-uv = []
[lints]
workspace = true

View file

@ -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<Option<String>> {
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<Option<String>> {
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<Option<String>> {
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<Option<PrintedRange>> {
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<Option<PrintedRange>> {
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<Option<PrintedRange>> {
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<PyFormatOptions> 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<Option<String>> {
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<Option<String>> {
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<Option<String>> {
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]
"#);
}
}
}

View file

@ -33,6 +33,10 @@ impl super::BackgroundDocumentRequestHandler for Format {
pub(super) fn format_full_document(snapshot: &DocumentSnapshot) -> Result<Fixes> {
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<Fixes>
.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<Fixes>
}
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<super::Form
.context("Failed to get text document for the format request")
.unwrap();
let query = snapshot.query();
let backend = snapshot
.client_settings()
.editor_settings()
.format_backend();
format_text_document(
text_document,
query,
snapshot.encoding(),
query.as_notebook().is_some(),
backend,
)
}
@ -81,6 +90,7 @@ fn format_text_document(
query: &DocumentQuery,
encoding: PositionEncoding,
is_notebook: bool,
backend: crate::format::FormatBackend,
) -> Result<super::FormatResponse> {
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 {

View file

@ -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<super::FormatResponse> {
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)?;

View file

@ -401,6 +401,7 @@ impl ConfigurationTransformer for EditorConfigurationTransformer<'_> {
configuration,
format_preview,
lint_preview,
format_backend: _,
select,
extend_select,
ignore,

View file

@ -7,9 +7,12 @@ use serde_json::{Map, Value};
use ruff_linter::{RuleSelector, line_width::LineLength, rule_selector::ParseError};
use crate::session::{
use crate::{
format::FormatBackend,
session::{
Client,
settings::{ClientSettings, EditorSettings, GlobalClientSettings, ResolvedConfiguration},
},
};
pub(crate) type WorkspaceOptionsMap = FxHashMap<Url, ClientOptions>;
@ -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<bool>,
backend: Option<FormatBackend>,
}
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<T> Combine for Option<T>
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()]),

View file

@ -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<ResolvedConfiguration>,
pub(super) lint_preview: Option<bool>,
pub(super) format_preview: Option<bool>,
pub(super) format_backend: Option<FormatBackend>,
pub(super) select: Option<Vec<RuleSelector>>,
pub(super) extend_select: Option<Vec<RuleSelector>>,
pub(super) ignore: Option<Vec<RuleSelector>>,
@ -163,3 +165,9 @@ impl ClientSettings {
&self.editor_settings
}
}
impl EditorSettings {
pub(crate) fn format_backend(&self) -> FormatBackend {
self.format_backend.unwrap_or_default()
}
}

View file

@ -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