This commit is contained in:
cristian64 2025-11-16 19:23:02 +01:00 committed by GitHub
commit 6952d06812
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 386 additions and 45 deletions

View file

@ -554,8 +554,10 @@ pub struct FormatCommand {
/// - The start position is optional. You can write `--range=-3` to format the first three lines of the document.
///
/// The option can only be used when formatting a single file. Range formatting of notebooks is unsupported.
///
/// The option can be specified multiple times to format more than one block.
#[clap(long, help_heading = "Editor options", verbatim_doc_comment)]
pub range: Option<FormatRange>,
pub range: Option<Vec<FormatRange>>,
/// Exit with a non-zero status code if any files were modified via format, even if all files were formatted successfully.
#[arg(long, help_heading = "Miscellaneous", alias = "exit-non-zero-on-fix")]
@ -802,7 +804,7 @@ impl FormatCommand {
files: self.files,
no_cache: self.no_cache,
stdin_filename: self.stdin_filename,
range: self.range,
ranges: self.range.unwrap_or_default(),
exit_non_zero_on_format: self.exit_non_zero_on_format,
};
@ -1097,7 +1099,7 @@ pub struct FormatArguments {
pub diff: bool,
pub files: Vec<PathBuf>,
pub stdin_filename: Option<PathBuf>,
pub range: Option<FormatRange>,
pub ranges: Vec<FormatRange>,
pub exit_non_zero_on_format: bool,
}

View file

@ -1021,12 +1021,13 @@ mod tests {
cache: &Cache,
) -> Result<FormatResult, FormatCommandError> {
let file_path = self.package_root.join(path);
let ranges = Vec::new();
format_path(
&file_path,
&self.settings.formatter,
PySourceType::Python,
FormatMode::Write,
None,
&ranges,
Some(cache),
)
}

View file

@ -32,7 +32,9 @@ use ruff_linter::rules::flake8_quotes::settings::Quote;
use ruff_linter::source_kind::{SourceError, SourceKind};
use ruff_linter::warn_user_once;
use ruff_python_ast::{PySourceType, SourceType};
use ruff_python_formatter::{FormatModuleError, QuoteStyle, format_module_source, format_range};
use ruff_python_formatter::{
FormatModuleError, QuoteStyle, expand_and_collapse_ranges, format_module_source, format_range,
};
use ruff_source_file::{LineIndex, LineRanges, OneIndexed, SourceFileBuilder};
use ruff_text_size::{TextLen, TextRange, TextSize};
use ruff_workspace::FormatterSettings;
@ -84,7 +86,7 @@ pub(crate) fn format(
return Ok(ExitStatus::Success);
}
if cli.range.is_some() && paths.len() > 1 {
if !cli.ranges.is_empty() && paths.len() > 1 {
return Err(anyhow::anyhow!(
"The `--range` option is only supported when formatting a single file but the specified paths resolve to {} files.",
paths.len()
@ -160,7 +162,7 @@ pub(crate) fn format(
&settings.formatter,
source_type,
mode,
cli.range,
&cli.ranges,
cache,
)
}) {
@ -263,7 +265,7 @@ pub(crate) fn format_path(
settings: &FormatterSettings,
source_type: PySourceType,
mode: FormatMode,
range: Option<FormatRange>,
ranges: &[FormatRange],
cache: Option<&Cache>,
) -> Result<FormatResult, FormatCommandError> {
if let Some(cache) = cache {
@ -289,20 +291,37 @@ pub(crate) fn format_path(
};
// Don't write back to the cache if formatting a range.
let cache = cache.filter(|_| range.is_none());
let cache = cache.filter(|_| ranges.is_empty());
// Format the source.
let format_result = match format_source(&unformatted, source_type, Some(path), settings, range)?
{
FormattedSource::Formatted(formatted) => match mode {
FormatMode::Write => {
let mut writer = File::create(path).map_err(|err| {
FormatCommandError::Write(Some(path.to_path_buf()), err.into())
})?;
formatted
.write(&mut writer)
.map_err(|err| FormatCommandError::Write(Some(path.to_path_buf()), err))?;
let format_result =
match format_source(&unformatted, source_type, Some(path), settings, ranges)? {
FormattedSource::Formatted(formatted) => match mode {
FormatMode::Write => {
let mut writer = File::create(path).map_err(|err| {
FormatCommandError::Write(Some(path.to_path_buf()), err.into())
})?;
formatted
.write(&mut writer)
.map_err(|err| FormatCommandError::Write(Some(path.to_path_buf()), err))?;
if let Some(cache) = cache {
if let Ok(cache_key) = FileCacheKey::from_path(path) {
let relative_path = cache
.relative_path(path)
.expect("wrong package cache for file");
cache.set_formatted(relative_path.to_path_buf(), &cache_key);
}
}
FormatResult::Formatted
}
FormatMode::Check | FormatMode::Diff => FormatResult::Diff {
unformatted,
formatted,
},
},
FormattedSource::Unchanged => {
if let Some(cache) = cache {
if let Ok(cache_key) = FileCacheKey::from_path(path) {
let relative_path = cache
@ -312,26 +331,9 @@ pub(crate) fn format_path(
}
}
FormatResult::Formatted
FormatResult::Unchanged
}
FormatMode::Check | FormatMode::Diff => FormatResult::Diff {
unformatted,
formatted,
},
},
FormattedSource::Unchanged => {
if let Some(cache) = cache {
if let Ok(cache_key) = FileCacheKey::from_path(path) {
let relative_path = cache
.relative_path(path)
.expect("wrong package cache for file");
cache.set_formatted(relative_path.to_path_buf(), &cache_key);
}
}
FormatResult::Unchanged
}
};
};
Ok(format_result)
}
@ -360,13 +362,14 @@ pub(crate) fn format_source(
source_type: PySourceType,
path: Option<&Path>,
settings: &FormatterSettings,
range: Option<FormatRange>,
ranges: &[FormatRange],
) -> Result<FormattedSource, FormatCommandError> {
match &source_kind {
SourceKind::Python(unformatted) => {
let options = settings.to_format_options(source_type, unformatted, path);
let formatted = if let Some(range) = range {
let formatted = if ranges.len() == 1 {
let range = &ranges[0];
let line_index = LineIndex::from_source_text(unformatted);
let byte_range = range.to_text_range(unformatted, &line_index);
format_range(unformatted, byte_range, options).map(|formatted_range| {
@ -378,6 +381,55 @@ pub(crate) fn format_source(
formatted
})
} else if !ranges.is_empty() {
// Convert to text ranges, and expand and collapse.
let line_index = LineIndex::from_source_text(unformatted);
let byte_ranges: Vec<TextRange> = ranges
.iter()
.map(|range| range.to_text_range(unformatted, &line_index))
.collect();
let treated_ranges: Vec<TextRange> =
expand_and_collapse_ranges(unformatted, &byte_ranges, options.clone())
.map_err(|err| {
if let FormatModuleError::ParseError(err) = err {
DisplayParseError::from_source_kind(
err,
path.map(Path::to_path_buf),
source_kind,
)
.into()
} else {
FormatCommandError::Format(path.map(Path::to_path_buf), err)
}
})?;
let mut result = Ok(String::new());
let mut unformatted = unformatted.clone();
// Format in each text range starting from the bottom towards the top.
for byte_range in treated_ranges.iter().rev() {
result = format_range(&unformatted, *byte_range, options.clone()).map(
|formatted_range| {
let mut formatted = unformatted.to_string();
formatted.replace_range(
std::ops::Range::<usize>::from(formatted_range.source_range()),
formatted_range.as_code(),
);
unformatted.clone_from(&formatted);
formatted
},
);
// Formatting will be halted on first error.
if result.is_err() {
break;
}
}
result
} else {
// Using `Printed::into_code` requires adding `ruff_formatter` as a direct dependency, and I suspect that Rust can optimize the closure away regardless.
#[expect(clippy::redundant_closure_for_method_calls)]
@ -408,7 +460,7 @@ pub(crate) fn format_source(
return Ok(FormattedSource::Unchanged);
}
if range.is_some() {
if !ranges.is_empty() {
return Err(FormatCommandError::RangeFormatNotebook(
path.map(Path::to_path_buf),
));

View file

@ -65,7 +65,7 @@ pub(crate) fn format_stdin(
};
// Format the file.
match format_source_code(path, cli.range, settings, source_type, mode) {
match format_source_code(path, &cli.ranges, settings, source_type, mode) {
Ok(result) => match mode {
FormatMode::Write => Ok(ExitStatus::Success),
FormatMode::Check | FormatMode::Diff => {
@ -86,7 +86,7 @@ pub(crate) fn format_stdin(
/// Format source code read from `stdin`.
fn format_source_code(
path: Option<&Path>,
range: Option<FormatRange>,
ranges: &[FormatRange],
settings: &FormatterSettings,
source_type: PySourceType,
mode: FormatMode,
@ -104,7 +104,7 @@ fn format_source_code(
};
// Format the source.
let formatted = format_source(&source_kind, source_type, path, settings, range)?;
let formatted = format_source(&source_kind, source_type, path, settings, ranges)?;
match &formatted {
FormattedSource::Formatted(formatted) => match mode {

View file

@ -2158,6 +2158,178 @@ def foo(arg1, arg2,):
Ok(())
}
#[test]
fn multiple_ranges() -> Result<()> {
let test = CliTest::new()?;
assert_cmd_snapshot!(test.format_command()
.args(["--isolated", "--stdin-filename", "test.py", "--range=7-9", "--range=2-3", "--range=4-6"])
.arg("-")
.pass_stdin(r#"
a = [2 ]
b = [3 ]
c = [4 ]
d = [5 ]
e = [6 ]
f = [7 ]
g = [8 ]
h = [9 ]
"#), @r#"
success: true
exit_code: 0
----- stdout -----
a = [2]
b = [3 ]
c = [4]
d = [5]
e = [6 ]
f = [7]
g = [8]
h = [9 ]
----- stderr -----
"#);
Ok(())
}
#[test]
fn multiple_overlapping_ranges() -> Result<()> {
let test = CliTest::new()?;
assert_cmd_snapshot!(test.format_command()
.args(["--isolated", "--stdin-filename", "test.py", "--range=5-9", "--range=5-8", "--range=4-6"])
.arg("-")
.pass_stdin(r#"
a = [2 ]
b = [3 ]
c = [4 ]
d = [5 ]
e = [6 ]
f = [7 ]
g = [8 ]
h = [9 ]
"#), @r#"
success: true
exit_code: 0
----- stdout -----
a = [2 ]
b = [3 ]
c = [4]
d = [5]
e = [6]
f = [7]
g = [8]
h = [9 ]
----- stderr -----
"#);
Ok(())
}
#[test]
fn multiple_ranges_in_same_statement() -> Result<()> {
let test = CliTest::new()?;
assert_cmd_snapshot!(test.format_command()
.args(["--isolated", "--stdin-filename", "test.py", "--range=3-4", "--range=6-7"])
.arg("-")
.pass_stdin(r#"
a = (2,
3 ,
4,
5,
6 ,
7,
8,
9
)
b = ('a', 'b', 'c', 'd',)
"#), @r#"
success: true
exit_code: 0
----- stdout -----
a = (2, 3, 4, 5, 6, 7, 8, 9)
b = ('a', 'b', 'c', 'd',)
----- stderr -----
"#);
Ok(())
}
#[test]
fn multiple_overlapping_ranges_in_adjacent_statement() -> Result<()> {
let test = CliTest::new()?;
assert_cmd_snapshot!(test.format_command()
.args(["--isolated", "--stdin-filename", "test.py", "--range=2-3", "--range=4-7", "--range=11-12:35", "--range=12:74-17", "--range=21-22", "--range=29-30", "--range=25-30"])
.arg("-")
.pass_stdin(r#"
def partition(array, begin,
end):
pivot = begin
for i in range( begin+1,
end + 1):
if array[i] <= array[begin]:
pivot += 1
array[i], array[ pivot ] = array[
pivot], array[ i]
array[pivot], array[begin ] = array[begin
], array[
pivot
]
return pivot
def quicksort(array, begin=0, end=None):
if end is None:
end = len( array) - 1
def _quicksort(array, begin , end):
if begin >= end:
return
pivot = partition(array, begin, end)
_quicksort(array, begin, pivot-1)
_quicksort(array, pivot+1, end)
return _quicksort(array, begin, end)
"#), @r#"
success: true
exit_code: 0
----- stdout -----
def partition(array, begin, end):
pivot = begin
for i in range(begin + 1, end + 1):
if array[i] <= array[begin]:
pivot += 1
array[i], array[pivot] = array[pivot], array[i]
array[pivot], array[begin] = array[begin], array[pivot]
return pivot
def quicksort(array, begin=0, end=None):
if end is None:
end = len( array) - 1
def _quicksort(array, begin , end):
if begin >= end:
return
pivot = partition(array, begin, end)
_quicksort(array, begin, pivot-1)
_quicksort(array, pivot+1, end)
return _quicksort(array, begin, end)
----- stderr -----
"#);
Ok(())
}
#[test]
fn range_missing_line() -> Result<()> {
let test = CliTest::new()?;

View file

@ -5,7 +5,7 @@ use ruff_db::source::source_text;
use thiserror::Error;
use tracing::Level;
pub use range::format_range;
pub use range::{expand_and_collapse_ranges, format_range};
use ruff_formatter::prelude::*;
use ruff_formatter::{FormatError, Formatted, PrintError, Printed, SourceCode, format, write};
use ruff_python_ast::{AnyNodeRef, Mod};

View file

@ -117,6 +117,83 @@ pub fn format_range(
Ok(printed.slice_range(narrowed_range, source))
}
/// Expands the text ranges into their enclosing nodes, and collapses those that overlap or touch.
pub fn expand_and_collapse_ranges(
source: &str,
ranges: &[TextRange],
options: PyFormatOptions,
) -> Result<Vec<TextRange>, FormatModuleError> {
for range in ranges {
// Error if the specified range lies outside of the source file.
if source.text_len() < range.end() {
return Err(FormatModuleError::FormatError(FormatError::RangeError {
input: *range,
tree: TextRange::up_to(source.text_len()),
}));
}
// If any range covers the entire file, that becomes the only range and no processing is
// required.
if *range == TextRange::up_to(source.text_len()) {
return Ok(vec![*range]);
}
}
let parsed = parse(source, ParseOptions::from(options.source_type()))?;
let source_code = SourceCode::new(source);
let comment_ranges = CommentRanges::from(parsed.tokens());
let comments = Comments::from_ast(parsed.syntax(), source_code, &comment_ranges);
let context = PyFormatContext::new(
options.with_source_map_generation(SourceMapGeneration::Enabled),
source,
comments,
parsed.tokens(),
);
let mut expanded_ranges: Vec<TextRange> = Vec::new();
for range in ranges {
if range.is_empty() {
continue;
}
let enclosing_node =
match find_enclosing_node(*range, AnyNodeRef::from(parsed.syntax()), &context) {
EnclosingNode::Node {
node,
indent_level: _,
} => node,
EnclosingNode::Suppressed => {
// The entire range falls into a suppressed range. There's nothing to format.
continue;
}
};
let narrowed_range = narrow_range(*range, enclosing_node, &context);
assert_valid_char_boundaries(narrowed_range, source);
expanded_ranges.push(narrowed_range);
}
if expanded_ranges.is_empty() {
return Ok(expanded_ranges);
}
expanded_ranges.sort();
let mut collapsed_ranges: Vec<TextRange> = Vec::new();
collapsed_ranges.push(expanded_ranges[0]);
for range in expanded_ranges {
let back_range_index = collapsed_ranges.len() - 1;
let back_range = &collapsed_ranges[back_range_index];
if back_range.overlap_or_touch(&range) {
collapsed_ranges[back_range_index] = back_range.joined(&range);
} else {
collapsed_ranges.push(range);
}
}
Ok(collapsed_ranges)
}
/// Finds the node with the minimum covering range of `range`.
///
/// It traverses the tree and returns the deepest node that fully encloses `range`.

View file

@ -269,6 +269,30 @@ impl TextRange {
self.cover(TextRange::empty(offset))
}
/// Checks if the text ranges overlap or touch each other.
#[must_use]
pub fn overlap_or_touch(&self, other: &Self) -> bool {
(self.start <= other.start && other.start <= self.end)
|| (other.start <= self.start && self.start <= other.end)
}
/// Joins the two text ranges.
#[must_use]
pub fn joined(&self, other: &Self) -> Self {
Self {
start: if self.start < other.start {
self.start
} else {
other.start
},
end: if self.end > other.end {
self.end
} else {
other.end
},
}
}
/// Add an offset to this range.
///
/// Note that this is not appropriate for changing where a `TextRange` is
@ -441,6 +465,19 @@ impl TextRange {
}
}
impl PartialOrd for TextRange {
#[inline]
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TextRange {
fn cmp(&self, other: &Self) -> Ordering {
self.start.cmp(&other.start).then(self.end.cmp(&other.end))
}
}
impl Index<TextRange> for str {
type Output = str;
#[inline]