mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-29 13:24:57 +00:00
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:
parent
79706a2e26
commit
9cdac2d6fb
12 changed files with 696 additions and 22 deletions
12
.github/workflows/ci.yaml
vendored
12
.github/workflows/ci.yaml
vendored
|
@ -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:
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,5 +50,8 @@ insta = { workspace = true }
|
|||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[features]
|
||||
test-uv = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
@ -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]
|
||||
"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)?;
|
||||
|
||||
|
|
|
@ -401,6 +401,7 @@ impl ConfigurationTransformer for EditorConfigurationTransformer<'_> {
|
|||
configuration,
|
||||
format_preview,
|
||||
lint_preview,
|
||||
format_backend: _,
|
||||
select,
|
||||
extend_select,
|
||||
ignore,
|
||||
|
|
|
@ -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()]),
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue